yaver-feedback-react-native 0.8.11 → 0.8.13

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.
@@ -12,6 +12,7 @@ import {
12
12
  Text,
13
13
  TextInput,
14
14
  View,
15
+ useWindowDimensions,
15
16
  } from 'react-native';
16
17
  import { YaverFeedback } from './YaverFeedback';
17
18
  import {
@@ -27,6 +28,8 @@ import { uploadFeedback } from './upload';
27
28
  import { DeviceInfo, FeedbackBundle } from './types';
28
29
  import { AuthOverlay } from './AuthOverlay';
29
30
  import { QuickActionIcon } from './QuickActionIcon';
31
+ import { VibeChatScreen } from './VibeChatScreen';
32
+ import { DeployPanel } from './DeployPanel';
30
33
  import { listReachableDevices, RemoteDevice } from './auth';
31
34
  import {
32
35
  QUICK_ICON_COLOR_PRESETS,
@@ -62,6 +65,11 @@ type MachineCardState = {
62
65
  };
63
66
 
64
67
  export const FeedbackModal: React.FC = () => {
68
+ const { width: winW, height: winH } = useWindowDimensions();
69
+ const isTablet = Math.min(winW, winH) >= 600;
70
+ // Tablet color/icon picker fans out to 5/6 cols — 31% (3-col)
71
+ // looks empty on a 1024pt iPad. Mobile keeps 3-col.
72
+ const iconOptionWidthOverride = isTablet ? '18%' : undefined;
65
73
  const [visible, setVisible] = useState(false);
66
74
  const [action, setAction] = useState<ActionState>('idle');
67
75
  const [error, setError] = useState<string | null>(null);
@@ -79,6 +87,7 @@ export const FeedbackModal: React.FC = () => {
79
87
  // "pick something for me" prompt (which in 0.7.13 pointed Claude at
80
88
  // the wrong project because the matcher grepped the prompt itself).
81
89
  const [showVibeInput, setShowVibeInput] = useState(false);
90
+ const [showDeploy, setShowDeploy] = useState(false);
82
91
  const [vibePrompt, setVibePrompt] = useState('');
83
92
  const [lastVibeTaskId, setLastVibeTaskId] = useState<string | null>(null);
84
93
  const [quickIconColorPreset, setQuickIconColorPreset] =
@@ -415,6 +424,7 @@ export const FeedbackModal: React.FC = () => {
415
424
  config.agentUrl,
416
425
  config.authToken ?? '',
417
426
  bundle,
427
+ YaverFeedback.getRelayPassword(),
418
428
  );
419
429
  // The agent returns the new report id as `id` (see
420
430
  // feedback_http.go::ReceiveFeedback). Trigger the fix loop if we got
@@ -534,6 +544,17 @@ export const FeedbackModal: React.FC = () => {
534
544
  }
535
545
  }, [showVibeInput, vibePrompt]);
536
546
 
547
+ // Hold the active vibe-chat session — set when handleVibingSubmit
548
+ // returns a fresh taskId. Renders <VibeChatScreen> which streams the
549
+ // SSE transcript, supports multi-turn follow-ups via /tasks/{id}/
550
+ // resume, and exposes a Reload button. Mirrors the in-Yaver native
551
+ // pane's transcript-mode behaviour, just rendered in RN here.
552
+ const [activeVibe, setActiveVibe] = useState<{
553
+ taskId: string;
554
+ initialPrompt: string;
555
+ } | null>(null);
556
+ const [includeScreenshot, setIncludeScreenshot] = useState<boolean>(true);
557
+
537
558
  const handleVibingSubmit = useCallback(async () => {
538
559
  const client = YaverFeedback.getP2PClient();
539
560
  if (!client) {
@@ -553,13 +574,47 @@ export const FeedbackModal: React.FC = () => {
553
574
  .join('\n')
554
575
  : '';
555
576
  const userPrompt = vibePrompt.trim();
556
- const prompt = userPrompt
577
+ const promptText = userPrompt
557
578
  ? userPrompt + errNote
558
579
  : 'Pick the next small improvement or fix for this app based on recent activity and the current screen.' +
559
580
  errNote;
560
- const result = await client.vibing(prompt);
581
+
582
+ // Optional screenshot — captured from the host app's window.
583
+ // captureScreenshotBase64 returns null when react-native-view-
584
+ // shot isn't installed; we skip the screenshot rather than
585
+ // abort the whole feedback in that case.
586
+ let screenshotBase64: string | undefined;
587
+ if (includeScreenshot) {
588
+ const cap = await import('./capture');
589
+ const captured = await cap.captureScreenshotBase64();
590
+ if (captured?.base64) {
591
+ screenshotBase64 = captured.base64;
592
+ }
593
+ }
594
+
595
+ // Resolve project context the same way reloadApp / vibing did.
596
+ const { resolveAppIdentity } = await import('./P2PClient');
597
+ const identity = resolveAppIdentity();
598
+
599
+ // Pull the user's preferred runner / model from local prefs.
600
+ // Both are optional — the agent falls back to whatever runner
601
+ // is signed in if neither is provided.
602
+ const prefs = await import('./preferences');
603
+ const preferredRunner = (await prefs.getPreferredRunner?.()) ?? null;
604
+ const preferredModel = (await prefs.getPreferredModel?.()) ?? null;
605
+
606
+ const result = await client.createFeedbackTask({
607
+ userPrompt: promptText,
608
+ projectName: identity.projectName,
609
+ projectPath: identity.projectPath,
610
+ runner: preferredRunner ?? undefined,
611
+ model: preferredModel ?? undefined,
612
+ screenshotBase64,
613
+ });
561
614
  setLastVibeTaskId(result.taskId);
562
- setToast(`Vibing task ${result.taskId.slice(0, 8)} created`);
615
+ // Hand off to VibeChatScreen — it streams the SSE transcript,
616
+ // accepts follow-ups, and surfaces a Reload button.
617
+ setActiveVibe({ taskId: result.taskId, initialPrompt: promptText });
563
618
  setVibePrompt('');
564
619
  setShowVibeInput(false);
565
620
  } catch (err: unknown) {
@@ -567,7 +622,7 @@ export const FeedbackModal: React.FC = () => {
567
622
  } finally {
568
623
  if (mountedRef.current) setAction('idle');
569
624
  }
570
- }, [vibePrompt]);
625
+ }, [vibePrompt, includeScreenshot]);
571
626
 
572
627
  /*
573
628
  const handleScreenRecording = useCallback(async () => {
@@ -577,6 +632,40 @@ export const FeedbackModal: React.FC = () => {
577
632
 
578
633
  const busy = action !== 'idle';
579
634
 
635
+ // Once the user fires off a vibe task, swap the entire modal body
636
+ // for the live chat screen. The chat manages its own SSE
637
+ // subscription, multi-turn follow-ups, and Reload button. Closing
638
+ // the chat returns to idle and clears the active vibe.
639
+ if (visible && activeVibe) {
640
+ const client = YaverFeedback.getP2PClient();
641
+ return (
642
+ <>
643
+ <AuthOverlay />
644
+ <QuickActionIcon />
645
+ <Modal
646
+ visible={visible}
647
+ animationType="slide"
648
+ transparent
649
+ onRequestClose={() => setActiveVibe(null)}
650
+ >
651
+ {client ? (
652
+ <VibeChatScreen
653
+ client={client}
654
+ initialTaskId={activeVibe.taskId}
655
+ initialUserPrompt={activeVibe.initialPrompt}
656
+ onClose={() => setActiveVibe(null)}
657
+ onReload={async () => {
658
+ const c = YaverFeedback.getP2PClient();
659
+ if (!c) throw new Error('Not connected');
660
+ await c.reloadApp();
661
+ }}
662
+ />
663
+ ) : null}
664
+ </Modal>
665
+ </>
666
+ );
667
+ }
668
+
580
669
  return (
581
670
  <>
582
671
  <AuthOverlay />
@@ -596,7 +685,21 @@ export const FeedbackModal: React.FC = () => {
596
685
  pointerEvents="box-none"
597
686
  >
598
687
  <Pressable
599
- style={styles.modal}
688
+ // Tablet: cap modal width and center as a card-style
689
+ // sheet rather than a phone bottom sheet that stretches
690
+ // across a 12.9" iPad. Phone behaviour unchanged.
691
+ style={[
692
+ styles.modal,
693
+ isTablet
694
+ ? {
695
+ width: '100%',
696
+ maxWidth: 640,
697
+ alignSelf: 'center',
698
+ borderTopLeftRadius: 22,
699
+ borderTopRightRadius: 22,
700
+ }
701
+ : null,
702
+ ]}
600
703
  onPress={(e) => {
601
704
  e.stopPropagation();
602
705
  Keyboard.dismiss();
@@ -702,6 +805,7 @@ export const FeedbackModal: React.FC = () => {
702
805
  }}
703
806
  style={[
704
807
  styles.iconOption,
808
+ iconOptionWidthOverride ? { width: iconOptionWidthOverride } : null,
705
809
  selected && styles.iconOptionSelected,
706
810
  ]}
707
811
  >
@@ -810,6 +914,23 @@ export const FeedbackModal: React.FC = () => {
810
914
  busy={action === 'capturing'}
811
915
  />
812
916
 
917
+ {/* Deploy — opens an inline panel that talks to
918
+ /fleet/deploy-options on the agent and lets the user
919
+ pick TestFlight / Play / Both, then a machine to run
920
+ it on. Capabilities (e.g. "Linux can't TestFlight")
921
+ come from the agent's doctor probes — no client-side
922
+ platform smarts here. */}
923
+ {!showDeploy ? (
924
+ <ActionRow
925
+ label="Deploy"
926
+ tint="#7f8cf7"
927
+ onPress={() => setShowDeploy(true)}
928
+ disabled={busy}
929
+ />
930
+ ) : (
931
+ <DeployPanel onClose={() => setShowDeploy(false)} />
932
+ )}
933
+
813
934
  {/* Remote sign-in buttons — trigger codex/claude device-auth
814
935
  on the selected agent without leaving the app. Opens a
815
936
  small native modal showing the verification URL + 8-char
@@ -11,6 +11,7 @@ import {
11
11
  TextInput,
12
12
  TouchableOpacity,
13
13
  View,
14
+ useWindowDimensions,
14
15
  } from 'react-native';
15
16
  import { YaverFeedback } from './YaverFeedback';
16
17
  import { FixReport } from './FixReport';
@@ -58,6 +59,15 @@ export interface FloatingButtonProps {
58
59
 
59
60
  const DEFAULT_SIZE = 40;
60
61
  const DEFAULT_COLOR = '#6366f1';
62
+
63
+ // Tablet detection — short-edge dp >= 600 means iPad / 7"+ Android
64
+ // tablet / Z Fold open. The SDK has no app-side responsive context
65
+ // to lean on (it's a guest in third-party apps), so we infer
66
+ // locally and bump the button + panel to tablet sizes.
67
+ const TABLET_SHORT_EDGE = 600;
68
+ function isTabletWindow(width: number, height: number): boolean {
69
+ return Math.min(width, height) >= TABLET_SHORT_EDGE;
70
+ }
61
71
  const DEFAULT_PANEL_BG = '#2d2d2d';
62
72
 
63
73
  /**
@@ -101,7 +111,16 @@ export const FloatingButton: React.FC<FloatingButtonProps> = ({
101
111
  healthCheckInterval = 5000,
102
112
  panelBackgroundColor,
103
113
  }) => {
104
- const { width: screenWidth } = Dimensions.get('window');
114
+ // Read window size live so the SDK overlay re-pins itself when
115
+ // the host app rotates or splits. The legacy snapshot via
116
+ // Dimensions.get only ran once and parked the button off-screen
117
+ // after orientation changes on iPad.
118
+ const { width: screenWidth, height: screenHeight } = useWindowDimensions();
119
+ const isTablet = isTabletWindow(screenWidth, screenHeight);
120
+ // Tablets get a larger touch target and a wider panel — phones
121
+ // keep the existing 40 / 280 defaults so guest apps aren't
122
+ // disrupted on small screens.
123
+ const effectiveSize = isTablet ? Math.max(size, 52) : size;
105
124
  const defaultX = initialPosition?.x ?? 10;
106
125
  const defaultY = initialPosition?.y ?? 90;
107
126
 
@@ -535,7 +554,12 @@ export const FloatingButton: React.FC<FloatingButtonProps> = ({
535
554
  const buttonIcon = icon ?? 'y';
536
555
  const btnBg = isConnected ? color : `${color}88`;
537
556
 
538
- const panelWidth = fullSize ? screenWidth - 24 : 280;
557
+ // Panel sizing tablets get a wider compact panel (420) and a
558
+ // capped full-size panel (max 720 instead of full window) so the
559
+ // overlay doesn't dwarf the host app on a 12.9" iPad.
560
+ const compactPanelWidth = isTablet ? 420 : 280;
561
+ const fullPanelWidth = isTablet ? Math.min(screenWidth - 24, 720) : screenWidth - 24;
562
+ const panelWidth = fullSize ? fullPanelWidth : compactPanelWidth;
539
563
 
540
564
  return (
541
565
  <Animated.View
@@ -730,7 +754,7 @@ export const FloatingButton: React.FC<FloatingButtonProps> = ({
730
754
  style={[
731
755
  s.button,
732
756
  isTerminal ? s.buttonTerminal : s.buttonMinimal,
733
- { backgroundColor: btnBg, width: size, height: size },
757
+ { backgroundColor: btnBg, width: effectiveSize, height: effectiveSize },
734
758
  !isTerminal && { borderRadius: size / 2 },
735
759
  ]}
736
760
  activeOpacity={0.7}
package/src/P2PClient.ts CHANGED
@@ -31,7 +31,7 @@ export interface ReloadAck {
31
31
  * NativeModules. None of the lookups throw — missing data just means
32
32
  * the agent will fall back to its own dev-server resolution.
33
33
  */
34
- function resolveAppIdentity(opts?: {
34
+ export function resolveAppIdentity(opts?: {
35
35
  projectName?: string;
36
36
  bundleId?: string;
37
37
  projectPath?: string;
@@ -807,6 +807,202 @@ export class P2PClient {
807
807
  }
808
808
 
809
809
  /** Internal helper for authenticated GET/POST requests. */
810
+ /**
811
+ * Convergence point for ALL feedback surfaces — Tasks tab, in-Yaver
812
+ * native pane, and this standalone SDK all POST the same shape to
813
+ * `/tasks`. Wraps the user's text with the shared prompt builder
814
+ * (see `_core/buildFeedbackPrompt`) so every surface conditions
815
+ * the AI the same way.
816
+ *
817
+ * Returns the agent's response payload (`taskId`, etc.) so callers
818
+ * can wire `streamTaskOutput()` next for live transcript.
819
+ *
820
+ * Inputs:
821
+ * - userPrompt what the user typed
822
+ * - projectName / path optional Hot-Reload project context
823
+ * - runner / model optional preferred coding agent + model
824
+ * - screenshotBase64 optional JPEG base64 (no `data:` prefix)
825
+ * - imageMimeType defaults to "image/jpeg"
826
+ */
827
+ async createFeedbackTask(input: {
828
+ userPrompt: string;
829
+ projectName?: string;
830
+ projectPath?: string;
831
+ runner?: string;
832
+ model?: string;
833
+ screenshotBase64?: string;
834
+ imageMimeType?: string;
835
+ }): Promise<{ taskId: string; raw?: unknown }> {
836
+ const { buildFeedbackPrompt } = await import('./_core/buildFeedbackPrompt');
837
+ const hasScreenshot = !!(input.screenshotBase64 && input.screenshotBase64.length > 0);
838
+ const description = buildFeedbackPrompt({
839
+ userPrompt: input.userPrompt,
840
+ projectName: input.projectName,
841
+ projectPath: input.projectPath,
842
+ hasScreenshot,
843
+ });
844
+ const images: Array<{ base64: string; mimeType: string; filename: string }> = [];
845
+ if (hasScreenshot && input.screenshotBase64) {
846
+ images.push({
847
+ base64: input.screenshotBase64,
848
+ mimeType: input.imageMimeType ?? 'image/jpeg',
849
+ filename: `yaver-feedback-${Math.floor(Date.now() / 1000)}.jpg`,
850
+ });
851
+ }
852
+ const body: Record<string, unknown> = {
853
+ title: input.userPrompt.slice(0, 80),
854
+ description,
855
+ userPrompt: input.userPrompt,
856
+ source: 'mobile-feedback',
857
+ images,
858
+ };
859
+ if (input.projectPath && input.projectPath.trim()) body.workDir = input.projectPath.trim();
860
+ if (input.projectName && input.projectName.trim()) body.projectName = input.projectName.trim();
861
+ if (input.runner && input.runner.trim()) body.runner = input.runner.trim();
862
+ if (input.model && input.model.trim()) body.model = input.model.trim();
863
+
864
+ const resp = await fetch(`${this.baseUrl}/tasks`, {
865
+ method: 'POST',
866
+ headers: this.authHeaders({ 'Content-Type': 'application/json' }),
867
+ body: JSON.stringify(body),
868
+ });
869
+ if (!resp.ok) {
870
+ const text = await resp.text().catch(() => '');
871
+ throw new Error(`createFeedbackTask HTTP ${resp.status}: ${text}`);
872
+ }
873
+ const json = (await resp.json().catch(() => ({}))) as { taskId?: string };
874
+ if (!json.taskId) {
875
+ throw new Error('createFeedbackTask: agent did not return taskId');
876
+ }
877
+ return { taskId: json.taskId, raw: json };
878
+ }
879
+
880
+ /**
881
+ * Subscribe to a task's live stdout/stderr stream. Returns an abort
882
+ * function — call it to detach. The agent emits NDJSON lines on
883
+ * `/tasks/{id}/output`; we surface each line via `onLine`.
884
+ *
885
+ * `onComplete` fires when the agent reports the task entered a
886
+ * terminal status (completed / failed / stopped). After that the
887
+ * caller should stop calling abort().
888
+ *
889
+ * Robust to fetch streaming on Hermes (streams Body via Response.
890
+ * body.getReader on platforms that support it; falls back to
891
+ * polling `/tasks/{id}` every 750 ms if streaming isn't available).
892
+ */
893
+ streamTaskOutput(
894
+ taskId: string,
895
+ onLine: (line: string) => void,
896
+ onComplete: (status: string) => void,
897
+ ): () => void {
898
+ const ctrl = new AbortController();
899
+ let closed = false;
900
+ const close = () => {
901
+ if (closed) return;
902
+ closed = true;
903
+ try { ctrl.abort(); } catch { /* ignore */ }
904
+ };
905
+
906
+ (async () => {
907
+ try {
908
+ const resp = await fetch(`${this.baseUrl}/tasks/${encodeURIComponent(taskId)}/output`, {
909
+ method: 'GET',
910
+ headers: this.authHeaders({ Accept: 'text/event-stream' }),
911
+ signal: ctrl.signal,
912
+ });
913
+ if (!resp.ok) {
914
+ throw new Error(`streamTaskOutput HTTP ${resp.status}`);
915
+ }
916
+ // RN Hermes: Response.body may be undefined. Fall back to
917
+ // polling final state.
918
+ const body = (resp as unknown as { body?: ReadableStream<Uint8Array> }).body;
919
+ if (!body || typeof body.getReader !== 'function') {
920
+ await pollTaskUntilDone(this, taskId, onLine, onComplete, () => closed);
921
+ return;
922
+ }
923
+ const reader = body.getReader();
924
+ const decoder = new TextDecoder();
925
+ let buf = '';
926
+ while (!closed) {
927
+ const { value, done } = await reader.read();
928
+ if (done) break;
929
+ buf += decoder.decode(value, { stream: true });
930
+ // SSE frames are separated by \n\n; payloads are `data: <json>\n`.
931
+ let idx = buf.indexOf('\n\n');
932
+ while (idx >= 0) {
933
+ const frame = buf.slice(0, idx);
934
+ buf = buf.slice(idx + 2);
935
+ for (const line of frame.split('\n')) {
936
+ if (line.startsWith('data:')) {
937
+ const payload = line.slice(5).trim();
938
+ if (payload) onLine(payload);
939
+ }
940
+ }
941
+ idx = buf.indexOf('\n\n');
942
+ }
943
+ }
944
+ // Stream closed cleanly — query final status.
945
+ try {
946
+ const final = await fetch(
947
+ `${this.baseUrl}/tasks/${encodeURIComponent(taskId)}`,
948
+ { headers: this.authHeaders() },
949
+ );
950
+ const j = (await final.json().catch(() => ({}))) as { status?: string };
951
+ onComplete(j.status ?? 'completed');
952
+ } catch {
953
+ onComplete('completed');
954
+ }
955
+ } catch (e) {
956
+ if (!closed) {
957
+ // Surface the error via onLine so the UI shows it inline,
958
+ // then mark complete so the caller stops waiting.
959
+ onLine(`__error__: ${e instanceof Error ? e.message : String(e)}`);
960
+ onComplete('failed');
961
+ }
962
+ }
963
+ })();
964
+ return close;
965
+ }
966
+
967
+ /**
968
+ * Send a follow-up message into an existing task — multi-turn vibe
969
+ * chat. The agent's `/tasks/{id}/resume` accepts the same shape as
970
+ * `/tasks` (description / userPrompt / images), and the existing
971
+ * task picks back up with the same runner + project context.
972
+ */
973
+ async resumeTask(input: {
974
+ taskId: string;
975
+ userPrompt: string;
976
+ screenshotBase64?: string;
977
+ imageMimeType?: string;
978
+ }): Promise<void> {
979
+ const images: Array<{ base64: string; mimeType: string; filename: string }> = [];
980
+ if (input.screenshotBase64 && input.screenshotBase64.length > 0) {
981
+ images.push({
982
+ base64: input.screenshotBase64,
983
+ mimeType: input.imageMimeType ?? 'image/jpeg',
984
+ filename: `yaver-feedback-followup-${Math.floor(Date.now() / 1000)}.jpg`,
985
+ });
986
+ }
987
+ const resp = await fetch(
988
+ `${this.baseUrl}/tasks/${encodeURIComponent(input.taskId)}/resume`,
989
+ {
990
+ method: 'POST',
991
+ headers: this.authHeaders({ 'Content-Type': 'application/json' }),
992
+ body: JSON.stringify({
993
+ description: input.userPrompt,
994
+ userPrompt: input.userPrompt,
995
+ source: 'mobile-feedback',
996
+ images,
997
+ }),
998
+ },
999
+ );
1000
+ if (!resp.ok) {
1001
+ const text = await resp.text().catch(() => '');
1002
+ throw new Error(`resumeTask HTTP ${resp.status}: ${text}`);
1003
+ }
1004
+ }
1005
+
810
1006
  private async request(method: string, path: string): Promise<Response> {
811
1007
  const response = await fetch(`${this.baseUrl}${path}`, {
812
1008
  method,
@@ -823,3 +1019,53 @@ export class P2PClient {
823
1019
  return response;
824
1020
  }
825
1021
  }
1022
+
1023
+ /**
1024
+ * Fallback path used by streamTaskOutput when the platform's fetch
1025
+ * returns a Response with no streaming body (older Hermes builds).
1026
+ * Polls `/tasks/{id}` every 750 ms; surfaces newly-appended output
1027
+ * lines via `onLine`, fires `onComplete` when status is terminal.
1028
+ */
1029
+ async function pollTaskUntilDone(
1030
+ client: P2PClient,
1031
+ taskId: string,
1032
+ onLine: (line: string) => void,
1033
+ onComplete: (status: string) => void,
1034
+ isClosed: () => boolean,
1035
+ ): Promise<void> {
1036
+ // Use bracket access to read the private baseUrl/authHeaders without
1037
+ // making them public — confined to this file's scope.
1038
+ const c = client as unknown as {
1039
+ baseUrl: string;
1040
+ authHeaders: (extra?: Record<string, string>) => Record<string, string>;
1041
+ };
1042
+ let lastLen = 0;
1043
+ for (;;) {
1044
+ if (isClosed()) return;
1045
+ try {
1046
+ const r = await fetch(`${c.baseUrl}/tasks/${encodeURIComponent(taskId)}`, {
1047
+ headers: c.authHeaders(),
1048
+ });
1049
+ const j = (await r.json().catch(() => ({}))) as {
1050
+ status?: string;
1051
+ output?: string[] | string;
1052
+ };
1053
+ const all = Array.isArray(j.output) ? j.output : (j.output ? [j.output] : []);
1054
+ const flat = all.join('\n');
1055
+ if (flat.length > lastLen) {
1056
+ const fresh = flat.slice(lastLen);
1057
+ lastLen = flat.length;
1058
+ for (const ln of fresh.split('\n')) {
1059
+ if (ln.length > 0) onLine(ln);
1060
+ }
1061
+ }
1062
+ if (j.status && ['completed', 'failed', 'stopped'].includes(j.status)) {
1063
+ onComplete(j.status);
1064
+ return;
1065
+ }
1066
+ } catch {
1067
+ // Transient — keep polling.
1068
+ }
1069
+ await new Promise((res) => setTimeout(res, 750));
1070
+ }
1071
+ }