@productmaker/mcp 2.8.3 → 2.9.1

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.
@@ -91,120 +91,102 @@
91
91
  .app {
92
92
  width: min(100%, 760px);
93
93
  margin: 0 auto;
94
- padding: 18px;
94
+ padding: 12px;
95
95
  }
96
96
 
97
97
  .card {
98
98
  overflow: hidden;
99
99
  border: 1px solid var(--pm-line);
100
- border-radius: 24px;
100
+ border-radius: 26px;
101
101
  background: var(--pm-card);
102
102
  box-shadow: var(--pm-shadow);
103
103
  }
104
104
 
105
- .hero,
106
- .section {
107
- padding: 22px;
108
- }
109
-
110
105
  .hero {
111
- border-bottom: 1px solid var(--pm-line);
106
+ padding: 19px 22px 14px;
107
+ border-bottom: 1px solid
108
+ color-mix(in srgb, var(--pm-line) 65%, transparent);
112
109
  background:
113
110
  radial-gradient(
114
111
  circle at 100% 0%,
115
112
  var(--pm-primary-soft),
116
- transparent 42%
113
+ transparent 48%
117
114
  ),
118
115
  var(--pm-card);
119
116
  }
120
117
 
121
- .eyebrow {
122
- display: inline-flex;
118
+ .upload-header {
119
+ display: flex;
123
120
  align-items: center;
124
- gap: 7px;
125
- margin: 0 0 10px;
126
- color: var(--pm-primary-strong);
127
- font-size: 12px;
128
- font-weight: 800;
129
- letter-spacing: 0.08em;
130
- text-transform: uppercase;
131
- }
132
-
133
- .eyebrow-dot {
134
- width: 8px;
135
- height: 8px;
136
- border-radius: 999px;
137
- background: var(--pm-primary);
138
- box-shadow: 0 0 0 5px var(--pm-primary-soft);
139
- }
140
-
141
- h1,
142
- h2,
143
- p {
144
- margin-top: 0;
145
- }
146
-
147
- h1 {
148
- margin-bottom: 8px;
149
- font-size: clamp(23px, 5vw, 34px);
150
- line-height: 1.12;
151
- letter-spacing: -0.03em;
152
- }
153
-
154
- h2 {
155
- margin-bottom: 6px;
156
- font-size: 18px;
121
+ justify-content: space-between;
122
+ gap: 12px;
157
123
  }
158
124
 
159
- .muted {
160
- color: var(--pm-muted);
125
+ .upload-title {
126
+ min-width: 0;
127
+ margin: 0;
128
+ overflow: hidden;
129
+ font-size: clamp(17px, 3vw, 24px);
130
+ font-weight: 760;
131
+ letter-spacing: -0.025em;
132
+ text-overflow: ellipsis;
133
+ white-space: nowrap;
161
134
  }
162
135
 
163
- .hero-copy,
164
- .section-copy {
165
- margin-bottom: 0;
166
- font-size: 14px;
167
- line-height: 1.55;
136
+ .brand-mark {
137
+ display: grid;
138
+ width: 34px;
139
+ height: 34px;
140
+ flex: 0 0 auto;
141
+ place-items: center;
142
+ border: 1px solid
143
+ color-mix(in srgb, var(--pm-primary) 20%, var(--pm-line));
144
+ border-radius: 50%;
145
+ background: color-mix(
146
+ in srgb,
147
+ var(--pm-primary-soft) 70%,
148
+ var(--pm-card)
149
+ );
150
+ color: var(--pm-primary-strong);
151
+ font-size: 20px;
152
+ font-weight: 800;
153
+ line-height: 1;
168
154
  }
169
155
 
170
156
  .session-row {
171
157
  display: flex;
172
- flex-wrap: wrap;
158
+ min-height: 0;
173
159
  align-items: center;
174
- gap: 8px 12px;
175
- margin-top: 16px;
160
+ gap: 10px;
161
+ margin-top: 8px;
176
162
  }
177
163
 
178
164
  .session-id {
179
165
  overflow: hidden;
180
166
  max-width: 100%;
181
- padding: 7px 10px;
182
- border-radius: 9px;
183
- background: var(--pm-card-muted);
184
167
  color: var(--pm-muted);
185
168
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
186
- font-size: 11px;
169
+ font-size: 10px;
187
170
  text-overflow: ellipsis;
188
171
  white-space: nowrap;
189
172
  }
190
173
 
191
174
  .fallback-link {
192
175
  color: var(--pm-primary-strong);
193
- font-size: 13px;
194
- font-weight: 750;
176
+ font-size: 12px;
177
+ font-weight: 760;
195
178
  text-decoration-thickness: 1px;
196
179
  text-underline-offset: 3px;
197
180
  }
198
181
 
199
182
  .status {
200
183
  display: none;
201
- margin: 14px 0 0;
202
- padding: 10px 12px;
203
- border-radius: 12px;
204
- background: var(--pm-card-muted);
184
+ margin: 10px 0 0;
185
+ padding: 9px 11px;
186
+ border-radius: 11px;
205
187
  color: var(--pm-muted);
206
- font-size: 13px;
207
- line-height: 1.45;
188
+ font-size: 12px;
189
+ line-height: 1.4;
208
190
  }
209
191
 
210
192
  .status[data-tone="success"] {
@@ -226,7 +208,11 @@
226
208
  }
227
209
 
228
210
  .status[data-tone="neutral"] {
229
- display: block;
211
+ display: none;
212
+ }
213
+
214
+ .section {
215
+ padding: 12px 22px 22px;
230
216
  }
231
217
 
232
218
  .section + .section {
@@ -235,12 +221,16 @@
235
221
 
236
222
  .dropzone {
237
223
  display: grid;
238
- min-height: 190px;
224
+ min-height: 260px;
239
225
  place-items: center;
240
- padding: 24px;
226
+ padding: 20px;
241
227
  border: 2px dashed var(--pm-line);
242
- border-radius: 18px;
243
- background: var(--pm-card-muted);
228
+ border-radius: 21px;
229
+ background: color-mix(
230
+ in srgb,
231
+ var(--pm-card-muted) 62%,
232
+ var(--pm-card)
233
+ );
244
234
  text-align: center;
245
235
  transition:
246
236
  border-color 160ms ease,
@@ -259,31 +249,54 @@
259
249
  opacity: 0.65;
260
250
  }
261
251
 
252
+ .drop-trigger {
253
+ display: grid;
254
+ width: min(100%, 280px);
255
+ min-height: 190px;
256
+ place-items: center;
257
+ align-content: center;
258
+ gap: 16px;
259
+ border: 0;
260
+ border-radius: 18px;
261
+ background: transparent;
262
+ color: var(--pm-text);
263
+ cursor: pointer;
264
+ }
265
+
266
+ .drop-trigger:disabled {
267
+ cursor: not-allowed;
268
+ }
269
+
262
270
  .drop-icon {
263
271
  display: grid;
264
- width: 48px;
265
- height: 48px;
266
- margin: 0 auto 12px;
272
+ width: 58px;
273
+ height: 58px;
267
274
  place-items: center;
268
- border-radius: 15px;
269
- background: var(--pm-primary-soft);
270
- color: var(--pm-primary-strong);
271
- font-size: 24px;
272
- font-weight: 800;
275
+ border: 1px solid var(--pm-line);
276
+ border-radius: 50%;
277
+ background: var(--pm-card);
278
+ box-shadow: 0 7px 20px rgb(27 36 66 / 8%);
279
+ color: var(--pm-text);
280
+ font-size: 31px;
281
+ font-weight: 350;
282
+ line-height: 1;
273
283
  }
274
284
 
275
285
  .drop-title {
276
- margin-bottom: 6px;
277
- font-size: 16px;
278
- font-weight: 780;
286
+ margin: 0;
287
+ font-size: 18px;
288
+ font-weight: 520;
289
+ letter-spacing: -0.02em;
279
290
  }
280
291
 
281
292
  .drop-help {
282
- max-width: 490px;
283
- margin: 0 auto 15px;
284
- color: var(--pm-muted);
285
- font-size: 12px;
286
- line-height: 1.5;
293
+ position: absolute;
294
+ width: 1px;
295
+ height: 1px;
296
+ overflow: hidden;
297
+ clip: rect(0 0 0 0);
298
+ clip-path: inset(50%);
299
+ white-space: nowrap;
287
300
  }
288
301
 
289
302
  .native-input {
@@ -337,17 +350,11 @@
337
350
  background: var(--pm-card-muted);
338
351
  }
339
352
 
340
- .button.danger {
341
- border-color: color-mix(in srgb, var(--pm-danger) 25%, var(--pm-line));
342
- background: transparent;
343
- color: var(--pm-danger);
344
- }
345
-
346
353
  .batch-error {
347
354
  display: none;
348
- margin: 12px 0 0;
355
+ margin: 10px 0 0;
349
356
  color: var(--pm-danger);
350
- font-size: 13px;
357
+ font-size: 12px;
351
358
  }
352
359
 
353
360
  .batch-error:not(:empty) {
@@ -356,85 +363,134 @@
356
363
 
357
364
  .file-list {
358
365
  display: grid;
359
- gap: 10px;
360
- margin-top: 16px;
366
+ grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
367
+ gap: 12px;
368
+ margin-top: 14px;
361
369
  }
362
370
 
363
371
  .file-card {
364
- padding: 13px;
365
- border: 1px solid var(--pm-line);
366
- border-radius: 15px;
367
- background: var(--pm-card);
372
+ min-width: 0;
373
+ background: transparent;
368
374
  }
369
375
 
370
- .file-head,
371
- .file-actions,
372
- .file-meta {
373
- display: flex;
374
- align-items: center;
376
+ .file-preview {
377
+ position: relative;
378
+ aspect-ratio: 1;
379
+ overflow: hidden;
380
+ border: 1px solid var(--pm-line);
381
+ border-radius: 16px;
382
+ background: var(--pm-card-muted);
375
383
  }
376
384
 
377
- .file-head {
378
- justify-content: space-between;
379
- gap: 12px;
385
+ .file-preview img,
386
+ .video-preview {
387
+ display: block;
388
+ width: 100%;
389
+ height: 100%;
390
+ object-fit: cover;
380
391
  }
381
392
 
382
- .file-meta {
383
- min-width: 0;
384
- gap: 10px;
393
+ .video-preview {
394
+ display: grid;
395
+ place-items: center;
396
+ background: linear-gradient(145deg, #242a3d, #515c78);
397
+ color: #ffffff;
398
+ font-size: 37px;
385
399
  }
386
400
 
387
401
  .file-kind {
388
402
  display: grid;
389
- width: 36px;
390
- height: 36px;
391
- flex: 0 0 auto;
403
+ width: 100%;
404
+ height: 100%;
392
405
  place-items: center;
393
- border-radius: 11px;
394
- background: var(--pm-primary-soft);
395
- color: var(--pm-primary-strong);
396
- font-size: 17px;
406
+ color: var(--pm-muted);
407
+ font-size: 34px;
408
+ }
409
+
410
+ .file-head {
411
+ min-width: 0;
412
+ margin-top: 7px;
397
413
  }
398
414
 
399
415
  .file-label {
400
416
  margin: 0;
401
- font-size: 13px;
402
- font-weight: 760;
417
+ overflow: hidden;
418
+ font-size: 12px;
419
+ font-weight: 700;
420
+ text-overflow: ellipsis;
421
+ white-space: nowrap;
403
422
  }
404
423
 
405
424
  .file-state {
406
425
  margin: 3px 0 0;
426
+ overflow: hidden;
407
427
  color: var(--pm-muted);
408
428
  font-size: 11px;
429
+ line-height: 1.35;
430
+ text-overflow: ellipsis;
431
+ white-space: nowrap;
409
432
  }
410
433
 
411
434
  .file-state.error {
412
435
  color: var(--pm-danger);
436
+ white-space: normal;
413
437
  }
414
438
 
415
- .file-actions {
416
- flex: 0 0 auto;
417
- gap: 7px;
439
+ .overlay-button {
440
+ position: absolute;
441
+ z-index: 1;
442
+ border: 0;
443
+ cursor: pointer;
418
444
  }
419
445
 
420
- .icon-button {
421
- min-height: 34px;
422
- padding: 7px 9px;
423
- border: 1px solid var(--pm-line);
424
- border-radius: 9px;
425
- background: transparent;
446
+ .remove-overlay {
447
+ top: 9px;
448
+ right: 9px;
449
+ display: grid;
450
+ width: 30px;
451
+ height: 30px;
452
+ place-items: center;
453
+ border: 1px solid rgb(23 32 51 / 12%);
454
+ border-radius: 50%;
455
+ background: rgb(255 255 255 / 90%);
456
+ color: #172033;
457
+ font-size: 23px;
458
+ font-weight: 350;
459
+ line-height: 1;
460
+ }
461
+
462
+ .remove-overlay:disabled {
463
+ cursor: not-allowed;
464
+ opacity: 0.72;
465
+ }
466
+
467
+ .retry-overlay {
468
+ top: 50%;
469
+ left: 50%;
470
+ padding: 10px 14px;
471
+ border: 1px solid rgb(255 255 255 / 76%);
472
+ border-radius: 11px;
473
+ background: rgb(255 255 255 / 94%);
474
+ box-shadow: 0 8px 24px rgb(27 36 66 / 16%);
426
475
  color: var(--pm-text);
427
- cursor: pointer;
428
- font-size: 11px;
429
- font-weight: 730;
476
+ font-size: 12px;
477
+ font-weight: 780;
478
+ transform: translate(-50%, -50%);
479
+ }
480
+
481
+ .retry-overlay:hover {
482
+ background: var(--pm-primary-soft);
430
483
  }
431
484
 
432
485
  .progress {
486
+ position: absolute;
487
+ right: 10px;
488
+ bottom: 10px;
489
+ left: 10px;
433
490
  height: 6px;
434
- margin-top: 11px;
435
491
  overflow: hidden;
436
492
  border-radius: 999px;
437
- background: var(--pm-card-muted);
493
+ background: rgb(255 255 255 / 75%);
438
494
  }
439
495
 
440
496
  .progress-bar {
@@ -448,20 +504,11 @@
448
504
  .destination-grid {
449
505
  display: grid;
450
506
  grid-template-columns: repeat(3, minmax(0, 1fr));
451
- gap: 12px;
452
- margin-top: 16px;
507
+ gap: 9px;
453
508
  }
454
509
 
455
510
  .field {
456
511
  display: grid;
457
- gap: 6px;
458
- }
459
-
460
- .field label {
461
- color: var(--pm-muted);
462
- font-size: 11px;
463
- font-weight: 760;
464
- letter-spacing: 0.02em;
465
512
  }
466
513
 
467
514
  select,
@@ -474,8 +521,9 @@
474
521
  }
475
522
 
476
523
  select {
477
- min-height: 44px;
478
- padding: 9px 32px 9px 10px;
524
+ min-height: 42px;
525
+ padding: 8px 30px 8px 10px;
526
+ font-size: 12px;
479
527
  }
480
528
 
481
529
  textarea {
@@ -492,19 +540,22 @@
492
540
  display: flex;
493
541
  flex-wrap: wrap;
494
542
  gap: 10px;
495
- margin-top: 16px;
543
+ margin-top: 12px;
496
544
  }
497
545
 
498
- .privacy-note {
499
- display: flex;
500
- gap: 9px;
501
- margin: 16px 0 0;
502
- padding: 11px 12px;
503
- border-radius: 12px;
504
- background: var(--pm-success-soft);
505
- color: var(--pm-success);
506
- font-size: 11px;
507
- line-height: 1.5;
546
+ .compatibility-control {
547
+ display: none !important;
548
+ }
549
+
550
+ .sr-only {
551
+ position: absolute;
552
+ width: 1px;
553
+ height: 1px;
554
+ padding: 0;
555
+ overflow: hidden;
556
+ clip: rect(0, 0, 0, 0);
557
+ white-space: nowrap;
558
+ border: 0;
508
559
  }
509
560
 
510
561
  [hidden] {
@@ -513,23 +564,29 @@
513
564
 
514
565
  @media (max-width: 640px) {
515
566
  .app {
516
- padding: 10px;
567
+ padding: 8px;
517
568
  }
518
569
 
519
570
  .card {
520
- border-radius: 18px;
571
+ border-radius: 20px;
521
572
  }
522
573
 
523
574
  .hero,
524
575
  .section {
525
- padding: 17px;
576
+ padding-right: 14px;
577
+ padding-left: 14px;
578
+ }
579
+
580
+ .dropzone {
581
+ min-height: 230px;
526
582
  }
527
583
 
528
584
  .destination-grid {
529
585
  grid-template-columns: 1fr;
530
586
  }
531
587
 
532
- .button {
588
+ .destination-actions .button,
589
+ .handoff-actions .button {
533
590
  width: 100%;
534
591
  }
535
592
  }
@@ -548,16 +605,16 @@
548
605
  <main class="app" data-testid="meta-upload-root">
549
606
  <article class="card">
550
607
  <header class="hero">
551
- <p class="eyebrow">
552
- <span class="eyebrow-dot"></span>ProductMaker · Meta Ads
553
- </p>
554
- <h1>Sube tus creativos de forma segura</h1>
555
- <p class="hero-copy muted">
556
- Los archivos van directo al staging privado de ProductMaker. El chat
557
- solo recibe identificadores seguros para continuar.
558
- </p>
608
+ <div class="upload-header">
609
+ <p class="upload-title" data-testid="meta-upload-title">
610
+ Upload image
611
+ </p>
612
+ <span class="brand-mark" aria-label="ProductMaker" role="img"
613
+ >↗</span
614
+ >
615
+ </div>
559
616
  <div class="session-row">
560
- <code class="session-id" data-testid="meta-upload-session-id"
617
+ <code class="session-id" data-testid="meta-upload-session-id" hidden
561
618
  >Esperando sesión…</code
562
619
  >
563
620
  <a
@@ -577,52 +634,46 @@
577
634
  ></p>
578
635
  </header>
579
636
 
580
- <section class="section" aria-labelledby="upload-heading">
581
- <h2 id="upload-heading">1. Selecciona tus archivos</h2>
582
- <p class="section-copy muted">
583
- Hasta 10 archivos · 350 MB por archivo · videos de máximo 180
584
- segundos · mínimo 600 × 600 px.
585
- </p>
637
+ <section class="section" aria-label="Upload media">
586
638
  <div
587
639
  class="dropzone"
588
640
  data-testid="meta-upload-dropzone"
589
641
  data-dragging="false"
590
642
  aria-disabled="true"
643
+ aria-describedby="meta-upload-constraints"
591
644
  >
592
- <div>
593
- <div class="drop-icon" aria-hidden="true">↑</div>
594
- <p class="drop-title">Arrastra aquí o usa el selector</p>
595
- <p class="drop-help">
596
- JPEG, PNG, WebP, MP4, MOV o WebM. Las imágenes grandes se
597
- comprimen; los videos nunca se transcodifican.
598
- </p>
599
- <button
600
- class="button"
601
- type="button"
602
- data-testid="meta-upload-picker-btn"
603
- disabled
604
- >
605
- Elegir archivos
606
- </button>
607
- <button
608
- class="button secondary"
609
- type="button"
610
- data-testid="meta-upload-library-btn"
611
- hidden
612
- disabled
645
+ <button
646
+ class="drop-trigger"
647
+ type="button"
648
+ data-testid="meta-upload-picker-btn"
649
+ disabled
650
+ >
651
+ <span class="drop-icon" aria-hidden="true">+</span>
652
+ <span class="drop-title" data-testid="meta-upload-drop-title"
653
+ >Upload image</span
613
654
  >
614
- Elegir desde la biblioteca de ChatGPT
615
- </button>
616
- <input
617
- class="native-input"
618
- data-testid="meta-upload-input"
619
- type="file"
620
- accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime,video/webm"
621
- multiple
622
- disabled
623
- />
624
- </div>
655
+ </button>
656
+ <button
657
+ class="compatibility-control"
658
+ type="button"
659
+ data-testid="meta-upload-library-btn"
660
+ hidden
661
+ disabled
662
+ aria-label="Choose from ChatGPT library"
663
+ ></button>
664
+ <input
665
+ class="native-input"
666
+ data-testid="meta-upload-input"
667
+ type="file"
668
+ accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime,video/webm"
669
+ multiple
670
+ disabled
671
+ />
625
672
  </div>
673
+ <p class="drop-help" id="meta-upload-constraints">
674
+ JPEG, PNG, WebP, MP4, MOV o WebM. Hasta 10 archivos de 350 MB; las
675
+ imágenes grandes se comprimen y los videos no se transcodifican.
676
+ </p>
626
677
  <p
627
678
  class="batch-error"
628
679
  data-testid="meta-upload-batch-error"
@@ -635,57 +686,44 @@
635
686
  aria-live="polite"
636
687
  ></div>
637
688
  <button
638
- class="button secondary"
689
+ class="compatibility-control"
639
690
  type="button"
640
691
  data-testid="meta-upload-pending-refresh"
641
692
  hidden
642
- >
643
- Actualizar estado del staging
644
- </button>
645
- <p class="privacy-note">
646
- <span aria-hidden="true">✓</span>
647
- <span
648
- >No enviamos bytes, enlaces temporales, nombres de archivo ni
649
- tokens al modelo.</span
650
- >
651
- </p>
693
+ aria-label="Refresh upload status"
694
+ ></button>
652
695
  </section>
653
696
 
654
697
  <section
655
698
  class="section"
656
699
  data-testid="meta-upload-destination-section"
657
- aria-labelledby="destination-heading"
700
+ aria-label="Meta destination"
658
701
  hidden
659
702
  >
660
- <h2 id="destination-heading">2. Elige el destino de Meta</h2>
661
- <p class="section-copy muted">
662
- La selección es obligatoria. Las cuentas se filtran por el portfolio
663
- elegido.
664
- </p>
665
703
  <div class="destination-grid">
666
704
  <div class="field">
667
- <label for="portfolio-select">Portfolio</label>
705
+ <label class="sr-only" for="portfolio-select">Portfolio</label>
668
706
  <select
669
707
  id="portfolio-select"
670
708
  data-testid="meta-upload-portfolio-select"
671
709
  >
672
- <option value="">Selecciona un portfolio</option>
710
+ <option value="">Portfolio</option>
673
711
  </select>
674
712
  </div>
675
713
  <div class="field">
676
- <label for="ad-account-select">Cuenta publicitaria</label>
714
+ <label class="sr-only" for="ad-account-select">Ad account</label>
677
715
  <select
678
716
  id="ad-account-select"
679
717
  data-testid="meta-upload-ad-account-select"
680
718
  disabled
681
719
  >
682
- <option value="">Selecciona una cuenta</option>
720
+ <option value="">Ad account</option>
683
721
  </select>
684
722
  </div>
685
723
  <div class="field">
686
- <label for="page-select">Página</label>
724
+ <label class="sr-only" for="page-select">Facebook Page</label>
687
725
  <select id="page-select" data-testid="meta-upload-page-select">
688
- <option value="">Selecciona una página</option>
726
+ <option value="">Facebook Page</option>
689
727
  </select>
690
728
  </div>
691
729
  </div>
@@ -696,15 +734,15 @@
696
734
  data-testid="meta-upload-submit-destination"
697
735
  disabled
698
736
  >
699
- Enviar selección al chat
737
+ Continue
700
738
  </button>
701
739
  <button
702
- class="button secondary"
740
+ class="compatibility-control"
703
741
  type="button"
704
742
  data-testid="meta-upload-check-status"
705
- >
706
- Actualizar estado
707
- </button>
743
+ hidden
744
+ aria-label="Refresh upload status"
745
+ ></button>
708
746
  </div>
709
747
  </section>
710
748
 
@@ -714,10 +752,8 @@
714
752
  aria-labelledby="handoff-heading"
715
753
  hidden
716
754
  >
717
- <h2 id="handoff-heading">3. Copia el handoff seguro</h2>
718
- <p class="section-copy muted">
719
- Este host no puede devolver el mensaje automáticamente. Copia este
720
- JSON exacto en el chat.
755
+ <p id="handoff-heading" class="section-copy muted">
756
+ Copy this handoff into the chat.
721
757
  </p>
722
758
  <textarea data-testid="meta-upload-handoff-copy" readonly></textarea>
723
759
  <div class="handoff-actions">
@@ -726,7 +762,7 @@
726
762
  type="button"
727
763
  data-testid="meta-upload-copy-btn"
728
764
  >
729
- Copiar handoff
765
+ Copy handoff
730
766
  </button>
731
767
  </div>
732
768
  </section>
@@ -765,6 +801,7 @@
765
801
 
766
802
  const elements = {
767
803
  root: document.querySelector('[data-testid="meta-upload-root"]'),
804
+ title: document.querySelector('[data-testid="meta-upload-title"]'),
768
805
  sessionId: document.querySelector(
769
806
  '[data-testid="meta-upload-session-id"]',
770
807
  ),
@@ -777,6 +814,9 @@
777
814
  dropzone: document.querySelector(
778
815
  '[data-testid="meta-upload-dropzone"]',
779
816
  ),
817
+ dropTitle: document.querySelector(
818
+ '[data-testid="meta-upload-drop-title"]',
819
+ ),
780
820
  pickerButton: document.querySelector(
781
821
  '[data-testid="meta-upload-picker-btn"]',
782
822
  ),
@@ -823,12 +863,17 @@
823
863
  };
824
864
 
825
865
  const state = {
866
+ disposed: false,
826
867
  initialized: false,
827
868
  standardHostReady: false,
828
869
  hostCanMessage: false,
829
870
  hostCanServerTools: false,
871
+ hostContainerDimensions: null,
830
872
  rpcId: 0,
831
873
  pendingRpc: new Map(),
874
+ pendingSizeReport: false,
875
+ forceSizeReport: false,
876
+ lastReportedHeight: 0,
832
877
  session: null,
833
878
  toolInput: {},
834
879
  items: [],
@@ -836,7 +881,18 @@
836
881
  destinations: { portfolios: [], adAccounts: [], pages: [] },
837
882
  consumedBridgeIds: new Set(),
838
883
  consumingBridgeFiles: false,
884
+ bridgeConsumptionQueued: false,
885
+ ingestionQueue: Promise.resolve(),
839
886
  busyBatch: false,
887
+ uploadBlocked: false,
888
+ fallbackRequired: false,
889
+ workCancelled: false,
890
+ abortController: null,
891
+ activeXhrs: new Set(),
892
+ resizeObserver: null,
893
+ mutationObserver: null,
894
+ resizeHandler: null,
895
+ hostMessageHandler: null,
840
896
  };
841
897
 
842
898
  function isRecord(value) {
@@ -870,6 +926,29 @@
870
926
  return null;
871
927
  }
872
928
 
929
+ function defaultUploadTitle() {
930
+ return state.session?.mediaKind === "video"
931
+ ? "Upload video"
932
+ : "Upload image";
933
+ }
934
+
935
+ function updateUploadTitle() {
936
+ const namedItems = state.items.filter(
937
+ (item) =>
938
+ (item.file && typeof item.file.name === "string") ||
939
+ (typeof item.displayName === "string" &&
940
+ item.displayName.length > 0),
941
+ );
942
+ const title =
943
+ namedItems.length === 1
944
+ ? namedItems[0].file?.name || namedItems[0].displayName
945
+ : namedItems.length > 1
946
+ ? `${namedItems.length} files`
947
+ : defaultUploadTitle();
948
+ elements.title.textContent = title;
949
+ elements.dropTitle.textContent = defaultUploadTitle();
950
+ }
951
+
873
952
  function safeSessionLimit() {
874
953
  const proposed =
875
954
  state.session && Number.isInteger(state.session.maxFiles)
@@ -887,16 +966,232 @@
887
966
  function setSessionStatus(message, tone = "neutral") {
888
967
  elements.sessionStatus.textContent = message;
889
968
  elements.sessionStatus.dataset.tone = tone;
969
+ scheduleIntrinsicSizeReport(true);
890
970
  }
891
971
 
892
972
  function setBatchError(message) {
893
973
  elements.batchError.textContent = message;
974
+ scheduleIntrinsicSizeReport(true);
975
+ }
976
+
977
+ function parseFinitePositiveNumber(value) {
978
+ return typeof value === "number" &&
979
+ Number.isFinite(value) &&
980
+ value > 0
981
+ ? value
982
+ : null;
983
+ }
984
+
985
+ function parseHostContainerDimensions(value) {
986
+ if (!isRecord(value)) return null;
987
+ const height = parseFinitePositiveNumber(value.height);
988
+ const maxHeight = parseFinitePositiveNumber(value.maxHeight);
989
+ if (!height && !maxHeight) return null;
990
+ return { height, maxHeight };
991
+ }
992
+
993
+ function boundedPositiveHeight(value) {
994
+ const parsed = parseFinitePositiveNumber(value);
995
+ return parsed ? Math.max(1, Math.round(parsed)) : null;
996
+ }
997
+
998
+ function intrinsicHeightLimit() {
999
+ const dimensions = state.hostContainerDimensions;
1000
+ if (!dimensions) return null;
1001
+ return boundedPositiveHeight(
1002
+ dimensions.height || dimensions.maxHeight,
1003
+ );
1004
+ }
1005
+
1006
+ function measureIntrinsicWidth() {
1007
+ const candidates = [
1008
+ elements.root && elements.root.scrollWidth,
1009
+ document.body && document.body.scrollWidth,
1010
+ document.documentElement && document.documentElement.scrollWidth,
1011
+ ];
1012
+ for (const candidate of candidates) {
1013
+ const parsed = boundedPositiveHeight(candidate);
1014
+ if (parsed) return parsed;
1015
+ }
1016
+ return 1;
1017
+ }
1018
+
1019
+ function applyHostContainerDimensions() {
1020
+ const dimensions = state.hostContainerDimensions;
1021
+ if (!dimensions) return;
1022
+ if (dimensions.height) {
1023
+ document.documentElement.style.height = "100vh";
1024
+ } else {
1025
+ document.documentElement.style.removeProperty("height");
1026
+ }
1027
+ if (dimensions.maxHeight) {
1028
+ document.documentElement.style.maxHeight = `${boundedPositiveHeight(dimensions.maxHeight)}px`;
1029
+ } else {
1030
+ document.documentElement.style.removeProperty("max-height");
1031
+ }
1032
+ document.documentElement.style.overflowY = "auto";
1033
+ scheduleIntrinsicSizeReport(true);
1034
+ }
1035
+
1036
+ function measureIntrinsicHeight() {
1037
+ const candidates = [
1038
+ elements.root && elements.root.scrollHeight,
1039
+ document.body && document.body.scrollHeight,
1040
+ document.documentElement && document.documentElement.scrollHeight,
1041
+ ];
1042
+ let measured = 1;
1043
+ for (const candidate of candidates) {
1044
+ const parsed = boundedPositiveHeight(candidate);
1045
+ if (parsed) {
1046
+ measured = parsed;
1047
+ break;
1048
+ }
1049
+ }
1050
+ const limit = intrinsicHeightLimit();
1051
+ return limit ? Math.min(measured, limit) : measured;
1052
+ }
1053
+
1054
+ function reportIntrinsicHeight(force = false) {
1055
+ state.pendingSizeReport = false;
1056
+ state.forceSizeReport = false;
1057
+ const height = measureIntrinsicHeight();
1058
+ if (!force && height === state.lastReportedHeight) return;
1059
+ state.lastReportedHeight = height;
1060
+ if (state.standardHostReady) {
1061
+ postNotification("ui/notifications/size-changed", {
1062
+ width: measureIntrinsicWidth(),
1063
+ height,
1064
+ });
1065
+ }
1066
+ const bridge = openAiBridge();
1067
+ if (bridge && typeof bridge.notifyIntrinsicHeight === "function") {
1068
+ void Promise.resolve(bridge.notifyIntrinsicHeight(height)).catch(
1069
+ () => {},
1070
+ );
1071
+ }
1072
+ }
1073
+
1074
+ function scheduleIntrinsicSizeReport(force = false) {
1075
+ state.forceSizeReport = state.forceSizeReport || force;
1076
+ if (state.pendingSizeReport) return;
1077
+ state.pendingSizeReport = true;
1078
+ const flush = () => reportIntrinsicHeight(state.forceSizeReport);
1079
+ if (typeof window.requestAnimationFrame === "function") {
1080
+ window.requestAnimationFrame(() => flush());
1081
+ return;
1082
+ }
1083
+ window.setTimeout(flush, 0);
1084
+ }
1085
+
1086
+ function installIntrinsicSizeObserver() {
1087
+ if (
1088
+ state.resizeObserver ||
1089
+ state.mutationObserver ||
1090
+ state.resizeHandler
1091
+ )
1092
+ return;
1093
+ if (typeof ResizeObserver === "function" && elements.root) {
1094
+ state.resizeObserver = new ResizeObserver(() =>
1095
+ scheduleIntrinsicSizeReport(),
1096
+ );
1097
+ state.resizeObserver.observe(elements.root);
1098
+ } else if (typeof MutationObserver === "function" && elements.root) {
1099
+ state.mutationObserver = new MutationObserver(() =>
1100
+ scheduleIntrinsicSizeReport(),
1101
+ );
1102
+ state.mutationObserver.observe(elements.root, {
1103
+ attributes: true,
1104
+ childList: true,
1105
+ subtree: true,
1106
+ characterData: true,
1107
+ });
1108
+ }
1109
+ state.resizeHandler = () => scheduleIntrinsicSizeReport();
1110
+ window.addEventListener("resize", state.resizeHandler);
1111
+ }
1112
+
1113
+ function setFallbackVisibility(visible) {
1114
+ elements.fallbackLink.hidden = !visible;
1115
+ scheduleIntrinsicSizeReport(true);
1116
+ }
1117
+
1118
+ function requireFallback(
1119
+ message,
1120
+ tone = "warning",
1121
+ blockPicker = false,
1122
+ ) {
1123
+ state.fallbackRequired = true;
1124
+ state.uploadBlocked = state.uploadBlocked || blockPicker;
1125
+ setFallbackVisibility(true);
1126
+ if (message) setSessionStatus(message, tone);
1127
+ setPickerAvailability();
1128
+ }
1129
+
1130
+ function enqueueIngestion(task) {
1131
+ const next = state.ingestionQueue.then(task, task);
1132
+ state.ingestionQueue = next.catch(() => {});
1133
+ return next;
1134
+ }
1135
+
1136
+ function activeAbortSignal() {
1137
+ if (!state.abortController || state.abortController.signal.aborted) {
1138
+ state.abortController =
1139
+ typeof AbortController === "function"
1140
+ ? new AbortController()
1141
+ : null;
1142
+ }
1143
+ return state.abortController?.signal;
1144
+ }
1145
+
1146
+ function abortActiveWork() {
1147
+ state.abortController?.abort();
1148
+ for (const request of state.activeXhrs) request.abort();
1149
+ state.activeXhrs.clear();
1150
+ }
1151
+
1152
+ function cleanup() {
1153
+ if (state.disposed) return;
1154
+ state.disposed = true;
1155
+ abortActiveWork();
1156
+ state.resizeObserver?.disconnect();
1157
+ state.mutationObserver?.disconnect();
1158
+ if (state.resizeHandler)
1159
+ window.removeEventListener("resize", state.resizeHandler);
1160
+ if (state.hostMessageHandler)
1161
+ window.removeEventListener("message", state.hostMessageHandler);
1162
+ for (const item of state.items) revokePreviewUrl(item);
1163
+ for (const [id, pending] of state.pendingRpc) {
1164
+ window.clearTimeout(pending.timeout);
1165
+ pending.reject(new Error("resource_teardown"));
1166
+ state.pendingRpc.delete(id);
1167
+ }
1168
+ }
1169
+
1170
+ function normalizedBridgeReferenceId(reference) {
1171
+ return isRecord(reference)
1172
+ ? boundedString(
1173
+ reference.file_id || reference.fileId || reference.id,
1174
+ 300,
1175
+ )
1176
+ : "";
1177
+ }
1178
+
1179
+ function normalizedBridgeDownloadUrl(result) {
1180
+ return typeof result === "string"
1181
+ ? result
1182
+ : isRecord(result)
1183
+ ? boundedString(
1184
+ result.download_url || result.downloadUrl || result.url,
1185
+ 3000,
1186
+ )
1187
+ : "";
894
1188
  }
895
1189
 
896
1190
  function setPickerAvailability() {
897
1191
  const disabled =
898
1192
  !state.session ||
899
1193
  sessionExpired() ||
1194
+ state.uploadBlocked ||
900
1195
  state.busyBatch ||
901
1196
  state.items.length >= safeSessionLimit();
902
1197
  elements.input.disabled = disabled;
@@ -925,10 +1220,12 @@
925
1220
  }
926
1221
 
927
1222
  function initialize() {
928
- if (state.initialized) return;
1223
+ if (state.initialized || state.disposed) return;
929
1224
  state.initialized = true;
930
1225
  bindUi();
1226
+ installIntrinsicSizeObserver();
931
1227
  setPickerAvailability();
1228
+ scheduleIntrinsicSizeReport(true);
932
1229
  void initializeStandardHost();
933
1230
  const bridge = openAiBridge();
934
1231
  elements.libraryPickerButton.hidden = !(
@@ -1010,14 +1307,17 @@
1010
1307
  () => void copyHandoff(),
1011
1308
  );
1012
1309
 
1013
- window.addEventListener("message", handleHostMessage);
1310
+ state.hostMessageHandler = handleHostMessage;
1311
+ window.addEventListener("message", state.hostMessageHandler);
1014
1312
  }
1015
1313
 
1016
1314
  async function initializeStandardHost() {
1017
1315
  try {
1018
1316
  const result = await rpcRequest("ui/initialize", {
1019
1317
  appInfo: { name: "ProductMaker Meta upload", version: "1.0.0" },
1020
- appCapabilities: {},
1318
+ appCapabilities: {
1319
+ availableDisplayModes: ["inline", "fullscreen"],
1320
+ },
1021
1321
  protocolVersion: APP_PROTOCOL_VERSION,
1022
1322
  });
1023
1323
  state.standardHostReady = true;
@@ -1025,11 +1325,20 @@
1025
1325
  isRecord(result) && isRecord(result.hostCapabilities)
1026
1326
  ? result.hostCapabilities
1027
1327
  : {};
1328
+ const hostContext =
1329
+ isRecord(result) && isRecord(result.hostContext)
1330
+ ? result.hostContext
1331
+ : {};
1332
+ state.hostContainerDimensions = parseHostContainerDimensions(
1333
+ hostContext.containerDimensions,
1334
+ );
1335
+ applyHostContainerDimensions();
1028
1336
  state.hostCanMessage = capabilityEnabled(capabilities.message);
1029
1337
  state.hostCanServerTools = capabilityEnabled(
1030
1338
  capabilities.serverTools,
1031
1339
  );
1032
1340
  postNotification("ui/notifications/initialized", {});
1341
+ scheduleIntrinsicSizeReport(true);
1033
1342
  } catch {
1034
1343
  state.standardHostReady = false;
1035
1344
  state.hostCanServerTools = false;
@@ -1038,6 +1347,10 @@
1038
1347
 
1039
1348
  function rpcRequest(method, params) {
1040
1349
  return new Promise((resolve, reject) => {
1350
+ if (state.disposed) {
1351
+ reject(new Error("resource_teardown"));
1352
+ return;
1353
+ }
1041
1354
  state.rpcId += 1;
1042
1355
  const id = state.rpcId;
1043
1356
  const timeout = window.setTimeout(() => {
@@ -1053,12 +1366,44 @@
1053
1366
  }
1054
1367
 
1055
1368
  function postNotification(method, params) {
1369
+ if (state.disposed) return;
1056
1370
  window.parent.postMessage({ jsonrpc: "2.0", method, params }, "*");
1057
1371
  }
1058
1372
 
1059
1373
  function handleHostMessage(event) {
1060
1374
  if (event.source !== window.parent || !isRecord(event.data)) return;
1061
1375
  const message = event.data;
1376
+ if (message.method === "ui/resource-teardown") {
1377
+ cleanup();
1378
+ if (Object.prototype.hasOwnProperty.call(message, "id")) {
1379
+ window.parent.postMessage(
1380
+ { jsonrpc: "2.0", id: message.id, result: {} },
1381
+ "*",
1382
+ );
1383
+ }
1384
+ return;
1385
+ }
1386
+ if (message.method === "ui/notifications/tool-cancelled") {
1387
+ state.workCancelled = true;
1388
+ abortActiveWork();
1389
+ requireFallback(
1390
+ "La carga fue cancelada por el host. Puedes continuar desde el enlace seguro.",
1391
+ "warning",
1392
+ true,
1393
+ );
1394
+ return;
1395
+ }
1396
+ if (message.method === "ui/notifications/host-context-changed") {
1397
+ const params = isRecord(message.params) ? message.params : {};
1398
+ const hostContext = isRecord(params.hostContext)
1399
+ ? params.hostContext
1400
+ : params;
1401
+ state.hostContainerDimensions = parseHostContainerDimensions(
1402
+ hostContext.containerDimensions,
1403
+ );
1404
+ applyHostContainerDimensions();
1405
+ return;
1406
+ }
1062
1407
  if (Object.prototype.hasOwnProperty.call(message, "id")) {
1063
1408
  const pending = state.pendingRpc.get(message.id);
1064
1409
  if (!pending) return;
@@ -1090,22 +1435,35 @@
1090
1435
  if (!candidate) return;
1091
1436
  const session = parseSession(candidate);
1092
1437
  if (!session) return;
1438
+ const sessionChanged =
1439
+ !state.session || state.session.sessionId !== session.sessionId;
1440
+ if (sessionChanged) {
1441
+ state.uploadBlocked = false;
1442
+ state.fallbackRequired = false;
1443
+ state.workCancelled = false;
1444
+ if (state.abortController?.signal.aborted) {
1445
+ state.abortController = null;
1446
+ }
1447
+ }
1093
1448
  state.session = session;
1094
1449
  elements.sessionId.textContent = session.sessionId;
1095
1450
  elements.fallbackLink.href = session.uploadPageUrl;
1096
- elements.fallbackLink.hidden = false;
1451
+ updateUploadTitle();
1097
1452
  if (sessionExpired()) {
1098
- setSessionStatus(
1453
+ requireFallback(
1099
1454
  "La sesión está expirada. Abre el enlace seguro para solicitar una nueva.",
1100
1455
  "danger",
1456
+ true,
1101
1457
  );
1102
1458
  elements.pendingRefresh.hidden = true;
1103
1459
  } else {
1460
+ setFallbackVisibility(state.fallbackRequired);
1104
1461
  setSessionStatus("Sesión lista para recibir archivos.", "neutral");
1105
- elements.pendingRefresh.hidden = false;
1462
+ elements.pendingRefresh.hidden = true;
1106
1463
  }
1107
1464
  setPickerAvailability();
1108
1465
  void consumePendingBridgeFiles();
1466
+ scheduleIntrinsicSizeReport(true);
1109
1467
  }
1110
1468
 
1111
1469
  function parseSession(candidate) {
@@ -1139,8 +1497,26 @@
1139
1497
  void consumePendingBridgeFiles();
1140
1498
  }
1141
1499
 
1142
- async function consumePendingBridgeFiles() {
1500
+ function consumePendingBridgeFiles() {
1143
1501
  if (
1502
+ state.disposed ||
1503
+ state.bridgeConsumptionQueued ||
1504
+ !state.session ||
1505
+ !Array.isArray(state.toolInput.files)
1506
+ )
1507
+ return;
1508
+ state.bridgeConsumptionQueued = true;
1509
+ void enqueueIngestion(async () => {
1510
+ state.bridgeConsumptionQueued = false;
1511
+ await consumePendingBridgeFilesNow();
1512
+ }).catch(() => {
1513
+ state.bridgeConsumptionQueued = false;
1514
+ });
1515
+ }
1516
+
1517
+ async function consumePendingBridgeFilesNow() {
1518
+ if (
1519
+ state.disposed ||
1144
1520
  !state.session ||
1145
1521
  !Array.isArray(state.toolInput.files) ||
1146
1522
  state.consumingBridgeFiles
@@ -1149,14 +1525,12 @@
1149
1525
  state.consumingBridgeFiles = true;
1150
1526
  try {
1151
1527
  const pending = state.toolInput.files.filter((reference) => {
1152
- const id = isRecord(reference)
1153
- ? boundedString(reference.file_id, 300)
1154
- : "";
1528
+ const id = normalizedBridgeReferenceId(reference);
1155
1529
  return id && !state.consumedBridgeIds.has(id);
1156
1530
  });
1157
1531
  if (!pending.length) return;
1158
1532
  if (!hasOpenAiFunction("getFileDownloadUrl")) {
1159
- setSessionStatus(
1533
+ requireFallback(
1160
1534
  "Este host no expone el puente de archivos. Usa el selector o el enlace seguro.",
1161
1535
  "warning",
1162
1536
  );
@@ -1168,11 +1542,13 @@
1168
1542
  );
1169
1543
  const files = [];
1170
1544
  for (const reference of pending.slice(0, availableSlots)) {
1171
- const id = boundedString(reference.file_id, 300);
1172
- state.consumedBridgeIds.add(id);
1545
+ const id = normalizedBridgeReferenceId(reference);
1173
1546
  try {
1174
- files.push(await downloadBridgeReference(reference));
1547
+ const file = await downloadBridgeReference(reference);
1548
+ state.consumedBridgeIds.add(id);
1549
+ files.push(file);
1175
1550
  } catch {
1551
+ state.consumedBridgeIds.add(id);
1176
1552
  addBridgeFailure(reference);
1177
1553
  }
1178
1554
  }
@@ -1182,9 +1558,7 @@
1182
1558
  const hasUnconsumed =
1183
1559
  Array.isArray(state.toolInput.files) &&
1184
1560
  state.toolInput.files.some((reference) => {
1185
- const id = isRecord(reference)
1186
- ? boundedString(reference.file_id, 300)
1187
- : "";
1561
+ const id = normalizedBridgeReferenceId(reference);
1188
1562
  return id && !state.consumedBridgeIds.has(id);
1189
1563
  });
1190
1564
  if (
@@ -1197,82 +1571,75 @@
1197
1571
  }
1198
1572
  }
1199
1573
 
1200
- async function processLocalFiles(files) {
1201
- if (!state.session || sessionExpired() || state.busyBatch) return;
1202
- const bridge = openAiBridge();
1203
- if (
1204
- !bridge ||
1205
- typeof bridge.uploadFile !== "function" ||
1206
- typeof bridge.getFileDownloadUrl !== "function"
1207
- ) {
1208
- await processFiles(files);
1209
- return;
1210
- }
1211
- const availableSlots = Math.max(
1212
- 0,
1213
- safeSessionLimit() - state.items.length,
1214
- );
1215
- const normalized = [];
1216
- for (const file of files.slice(0, availableSlots)) {
1217
- try {
1218
- const fileId = boundedString(
1219
- await bridge.uploadFile(file, { library: false }),
1220
- 300,
1221
- );
1222
- if (!fileId) throw new Error("missing_bridge_file_id");
1223
- normalized.push(
1224
- await downloadBridgeReference({
1225
- fileId,
1226
- fileName: file.name,
1227
- mimeType: file.type,
1228
- }),
1574
+ function processLocalFiles(files) {
1575
+ return enqueueIngestion(async () => {
1576
+ if (
1577
+ state.disposed ||
1578
+ !state.session ||
1579
+ sessionExpired() ||
1580
+ state.uploadBlocked
1581
+ )
1582
+ return;
1583
+ state.workCancelled = false;
1584
+ if (files.length > safeSessionLimit() - state.items.length) {
1585
+ setBatchError(
1586
+ `Puedes subir hasta ${safeSessionLimit()} archivos por sesión.`,
1229
1587
  );
1230
- } catch {
1231
- addBridgeFailure({ fileName: file.name, mimeType: file.type });
1232
1588
  }
1233
- }
1234
- if (files.length > availableSlots) {
1235
- setBatchError(
1236
- `Puedes subir hasta ${safeSessionLimit()} archivos por sesión.`,
1237
- );
1238
- }
1239
- await processFiles(normalized);
1589
+ await processFiles(files);
1590
+ });
1240
1591
  }
1241
1592
 
1242
- async function chooseLibraryFiles() {
1243
- if (!state.session || sessionExpired() || state.busyBatch) return;
1244
- const bridge = openAiBridge();
1245
- if (
1246
- !bridge ||
1247
- typeof bridge.selectFiles !== "function" ||
1248
- typeof bridge.getFileDownloadUrl !== "function"
1249
- )
1250
- return;
1251
- try {
1252
- const result = await bridge.selectFiles();
1253
- const selected = Array.isArray(result) ? result : [];
1254
- const availableSlots = Math.max(
1255
- 0,
1256
- safeSessionLimit() - state.items.length,
1257
- );
1258
- const files = [];
1259
- for (const candidate of selected.slice(0, availableSlots)) {
1260
- if (!isRecord(candidate)) continue;
1261
- const fileId = boundedString(candidate.fileId, 300);
1262
- const fileName = boundedString(candidate.fileName, 240);
1263
- const mimeType = boundedString(candidate.mimeType, 100);
1264
- if (!fileId || !fileName || !mimeType) continue;
1265
- files.push(
1266
- await downloadBridgeReference({ fileId, fileName, mimeType }),
1593
+ function chooseLibraryFiles() {
1594
+ return enqueueIngestion(async () => {
1595
+ if (
1596
+ state.disposed ||
1597
+ !state.session ||
1598
+ sessionExpired() ||
1599
+ state.uploadBlocked
1600
+ )
1601
+ return;
1602
+ const bridge = openAiBridge();
1603
+ if (
1604
+ !bridge ||
1605
+ typeof bridge.selectFiles !== "function" ||
1606
+ typeof bridge.getFileDownloadUrl !== "function"
1607
+ )
1608
+ return;
1609
+ try {
1610
+ const result = await bridge.selectFiles();
1611
+ const selected = Array.isArray(result) ? result : [];
1612
+ const availableSlots = Math.max(
1613
+ 0,
1614
+ safeSessionLimit() - state.items.length,
1615
+ );
1616
+ const files = [];
1617
+ for (const candidate of selected.slice(0, availableSlots)) {
1618
+ if (!isRecord(candidate)) continue;
1619
+ const fileId = boundedString(candidate.fileId, 300);
1620
+ const fileName = boundedString(candidate.fileName, 240);
1621
+ const mimeType = boundedString(candidate.mimeType, 100);
1622
+ if (!fileId || !fileName || !mimeType) continue;
1623
+ try {
1624
+ files.push(
1625
+ await downloadBridgeReference({
1626
+ fileId,
1627
+ fileName,
1628
+ mimeType,
1629
+ }),
1630
+ );
1631
+ } catch {
1632
+ addBridgeFailure({ fileId, fileName, mimeType });
1633
+ }
1634
+ }
1635
+ await processFiles(files);
1636
+ } catch {
1637
+ setSessionStatus(
1638
+ "No se pudo abrir el selector del host. Usa el selector del navegador.",
1639
+ "warning",
1267
1640
  );
1268
1641
  }
1269
- await processFiles(files);
1270
- } catch {
1271
- setSessionStatus(
1272
- "No se pudo abrir el selector del host. Usa el selector del navegador.",
1273
- "warning",
1274
- );
1275
- }
1642
+ });
1276
1643
  }
1277
1644
 
1278
1645
  function isBrowserFile(value) {
@@ -1290,31 +1657,21 @@
1290
1657
  if (!bridge || typeof bridge.getFileDownloadUrl !== "function") {
1291
1658
  throw new Error("bridge_download_unavailable");
1292
1659
  }
1293
- const fileId = boundedString(
1294
- reference.file_id || reference.fileId || reference.id,
1295
- 300,
1296
- );
1660
+ const fileId = normalizedBridgeReferenceId(reference);
1297
1661
  if (!fileId) throw new Error("invalid_bridge_file");
1298
1662
  const resolved = await bridge.getFileDownloadUrl({ fileId });
1299
- const temporaryUrl =
1300
- typeof resolved === "string"
1301
- ? resolved
1302
- : isRecord(resolved)
1303
- ? boundedString(
1304
- resolved.download_url ||
1305
- resolved.downloadUrl ||
1306
- resolved.url,
1307
- 3000,
1308
- )
1309
- : "";
1663
+ const temporaryUrl = normalizedBridgeDownloadUrl(resolved);
1310
1664
  const parsedUrl = new URL(temporaryUrl);
1311
- if (!safeWebProtocol(parsedUrl)) throw new Error("unsafe_bridge_url");
1665
+ if (!safeBridgeDownloadUrl(parsedUrl))
1666
+ throw new Error("unsafe_bridge_url");
1312
1667
  const response = await fetch(parsedUrl.toString(), {
1313
1668
  method: "GET",
1314
1669
  credentials: "omit",
1670
+ redirect: "error",
1671
+ signal: activeAbortSignal(),
1315
1672
  });
1316
1673
  if (!response.ok) throw new Error("bridge_download_failed");
1317
- const blob = await response.blob();
1674
+ const blob = await readBoundedBridgeBlob(response);
1318
1675
  const preferredType = boundedString(
1319
1676
  reference.mime_type || reference.mimeType || reference.type,
1320
1677
  100,
@@ -1338,6 +1695,48 @@
1338
1695
  });
1339
1696
  }
1340
1697
 
1698
+ async function readBoundedBridgeBlob(response) {
1699
+ const declaredLength = Number(
1700
+ response.headers.get("content-length") || "",
1701
+ );
1702
+ if (
1703
+ Number.isFinite(declaredLength) &&
1704
+ declaredLength > MAX_FILE_SIZE_BYTES
1705
+ ) {
1706
+ throw new Error("bridge_file_too_large");
1707
+ }
1708
+
1709
+ if (!response.body || typeof response.body.getReader !== "function") {
1710
+ const blob = await response.blob();
1711
+ if (blob.size > MAX_FILE_SIZE_BYTES) {
1712
+ throw new Error("bridge_file_too_large");
1713
+ }
1714
+ return blob;
1715
+ }
1716
+
1717
+ const reader = response.body.getReader();
1718
+ const chunks = [];
1719
+ let total = 0;
1720
+ try {
1721
+ while (true) {
1722
+ const next = await reader.read();
1723
+ if (next.done) break;
1724
+ const chunk = next.value;
1725
+ total += chunk.byteLength;
1726
+ if (total > MAX_FILE_SIZE_BYTES) {
1727
+ await reader.cancel();
1728
+ throw new Error("bridge_file_too_large");
1729
+ }
1730
+ chunks.push(chunk);
1731
+ }
1732
+ } finally {
1733
+ reader.releaseLock?.();
1734
+ }
1735
+ return new Blob(chunks, {
1736
+ type: response.headers.get("content-type") || "",
1737
+ });
1738
+ }
1739
+
1341
1740
  function addBridgeFailure(reference) {
1342
1741
  const type = boundedString(
1343
1742
  reference.mime_type || reference.mimeType || reference.type,
@@ -1345,11 +1744,19 @@
1345
1744
  );
1346
1745
  const mediaKind = mediaKindForType(type) || "image";
1347
1746
  const item = createItem(null, mediaKind);
1747
+ item.displayName = boundedString(
1748
+ reference.file_name || reference.fileName || reference.name,
1749
+ 300,
1750
+ );
1348
1751
  item.status = "failed";
1349
1752
  item.error =
1350
1753
  "No se pudo leer el archivo temporal del host. Usa el selector o el enlace seguro.";
1351
1754
  item.failureStage = "bridge";
1352
1755
  state.items.push(item);
1756
+ requireFallback(
1757
+ "No se pudo leer el archivo temporal del host. Usa el selector o el enlace seguro.",
1758
+ "warning",
1759
+ );
1353
1760
  renderItems();
1354
1761
  emitAnalytics("ads_meta_remote_upload_error", mediaKind, {
1355
1762
  failureStage: "bridge",
@@ -1358,7 +1765,14 @@
1358
1765
  }
1359
1766
 
1360
1767
  async function processFiles(fileCandidates) {
1361
- if (!state.session || sessionExpired() || state.busyBatch) return;
1768
+ if (
1769
+ state.disposed ||
1770
+ !state.session ||
1771
+ sessionExpired() ||
1772
+ state.uploadBlocked ||
1773
+ state.busyBatch
1774
+ )
1775
+ return;
1362
1776
  const files = fileCandidates.filter(isBrowserFile);
1363
1777
  if (!files.length) return;
1364
1778
  setBatchError("");
@@ -1395,6 +1809,7 @@
1395
1809
  id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`,
1396
1810
  uploadId: createClientUuid(),
1397
1811
  file,
1812
+ displayName: file?.name || "",
1398
1813
  uploadFile: file,
1399
1814
  mediaKind,
1400
1815
  status: "pending",
@@ -1403,6 +1818,7 @@
1403
1818
  error: "",
1404
1819
  failureStage: "",
1405
1820
  stagingAssetId: "",
1821
+ previewUrl: "",
1406
1822
  };
1407
1823
  }
1408
1824
 
@@ -1435,6 +1851,18 @@
1435
1851
  failureStage: failure.stage,
1436
1852
  errorType: failure.code,
1437
1853
  });
1854
+ if (failure.code === "session_expired") {
1855
+ requireFallback(
1856
+ "La sesión expiró. Abre el enlace seguro para crear otra.",
1857
+ "danger",
1858
+ true,
1859
+ );
1860
+ } else if (failure.stage !== "validation") {
1861
+ requireFallback(
1862
+ "La carga no terminó. Puedes reintentarlo o continuar desde el enlace seguro.",
1863
+ "warning",
1864
+ );
1865
+ }
1438
1866
  }
1439
1867
  renderItems();
1440
1868
  }
@@ -1606,6 +2034,12 @@
1606
2034
  alpha: file.type !== "image/jpeg",
1607
2035
  });
1608
2036
  if (!context) throw new Error("canvas_unavailable");
2037
+ if (file.type === "image/png") {
2038
+ context.save();
2039
+ context.fillStyle = "#ffffff";
2040
+ context.fillRect(0, 0, canvas.width, canvas.height);
2041
+ context.restore();
2042
+ }
1609
2043
  context.drawImage(
1610
2044
  inspection.image,
1611
2045
  0,
@@ -1613,16 +2047,13 @@
1613
2047
  canvas.width,
1614
2048
  canvas.height,
1615
2049
  );
1616
- const blob = await new Promise((resolve, reject) => {
1617
- canvas.toBlob(
1618
- (result) =>
1619
- result
1620
- ? resolve(result)
1621
- : reject(new Error("compression_failed")),
1622
- file.type,
1623
- 0.85,
1624
- );
1625
- });
2050
+ let blob = await canvasBlob(canvas, file.type);
2051
+ if (
2052
+ file.type === "image/png" &&
2053
+ blob.size > IMAGE_COMPRESSION_MAX_BYTES
2054
+ ) {
2055
+ blob = await canvasBlob(canvas, "image/jpeg");
2056
+ }
1626
2057
  const compressed = new File([blob], file.name, {
1627
2058
  type: blob.type || file.type,
1628
2059
  lastModified: file.lastModified,
@@ -1651,6 +2082,19 @@
1651
2082
  }
1652
2083
  }
1653
2084
 
2085
+ function canvasBlob(canvas, type) {
2086
+ return new Promise((resolve, reject) => {
2087
+ canvas.toBlob(
2088
+ (result) =>
2089
+ result
2090
+ ? resolve(result)
2091
+ : reject(new Error("compression_failed")),
2092
+ type,
2093
+ 0.85,
2094
+ );
2095
+ });
2096
+ }
2097
+
1654
2098
  async function uploadWithRetries(item) {
1655
2099
  let lastError = null;
1656
2100
  for (let attempt = 1; attempt <= MAX_UPLOAD_ATTEMPTS; attempt += 1) {
@@ -1675,6 +2119,7 @@
1675
2119
  return;
1676
2120
  } catch (error) {
1677
2121
  if (error && error.message === "session_expired") throw error;
2122
+ if (state.disposed || state.workCancelled) throw error;
1678
2123
  lastError = error;
1679
2124
  }
1680
2125
  }
@@ -1712,6 +2157,7 @@
1712
2157
  fileSize: file.size,
1713
2158
  }),
1714
2159
  credentials: "omit",
2160
+ signal: activeAbortSignal(),
1715
2161
  });
1716
2162
  if (!response.ok) {
1717
2163
  const error = new Error(
@@ -1761,6 +2207,21 @@
1761
2207
  );
1762
2208
  }
1763
2209
 
2210
+ function safeBridgeDownloadUrl(url) {
2211
+ if (
2212
+ url.protocol !== "https:" ||
2213
+ url.username ||
2214
+ url.password ||
2215
+ url.port
2216
+ )
2217
+ return false;
2218
+ const hostname = url.hostname.toLowerCase();
2219
+ return (
2220
+ hostname === "files.oaiusercontent.com" ||
2221
+ hostname.endsWith(".oaiusercontent.com")
2222
+ );
2223
+ }
2224
+
1764
2225
  function safeUploadPageUrl(url) {
1765
2226
  if (url.username || url.password) return false;
1766
2227
  if (
@@ -1779,23 +2240,32 @@
1779
2240
  function putFileWithProgress(url, file, onProgress) {
1780
2241
  return new Promise((resolve, reject) => {
1781
2242
  const request = new XMLHttpRequest();
2243
+ let settled = false;
2244
+ const finish = (callback) => {
2245
+ if (settled) return;
2246
+ settled = true;
2247
+ state.activeXhrs.delete(request);
2248
+ callback();
2249
+ };
1782
2250
  request.open("PUT", url);
1783
2251
  request.timeout = 5 * 60 * 1000;
1784
2252
  request.setRequestHeader("Content-Type", file.type);
2253
+ state.activeXhrs.add(request);
1785
2254
  request.upload.addEventListener("progress", (event) => {
1786
2255
  if (event.lengthComputable && event.total > 0) {
1787
2256
  onProgress(Math.round((event.loaded / event.total) * 100));
1788
2257
  }
1789
2258
  });
1790
2259
  request.addEventListener("load", () => {
1791
- if (request.status >= 200 && request.status < 300) resolve();
1792
- else reject(uploadTransportError("put_failed"));
2260
+ if (request.status >= 200 && request.status < 300)
2261
+ finish(resolve);
2262
+ else finish(() => reject(uploadTransportError("put_failed")));
1793
2263
  });
1794
2264
  request.addEventListener("error", () =>
1795
- reject(uploadTransportError("network_error")),
2265
+ finish(() => reject(uploadTransportError("network_error"))),
1796
2266
  );
1797
2267
  request.addEventListener("timeout", () =>
1798
- reject(uploadTransportError("upload_timeout")),
2268
+ finish(() => reject(uploadTransportError("upload_timeout"))),
1799
2269
  );
1800
2270
  request.send(file);
1801
2271
  });
@@ -1846,11 +2316,38 @@
1846
2316
 
1847
2317
  function removeItem(index) {
1848
2318
  if (state.busyBatch) return;
1849
- state.items.splice(index, 1);
2319
+ const [item] = state.items.splice(index, 1);
2320
+ if (item) revokePreviewUrl(item);
1850
2321
  renderItems();
1851
2322
  setPickerAvailability();
1852
2323
  }
1853
2324
 
2325
+ function revokePreviewUrl(item) {
2326
+ if (
2327
+ item.previewUrl &&
2328
+ typeof window.URL.revokeObjectURL === "function"
2329
+ ) {
2330
+ window.URL.revokeObjectURL(item.previewUrl);
2331
+ item.previewUrl = "";
2332
+ }
2333
+ }
2334
+
2335
+ function previewUrlFor(item) {
2336
+ if (
2337
+ !item.file ||
2338
+ item.mediaKind !== "image" ||
2339
+ !IMAGE_TYPES.has(item.file.type)
2340
+ )
2341
+ return "";
2342
+ if (
2343
+ !item.previewUrl &&
2344
+ typeof window.URL.createObjectURL === "function"
2345
+ ) {
2346
+ item.previewUrl = window.URL.createObjectURL(item.file);
2347
+ }
2348
+ return item.previewUrl;
2349
+ }
2350
+
1854
2351
  function renderItems() {
1855
2352
  const fragment = document.createDocumentFragment();
1856
2353
  state.items.forEach((item, index) => {
@@ -1858,33 +2355,46 @@
1858
2355
  card.className = "file-card";
1859
2356
  card.dataset.testid = `meta-upload-file-${index}`;
1860
2357
 
1861
- const head = document.createElement("div");
1862
- head.className = "file-head";
1863
- const meta = document.createElement("div");
1864
- meta.className = "file-meta";
1865
- const icon = document.createElement("span");
1866
- icon.className = "file-kind";
1867
- icon.setAttribute("aria-hidden", "true");
1868
- icon.textContent = item.mediaKind === "video" ? "▶" : "▧";
1869
- const text = document.createElement("div");
1870
- const label = document.createElement("p");
1871
- label.className = "file-label";
1872
- label.textContent =
1873
- item.mediaKind === "video"
1874
- ? `Video ${index + 1}`
1875
- : `Imagen ${index + 1}`;
1876
- const status = document.createElement("p");
1877
- status.className = `file-state${item.status === "failed" ? " error" : ""}`;
1878
- status.dataset.testid =
1879
- item.status === "failed"
1880
- ? `meta-upload-file-error-${index}`
1881
- : `meta-upload-file-status-${index}`;
1882
- status.textContent = itemStatusText(item);
1883
- text.append(label, status);
1884
- meta.append(icon, text);
2358
+ const preview = document.createElement("div");
2359
+ preview.className = "file-preview";
2360
+ preview.dataset.testid = `meta-upload-file-preview-${index}`;
2361
+ const previewUrl = previewUrlFor(item);
2362
+ if (previewUrl) {
2363
+ const image = document.createElement("img");
2364
+ image.src = previewUrl;
2365
+ image.alt =
2366
+ item.file?.name || item.displayName || "Uploaded image";
2367
+ image.dataset.testid = `meta-upload-preview-${index}`;
2368
+ preview.append(image);
2369
+ } else if (item.mediaKind === "video") {
2370
+ const video = document.createElement("span");
2371
+ video.className = "video-preview";
2372
+ video.setAttribute("aria-hidden", "true");
2373
+ video.textContent = "▶";
2374
+ preview.append(video);
2375
+ } else {
2376
+ const placeholder = document.createElement("span");
2377
+ placeholder.className = "file-kind";
2378
+ placeholder.setAttribute("aria-hidden", "true");
2379
+ placeholder.textContent = "▧";
2380
+ preview.append(placeholder);
2381
+ }
2382
+
2383
+ if (item.status === "uploading" || item.status === "uploaded") {
2384
+ const progress = document.createElement("div");
2385
+ progress.className = "progress";
2386
+ progress.setAttribute("role", "progressbar");
2387
+ progress.setAttribute("aria-valuemin", "0");
2388
+ progress.setAttribute("aria-valuemax", "100");
2389
+ progress.setAttribute("aria-valuenow", String(item.progress));
2390
+ progress.dataset.testid = `meta-upload-progress-${index}`;
2391
+ const bar = document.createElement("div");
2392
+ bar.className = "progress-bar";
2393
+ bar.style.width = `${item.progress}%`;
2394
+ progress.append(bar);
2395
+ preview.append(progress);
2396
+ }
1885
2397
 
1886
- const actions = document.createElement("div");
1887
- actions.className = "file-actions";
1888
2398
  if (
1889
2399
  item.status === "failed" &&
1890
2400
  item.failureStage !== "validation" &&
@@ -1892,42 +2402,50 @@
1892
2402
  ) {
1893
2403
  const retry = document.createElement("button");
1894
2404
  retry.type = "button";
1895
- retry.className = "icon-button";
2405
+ retry.className = "overlay-button retry-overlay";
1896
2406
  retry.dataset.testid = `meta-upload-retry-${index}`;
1897
- retry.textContent = "Reintentar";
2407
+ retry.setAttribute("aria-label", `Retry ${item.file.name}`);
2408
+ retry.textContent = "Retry";
1898
2409
  retry.addEventListener("click", () => void retryItem(index));
1899
- actions.append(retry);
2410
+ preview.append(retry);
1900
2411
  }
2412
+
1901
2413
  const remove = document.createElement("button");
1902
2414
  remove.type = "button";
1903
- remove.className = "icon-button";
2415
+ remove.className = "overlay-button remove-overlay";
1904
2416
  remove.dataset.testid = `meta-upload-remove-${index}`;
1905
- remove.textContent = "Quitar";
2417
+ remove.setAttribute(
2418
+ "aria-label",
2419
+ `Remove ${item.file?.name || item.displayName || `file ${index + 1}`}`,
2420
+ );
2421
+ remove.textContent = "×";
1906
2422
  remove.disabled = ["uploading", "validating", "uploaded"].includes(
1907
2423
  item.status,
1908
2424
  );
1909
2425
  remove.addEventListener("click", () => removeItem(index));
1910
- actions.append(remove);
1911
- head.append(meta, actions);
1912
- card.append(head);
2426
+ preview.append(remove);
2427
+ card.append(preview);
1913
2428
 
1914
- if (item.status === "uploading" || item.status === "uploaded") {
1915
- const progress = document.createElement("div");
1916
- progress.className = "progress";
1917
- progress.setAttribute("role", "progressbar");
1918
- progress.setAttribute("aria-valuemin", "0");
1919
- progress.setAttribute("aria-valuemax", "100");
1920
- progress.setAttribute("aria-valuenow", String(item.progress));
1921
- progress.dataset.testid = `meta-upload-progress-${index}`;
1922
- const bar = document.createElement("div");
1923
- bar.className = "progress-bar";
1924
- bar.style.width = `${item.progress}%`;
1925
- progress.append(bar);
1926
- card.append(progress);
1927
- }
2429
+ const head = document.createElement("div");
2430
+ head.className = "file-head";
2431
+ const label = document.createElement("p");
2432
+ label.className = "file-label";
2433
+ label.textContent =
2434
+ item.file?.name || item.displayName || `File ${index + 1}`;
2435
+ const status = document.createElement("p");
2436
+ status.className = `file-state${item.status === "failed" ? " error" : ""}`;
2437
+ status.dataset.testid =
2438
+ item.status === "failed"
2439
+ ? `meta-upload-file-error-${index}`
2440
+ : `meta-upload-file-status-${index}`;
2441
+ status.textContent = itemStatusText(item);
2442
+ head.append(label, status);
2443
+ card.append(head);
1928
2444
  fragment.append(card);
1929
2445
  });
1930
2446
  elements.fileList.replaceChildren(fragment);
2447
+ updateUploadTitle();
2448
+ scheduleIntrinsicSizeReport(true);
1931
2449
  }
1932
2450
 
1933
2451
  function itemStatusText(item) {
@@ -1950,24 +2468,36 @@
1950
2468
  const status = parseStatusResult(result);
1951
2469
  state.assets = status.assets;
1952
2470
  if (!status.assets.length) {
1953
- setSessionStatus(
1954
- status.legacyKindAmbiguous
1955
- ? "El tipo del asset legacy es ambiguo. Vuelve a comprobar o usa el enlace seguro."
1956
- : "La sesión sigue pendiente. Termina la carga o vuelve a comprobar.",
1957
- "warning",
1958
- );
2471
+ if (status.legacyKindAmbiguous) {
2472
+ requireFallback(
2473
+ "El tipo del asset legacy es ambiguo. Vuelve a comprobar o usa el enlace seguro.",
2474
+ "warning",
2475
+ );
2476
+ } else {
2477
+ setFallbackVisibility(state.fallbackRequired);
2478
+ setSessionStatus(
2479
+ "La sesión sigue pendiente. Termina la carga o vuelve a comprobar.",
2480
+ "warning",
2481
+ );
2482
+ }
1959
2483
  elements.destinationSection.hidden = true;
1960
- elements.pendingRefresh.hidden = false;
2484
+ elements.pendingRefresh.hidden = true;
1961
2485
  return;
1962
2486
  }
1963
2487
  elements.pendingRefresh.hidden = true;
1964
2488
 
1965
- const locallyUploaded = state.items.filter(
1966
- (item) => item.status === "uploaded",
1967
- ).length;
1968
2489
  const partial =
1969
2490
  status.status === "pending" ||
1970
- status.assets.length < locallyUploaded;
2491
+ (state.items.length > 0 &&
2492
+ status.assets.length < state.items.length);
2493
+ if (partial) {
2494
+ requireFallback(
2495
+ "Carga parcial verificada. Puedes continuar desde el enlace seguro si algún archivo no terminó.",
2496
+ "warning",
2497
+ );
2498
+ } else {
2499
+ setFallbackVisibility(state.fallbackRequired);
2500
+ }
1971
2501
  setSessionStatus(
1972
2502
  partial
1973
2503
  ? "Carga parcial verificada. Continuaremos solo con los assets listos."
@@ -1986,17 +2516,16 @@
1986
2516
  (error && /expired|expirad/i.test(String(error.message)));
1987
2517
  const toolsUnavailable =
1988
2518
  error && error.message === "host_tools_unavailable";
1989
- setSessionStatus(
1990
- expired
1991
- ? "La sesión está expirada. Abre el enlace seguro para solicitar una nueva."
1992
- : toolsUnavailable
1993
- ? "host_tools_unavailable: este host no permite comprobar el staging desde la App. Usa el enlace seguro."
1994
- : "No se pudo comprobar la sesión. Conserva el enlace y vuelve a intentarlo.",
1995
- "danger",
1996
- );
1997
- elements.pendingRefresh.hidden = expired;
2519
+ const message = expired
2520
+ ? "La sesión está expirada. Abre el enlace seguro para solicitar una nueva."
2521
+ : toolsUnavailable
2522
+ ? "host_tools_unavailable: este host no permite comprobar el staging desde la App. Usa el enlace seguro."
2523
+ : "No se pudo comprobar la sesión. Conserva el enlace y vuelve a intentarlo.";
2524
+ requireFallback(message, "danger", expired);
2525
+ elements.pendingRefresh.hidden = true;
1998
2526
  } finally {
1999
2527
  elements.checkStatus.disabled = false;
2528
+ elements.pendingRefresh.hidden = true;
2000
2529
  elements.pendingRefresh.disabled = false;
2001
2530
  }
2002
2531
  }
@@ -2012,7 +2541,15 @@
2012
2541
  const bridge = openAiBridge();
2013
2542
  let result;
2014
2543
  if (bridge && typeof bridge.callTool === "function") {
2015
- result = await bridge.callTool(name, args);
2544
+ try {
2545
+ result = await bridge.callTool(name, args);
2546
+ } catch (error) {
2547
+ if (!state.hostCanServerTools) throw error;
2548
+ result = await rpcRequest("tools/call", {
2549
+ name,
2550
+ arguments: args,
2551
+ });
2552
+ }
2016
2553
  } else if (state.hostCanServerTools) {
2017
2554
  result = await rpcRequest("tools/call", { name, arguments: args });
2018
2555
  } else {
@@ -2125,18 +2662,19 @@
2125
2662
  fillSelect(
2126
2663
  elements.portfolio,
2127
2664
  state.destinations.portfolios,
2128
- "Selecciona un portfolio",
2665
+ "Portfolio",
2129
2666
  (entry) => entry.name,
2130
2667
  );
2131
2668
  fillSelect(
2132
2669
  elements.page,
2133
2670
  state.destinations.pages,
2134
- "Selecciona una página",
2671
+ "Facebook Page",
2135
2672
  (entry) => entry.name,
2136
2673
  );
2137
2674
  renderAdAccounts();
2138
2675
  elements.destinationSection.hidden = false;
2139
2676
  updateDestinationSubmit();
2677
+ scheduleIntrinsicSizeReport(true);
2140
2678
  }
2141
2679
 
2142
2680
  function renderAdAccounts() {
@@ -2149,9 +2687,7 @@
2149
2687
  fillSelect(
2150
2688
  elements.adAccount,
2151
2689
  accounts,
2152
- portfolioId
2153
- ? "Selecciona una cuenta"
2154
- : "Elige primero un portfolio",
2690
+ portfolioId ? "Ad account" : "Ad account",
2155
2691
  (entry) =>
2156
2692
  `${entry.name}${entry.currency ? ` · ${entry.currency}` : ""}`,
2157
2693
  );
@@ -2236,6 +2772,7 @@
2236
2772
  "Copia el handoff exacto y pégalo en el chat.",
2237
2773
  "warning",
2238
2774
  );
2775
+ scheduleIntrinsicSizeReport(true);
2239
2776
  }
2240
2777
 
2241
2778
  async function copyHandoff() {