viveworker 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/web/app.js CHANGED
@@ -7,6 +7,7 @@ const INITIAL_DETECTED_LOCALE = detectBrowserLocale();
7
7
  const TIMELINE_MESSAGE_KINDS = new Set(["user_message", "assistant_commentary", "assistant_final"]);
8
8
  const TIMELINE_OPERATIONAL_KINDS = new Set(["approval", "plan", "plan_ready", "choice", "completion"]);
9
9
  const THREAD_FILTER_INTERACTION_DEFER_MS = 8000;
10
+ const MAX_COMPLETION_REPLY_IMAGE_COUNT = 4;
10
11
 
11
12
  const state = {
12
13
  session: null,
@@ -40,6 +41,7 @@ const state = {
40
41
  pushError: "",
41
42
  deviceNotice: "",
42
43
  deviceError: "",
44
+ imageViewer: null,
43
45
  serviceWorkerRegistration: null,
44
46
  installGuideOpen: false,
45
47
  logoutConfirmOpen: false,
@@ -89,7 +91,10 @@ async function boot() {
89
91
 
90
92
  if (!state.session?.authenticated && initialPairToken && shouldAutoPairFromBootstrapToken()) {
91
93
  try {
92
- await pair({ token: initialPairToken });
94
+ await pair({
95
+ token: initialPairToken,
96
+ temporary: shouldUseTemporaryBootstrapPairing(),
97
+ });
93
98
  } catch (error) {
94
99
  state.pairError = error.message || String(error);
95
100
  }
@@ -532,7 +537,9 @@ function renderPair() {
532
537
 
533
538
  async function pair(payload) {
534
539
  const result = await apiPost("/api/session/pair", payload);
535
- syncPairingTokenState("");
540
+ if (result?.temporaryPairing !== true) {
541
+ syncPairingTokenState("");
542
+ }
536
543
  return result;
537
544
  }
538
545
 
@@ -617,6 +624,7 @@ async function renderShell() {
617
624
  ${desktop ? renderDesktopWorkspace(detail) : renderMobileWorkspace(detail)}
618
625
  </main>
619
626
  ${desktop || state.detailOpen || isSettingsSubpageOpen() ? "" : renderBottomTabs()}
627
+ ${renderImageViewerModal()}
620
628
  ${renderInstallGuideModal()}
621
629
  ${renderLogoutConfirmModal()}
622
630
  </div>
@@ -768,12 +776,7 @@ function normalizeReplyMode(value) {
768
776
  return normalizeClientText(value).toLowerCase() === "plan" ? "plan" : "default";
769
777
  }
770
778
 
771
- const COMPLETION_REPLY_IMAGE_SUPPORT = false;
772
-
773
779
  function normalizeCompletionReplyAttachment(value) {
774
- if (!COMPLETION_REPLY_IMAGE_SUPPORT) {
775
- return null;
776
- }
777
780
  if (!value || typeof value !== "object") {
778
781
  return null;
779
782
  }
@@ -795,9 +798,6 @@ function normalizeCompletionReplyAttachment(value) {
795
798
  }
796
799
 
797
800
  function createCompletionReplyAttachment(file) {
798
- if (!COMPLETION_REPLY_IMAGE_SUPPORT) {
799
- return null;
800
- }
801
801
  if (!(typeof File !== "undefined" && file instanceof File)) {
802
802
  return null;
803
803
  }
@@ -831,7 +831,7 @@ function getCompletionReplyDraft(token) {
831
831
  return {
832
832
  text: "",
833
833
  sentText: "",
834
- attachment: null,
834
+ attachments: [],
835
835
  mode: "default",
836
836
  notice: "",
837
837
  error: "",
@@ -846,7 +846,7 @@ function getCompletionReplyDraft(token) {
846
846
  return {
847
847
  text: String(draft.text ?? ""),
848
848
  sentText: normalizeClientText(draft.sentText ?? ""),
849
- attachment: normalizeCompletionReplyAttachment(draft.attachment),
849
+ attachments: normalizeCompletionReplyAttachments(draft.attachments ?? draft.attachment),
850
850
  mode: normalizeReplyMode(draft.mode),
851
851
  notice: normalizeClientText(draft.notice),
852
852
  error: normalizeClientText(draft.error),
@@ -874,26 +874,44 @@ function normalizeCompletionReplyWarning(value) {
874
874
  };
875
875
  }
876
876
 
877
+ function normalizeCompletionReplyAttachments(values) {
878
+ const rawValues = Array.isArray(values)
879
+ ? values
880
+ : values
881
+ ? [values]
882
+ : [];
883
+ return rawValues
884
+ .map((value) => normalizeCompletionReplyAttachment(value))
885
+ .filter(Boolean);
886
+ }
887
+
877
888
  function setCompletionReplyDraft(token, partialDraft) {
878
889
  if (!token) {
879
890
  return;
880
891
  }
881
892
  const previousStoredDraft = state.completionReplyDrafts?.[token] || {};
882
- const previousAttachment = normalizeCompletionReplyAttachment(previousStoredDraft.attachment);
893
+ const previousAttachments = normalizeCompletionReplyAttachments(
894
+ previousStoredDraft.attachments ?? previousStoredDraft.attachment
895
+ );
883
896
  const nextDraft = {
884
897
  ...getCompletionReplyDraft(token),
885
898
  ...(partialDraft || {}),
886
899
  };
887
- const nextAttachment = Object.prototype.hasOwnProperty.call(partialDraft || {}, "attachment")
888
- ? normalizeCompletionReplyAttachment(partialDraft?.attachment)
889
- : normalizeCompletionReplyAttachment(nextDraft.attachment);
890
- if (previousAttachment?.previewUrl && previousAttachment.previewUrl !== nextAttachment?.previewUrl) {
891
- releaseCompletionReplyAttachment(previousAttachment);
900
+ const nextAttachments = Object.prototype.hasOwnProperty.call(partialDraft || {}, "attachments")
901
+ ? normalizeCompletionReplyAttachments(partialDraft?.attachments)
902
+ : Object.prototype.hasOwnProperty.call(partialDraft || {}, "attachment")
903
+ ? normalizeCompletionReplyAttachments(partialDraft?.attachment)
904
+ : normalizeCompletionReplyAttachments(nextDraft.attachments);
905
+ const nextPreviewUrls = new Set(nextAttachments.map((attachment) => attachment.previewUrl).filter(Boolean));
906
+ for (const previousAttachment of previousAttachments) {
907
+ if (previousAttachment?.previewUrl && !nextPreviewUrls.has(previousAttachment.previewUrl)) {
908
+ releaseCompletionReplyAttachment(previousAttachment);
909
+ }
892
910
  }
893
911
  state.completionReplyDrafts[token] = {
894
912
  text: String(nextDraft.text ?? ""),
895
913
  sentText: normalizeClientText(nextDraft.sentText ?? ""),
896
- attachment: nextAttachment,
914
+ attachments: nextAttachments,
897
915
  mode: normalizeReplyMode(nextDraft.mode),
898
916
  notice: normalizeClientText(nextDraft.notice),
899
917
  error: normalizeClientText(nextDraft.error),
@@ -908,7 +926,11 @@ function clearCompletionReplyDraft(token) {
908
926
  if (!token || !state.completionReplyDrafts?.[token]) {
909
927
  return;
910
928
  }
911
- releaseCompletionReplyAttachment(state.completionReplyDrafts[token]?.attachment);
929
+ for (const attachment of normalizeCompletionReplyAttachments(
930
+ state.completionReplyDrafts[token]?.attachments ?? state.completionReplyDrafts[token]?.attachment
931
+ )) {
932
+ releaseCompletionReplyAttachment(attachment);
933
+ }
912
934
  delete state.completionReplyDrafts[token];
913
935
  }
914
936
 
@@ -1492,6 +1514,7 @@ function renderTimelineEntry(entry, { desktop }) {
1492
1514
  const kindClassName = escapeHtml(kindInfo.tone || "neutral");
1493
1515
  const kindNameClass = escapeHtml(String(item.kind || "item").replace(/_/gu, "-"));
1494
1516
  const isMessageLike = TIMELINE_MESSAGE_KINDS.has(item.kind) || item.kind === "completion";
1517
+ const imageUrls = Array.isArray(item.imageUrls) ? item.imageUrls.filter(Boolean) : [];
1495
1518
  const primaryText = isMessageLike
1496
1519
  ? item.summary || fallbackSummaryForKind(item.kind, entry.status)
1497
1520
  : item.title || L("common.untitledItem");
@@ -1521,12 +1544,39 @@ function renderTimelineEntry(entry, { desktop }) {
1521
1544
  <div class="timeline-entry__body">
1522
1545
  <p class="timeline-entry__title">${escapeHtml(primaryText)}</p>
1523
1546
  ${secondaryText ? `<p class="timeline-entry__summary">${escapeHtml(secondaryText)}</p>` : ""}
1547
+ ${renderTimelineEntryImageStrip(imageUrls)}
1524
1548
  </div>
1525
1549
  ${statusLabel ? `<div class="timeline-entry__footer"><span class="timeline-entry__status">${escapeHtml(statusLabel)}</span></div>` : ""}
1526
1550
  </button>
1527
1551
  `;
1528
1552
  }
1529
1553
 
1554
+ function renderTimelineEntryImageStrip(imageUrls) {
1555
+ if (!Array.isArray(imageUrls) || imageUrls.length === 0) {
1556
+ return "";
1557
+ }
1558
+
1559
+ return `
1560
+ <div class="timeline-entry__images" aria-hidden="true">
1561
+ ${imageUrls
1562
+ .slice(0, 4)
1563
+ .map(
1564
+ (imageUrl, index) => `
1565
+ <span class="timeline-entry__image-frame">
1566
+ <img
1567
+ class="timeline-entry__image"
1568
+ src="${escapeHtml(imageUrl)}"
1569
+ alt="${escapeHtml(L("detail.imageAlt", { index: index + 1 }))}"
1570
+ loading="lazy"
1571
+ >
1572
+ </span>
1573
+ `
1574
+ )
1575
+ .join("")}
1576
+ </div>
1577
+ `;
1578
+ }
1579
+
1530
1580
  function timelineEntryThreadLabel(item, isMessage) {
1531
1581
  const threadLabel = resolvedThreadLabel(item.threadId || "", item.threadLabel || "");
1532
1582
  if (!threadLabel) {
@@ -1539,23 +1589,82 @@ function timelineEntryThreadLabel(item, isMessage) {
1539
1589
  return title.includes(threadLabel) ? "" : threadLabel;
1540
1590
  }
1541
1591
 
1592
+ function sanitizeThreadLabelForDisplay(label = "", threadId = "") {
1593
+ const normalizedLabel = normalizeClientText(label || "");
1594
+ if (!normalizedLabel) {
1595
+ return "";
1596
+ }
1597
+
1598
+ const normalizedThreadId = normalizeClientText(threadId || "");
1599
+ if (normalizedThreadId && (normalizedLabel === normalizedThreadId || normalizedLabel === normalizedThreadId.slice(0, 8))) {
1600
+ return "";
1601
+ }
1602
+
1603
+ if (/^[0-9a-f]{8}(?:-[0-9a-f]{4}){0,4}$/i.test(normalizedLabel)) {
1604
+ return "";
1605
+ }
1606
+
1607
+ if (looksLikeGeneratedThreadTitle(normalizedLabel)) {
1608
+ return "";
1609
+ }
1610
+
1611
+ return normalizedLabel;
1612
+ }
1613
+
1614
+ function looksLikeGeneratedThreadTitle(label = "") {
1615
+ const normalizedLabel = normalizeClientText(label || "");
1616
+ if (!normalizedLabel.includes("|")) {
1617
+ return false;
1618
+ }
1619
+ const prefix = normalizeClientText(normalizedLabel.split("|", 1)[0] || "");
1620
+ if (!prefix) {
1621
+ return false;
1622
+ }
1623
+ const titleKeys = [
1624
+ "server.title.userMessage",
1625
+ "server.title.assistantCommentary",
1626
+ "server.title.assistantFinal",
1627
+ "server.title.approval",
1628
+ "server.title.plan",
1629
+ "server.title.planReady",
1630
+ "server.title.choice",
1631
+ "server.title.choiceReadOnly",
1632
+ "server.title.complete",
1633
+ ];
1634
+ return SUPPORTED_LOCALES.some((locale) => titleKeys.some((key) => t(locale, key) === prefix));
1635
+ }
1636
+
1542
1637
  function resolvedThreadLabel(threadId, explicitLabel = "") {
1543
- const normalizedLabel = normalizeClientText(explicitLabel || "");
1638
+ const normalizedThreadId = normalizeClientText(threadId || "");
1639
+ const normalizedLabel = sanitizeThreadLabelForDisplay(explicitLabel || "", normalizedThreadId);
1544
1640
  if (normalizedLabel) {
1545
1641
  return normalizedLabel;
1546
1642
  }
1547
- const normalizedThreadId = normalizeClientText(threadId || "");
1548
1643
  if (!normalizedThreadId) {
1549
1644
  return "";
1550
1645
  }
1551
1646
  const timelineThreads = Array.isArray(state.timeline?.threads) ? state.timeline.threads : [];
1552
1647
  const matchingThread = timelineThreads.find((thread) => thread.id === normalizedThreadId);
1553
- const fallbackLabel = normalizeClientText(matchingThread?.label || "");
1648
+ const fallbackLabel = sanitizeThreadLabelForDisplay(matchingThread?.label || "", normalizedThreadId);
1554
1649
  return fallbackLabel || "";
1555
1650
  }
1556
1651
 
1652
+ function compactDropdownThreadLabel(label) {
1653
+ const normalized = normalizeClientText(label || "");
1654
+ if (!normalized) {
1655
+ return "";
1656
+ }
1657
+
1658
+ const glyphs = Array.from(normalized);
1659
+ if (glyphs.length <= 28) {
1660
+ return normalized;
1661
+ }
1662
+
1663
+ return `${glyphs.slice(0, 28).join("")}...`;
1664
+ }
1665
+
1557
1666
  function dropdownThreadLabel(threadId, explicitLabel = "") {
1558
- return resolvedThreadLabel(threadId, explicitLabel) || L("timeline.unknownThread");
1667
+ return compactDropdownThreadLabel(resolvedThreadLabel(threadId, explicitLabel)) || L("timeline.unknownThread");
1559
1668
  }
1560
1669
 
1561
1670
  function formatTimelineTimestamp(value) {
@@ -2193,9 +2302,11 @@ function renderStandardDetailDesktop(detail) {
2193
2302
  <h2 class="detail-title detail-title--desktop">${escapeHtml(detailDisplayTitle(detail))}</h2>
2194
2303
  ${detail.readOnly ? "" : renderDetailLead(detail, kindInfo)}
2195
2304
  ${renderPreviousContextCard(detail)}
2305
+ ${renderInterruptedDetailNotice(detail)}
2196
2306
  <section class="detail-card detail-card--body ${spaciousBodyDetail ? "detail-card--message-body" : ""}">
2197
2307
  <div class="detail-body ${spaciousBodyDetail ? "detail-body--message " : ""}markdown">${detail.messageHtml || ""}</div>
2198
2308
  </section>
2309
+ ${renderDetailImageGallery(detail)}
2199
2310
  ${renderCompletionReplyComposer(detail)}
2200
2311
  ${detail.readOnly ? "" : renderActionButtons(detail.actions || [])}
2201
2312
  </div>
@@ -2211,10 +2322,12 @@ function renderStandardDetailMobile(detail) {
2211
2322
  <div class="mobile-detail-scroll mobile-detail-scroll--detail">
2212
2323
  ${renderDetailMetaRow(detail, kindInfo, { mobile: true })}
2213
2324
  ${renderPreviousContextCard(detail, { mobile: true })}
2325
+ ${renderInterruptedDetailNotice(detail, { mobile: true })}
2214
2326
  <section class="detail-card detail-card--body detail-card--mobile ${spaciousBodyDetail ? "detail-card--message-body" : ""}">
2215
2327
  ${detail.readOnly ? "" : renderDetailLead(detail, kindInfo, { mobile: true })}
2216
2328
  <div class="detail-body ${spaciousBodyDetail ? "detail-body--message " : ""}markdown">${detail.messageHtml || ""}</div>
2217
2329
  </section>
2330
+ ${renderDetailImageGallery(detail, { mobile: true })}
2218
2331
  ${renderCompletionReplyComposer(detail, { mobile: true })}
2219
2332
  </div>
2220
2333
  ${detail.readOnly ? "" : renderActionButtons(detail.actions || [], { mobileSticky: true })}
@@ -2250,9 +2363,24 @@ function renderDetailLead(detail, kindInfo, options = {}) {
2250
2363
  `;
2251
2364
  }
2252
2365
 
2366
+ function renderInterruptedDetailNotice(detail, options = {}) {
2367
+ const message = normalizeClientText(detail?.interruptNotice || "");
2368
+ if (!message) {
2369
+ return "";
2370
+ }
2371
+ return `
2372
+ <section class="detail-card detail-card--interrupt ${options.mobile ? "detail-card--mobile" : ""}">
2373
+ <p class="detail-interrupt-copy">
2374
+ <span class="detail-interrupt-copy__icon" aria-hidden="true">${renderIcon("pending")}</span>
2375
+ <span>${escapeHtml(message)}</span>
2376
+ </p>
2377
+ </section>
2378
+ `;
2379
+ }
2380
+
2253
2381
  function renderPreviousContextCard(detail, options = {}) {
2254
2382
  const context = detail?.previousContext;
2255
- if (!context?.messageHtml || detail.kind !== "approval") {
2383
+ if (!context?.messageHtml) {
2256
2384
  return "";
2257
2385
  }
2258
2386
 
@@ -2263,7 +2391,7 @@ function renderPreviousContextCard(detail, options = {}) {
2263
2391
  <div class="detail-context-card__header">
2264
2392
  <div class="detail-context-card__eyebrow">
2265
2393
  <span class="detail-context-card__icon" aria-hidden="true">${renderIcon(contextKind.icon)}</span>
2266
- <span>${escapeHtml(L("detail.previousMessage"))}</span>
2394
+ <span>${escapeHtml(context.label || L("detail.previousMessage"))}</span>
2267
2395
  </div>
2268
2396
  ${timestampLabel ? `<span class="detail-context-card__time">${escapeHtml(timestampLabel)}</span>` : ""}
2269
2397
  </div>
@@ -2273,6 +2401,40 @@ function renderPreviousContextCard(detail, options = {}) {
2273
2401
  `;
2274
2402
  }
2275
2403
 
2404
+ function renderDetailImageGallery(detail, options = {}) {
2405
+ const imageUrls = Array.isArray(detail?.imageUrls) ? detail.imageUrls.filter(Boolean) : [];
2406
+ if (imageUrls.length === 0) {
2407
+ return "";
2408
+ }
2409
+
2410
+ return `
2411
+ <section class="detail-card detail-card--images ${options.mobile ? "detail-card--mobile" : ""}">
2412
+ <div class="detail-image-grid">
2413
+ ${imageUrls
2414
+ .map((imageUrl, index) => {
2415
+ const altText = L("detail.imageAlt", { index: index + 1 });
2416
+ return `
2417
+ <button
2418
+ class="detail-image-link"
2419
+ type="button"
2420
+ data-open-image-viewer="${escapeHtml(imageUrl)}"
2421
+ data-image-alt="${escapeHtml(altText)}"
2422
+ >
2423
+ <img
2424
+ class="detail-image"
2425
+ src="${escapeHtml(imageUrl)}"
2426
+ alt="${escapeHtml(altText)}"
2427
+ loading="lazy"
2428
+ >
2429
+ </button>
2430
+ `;
2431
+ })
2432
+ .join("")}
2433
+ </div>
2434
+ </section>
2435
+ `;
2436
+ }
2437
+
2276
2438
  function renderCompletionReplyComposer(detail, options = {}) {
2277
2439
  if (detail.kind !== "completion" || detail.reply?.enabled !== true) {
2278
2440
  return "";
@@ -2289,8 +2451,7 @@ function renderCompletionReplyComposer(detail, options = {}) {
2289
2451
  const warningTimestamp = draft.warning?.createdAtMs ? formatTimelineTimestamp(draft.warning.createdAtMs) : "";
2290
2452
  const showCollapsedState =
2291
2453
  draft.collapsedAfterSend && Boolean(draft.notice) && !draft.error && !draft.warning && !draft.sending;
2292
- const attachmentName = draft.attachment?.name ? escapeHtml(draft.attachment.name) : "";
2293
- const attachmentPreviewUrl = draft.attachment?.previewUrl ? escapeHtml(draft.attachment.previewUrl) : "";
2454
+ const attachments = Array.isArray(draft.attachments) ? draft.attachments : [];
2294
2455
 
2295
2456
  return `
2296
2457
  <section class="detail-card detail-card--reply ${options.mobile ? "detail-card--mobile" : ""}">
@@ -2347,86 +2508,93 @@ function renderCompletionReplyComposer(detail, options = {}) {
2347
2508
  <form class="reply-composer__form" data-completion-reply-form data-token="${escapeHtml(detail.token)}">
2348
2509
  <label class="field reply-field">
2349
2510
  <span class="field-label">${escapeHtml(L("reply.fieldLabel"))}</span>
2350
- <textarea
2351
- class="reply-field__input"
2352
- name="text"
2353
- rows="4"
2354
- placeholder="${escapeHtml(L("reply.placeholder"))}"
2355
- data-completion-reply-textarea
2356
- data-reply-token="${escapeHtml(detail.token)}"
2357
- >${escapeHtml(draft.text)}</textarea>
2511
+ <div class="reply-field__shell">
2512
+ <textarea
2513
+ class="reply-field__input"
2514
+ name="text"
2515
+ rows="4"
2516
+ placeholder="${escapeHtml(L("reply.placeholder"))}"
2517
+ data-completion-reply-textarea
2518
+ data-reply-token="${escapeHtml(detail.token)}"
2519
+ >${escapeHtml(draft.text)}</textarea>
2520
+ <div class="reply-field__toolbar">
2521
+ ${
2522
+ detail.reply?.supportsImages
2523
+ ? `
2524
+ <label class="reply-attachment-trigger" aria-label="${escapeHtml(L(attachments.length ? "reply.imageAddMore" : "reply.imageAdd"))}">
2525
+ <input
2526
+ class="reply-attachment-trigger__input"
2527
+ type="file"
2528
+ accept="image/*"
2529
+ multiple
2530
+ data-reply-image-input
2531
+ data-reply-token="${escapeHtml(detail.token)}"
2532
+ >
2533
+ <span class="reply-attachment-trigger__icon" aria-hidden="true">${renderIcon("clip")}</span>
2534
+ ${
2535
+ attachments.length
2536
+ ? `<span class="reply-attachment-trigger__count">${escapeHtml(String(attachments.length))}</span>`
2537
+ : ""
2538
+ }
2539
+ </label>
2540
+ `
2541
+ : ""
2542
+ }
2543
+ ${
2544
+ detail.reply?.supportsPlanMode
2545
+ ? `
2546
+ <label class="reply-mode-toggle" data-reply-mode-switch>
2547
+ <span class="reply-mode-toggle__label">${escapeHtml(L("reply.mode.planLabel"))}</span>
2548
+ <input
2549
+ class="reply-mode-toggle__input"
2550
+ type="checkbox"
2551
+ ${planMode ? "checked" : ""}
2552
+ data-reply-mode-toggle
2553
+ data-reply-token="${escapeHtml(detail.token)}"
2554
+ >
2555
+ <span class="reply-mode-toggle__track" aria-hidden="true">
2556
+ <span class="reply-mode-toggle__thumb"></span>
2557
+ </span>
2558
+ </label>
2559
+ `
2560
+ : ""
2561
+ }
2562
+ </div>
2563
+ </div>
2358
2564
  </label>
2359
2565
  ${
2360
2566
  detail.reply?.supportsImages
2361
2567
  ? `
2362
- <div class="reply-attachment-field">
2363
- <div class="reply-attachment-field__header">
2364
- <span class="field-label">${escapeHtml(L("reply.imageLabel"))}</span>
2365
- ${
2366
- draft.attachment
2367
- ? `
2368
- <button
2369
- class="secondary secondary--compact"
2370
- type="button"
2371
- data-reply-image-remove
2372
- data-reply-token="${escapeHtml(detail.token)}"
2373
- >
2374
- ${escapeHtml(L("reply.imageRemove"))}
2375
- </button>
2376
- `
2377
- : ""
2378
- }
2379
- </div>
2380
- <label class="reply-attachment-picker">
2381
- <input
2382
- class="reply-attachment-picker__input"
2383
- type="file"
2384
- accept="image/*"
2385
- data-reply-image-input
2386
- data-reply-token="${escapeHtml(detail.token)}"
2387
- >
2388
- <span class="reply-attachment-picker__label">${escapeHtml(L(draft.attachment ? "reply.imageReplace" : "reply.imageAdd"))}</span>
2389
- <span class="reply-attachment-picker__hint">${escapeHtml(L("reply.imageHint"))}</span>
2390
- </label>
2391
- ${
2392
- draft.attachment
2393
- ? `
2394
- <div class="reply-image-preview">
2395
- <img class="reply-image-preview__image" src="${attachmentPreviewUrl}" alt="${attachmentName}">
2396
- <div class="reply-image-preview__copy">
2397
- <p class="reply-image-preview__name">${attachmentName}</p>
2398
- <p class="reply-image-preview__meta">${escapeHtml(L("reply.imageAttached"))}</p>
2399
- </div>
2400
- </div>
2401
- `
2402
- : ""
2403
- }
2404
- </div>
2405
- `
2406
- : ""
2407
- }
2408
- ${
2409
- detail.reply?.supportsPlanMode
2410
- ? `
2411
- <label class="reply-mode-switch" data-reply-mode-switch>
2412
- <input
2413
- class="reply-mode-switch__input"
2414
- type="checkbox"
2415
- ${planMode ? "checked" : ""}
2416
- data-reply-mode-toggle
2417
- data-reply-token="${escapeHtml(detail.token)}"
2418
- >
2419
- <span class="reply-mode-switch__track" aria-hidden="true">
2420
- <span class="reply-mode-switch__thumb"></span>
2421
- </span>
2422
- <span class="reply-mode-switch__copy">
2423
- <span class="reply-mode-switch__title">
2424
- <span>${escapeHtml(L("reply.mode.planLabel"))}</span>
2425
- <span class="reply-mode-switch__state">${escapeHtml(L(planMode ? "reply.mode.on" : "reply.mode.off"))}</span>
2426
- </span>
2427
- <span class="reply-mode-switch__hint">${escapeHtml(L(planMode ? "reply.mode.planHint" : "reply.mode.defaultHint"))}</span>
2428
- </span>
2429
- </label>
2568
+ ${
2569
+ attachments.length
2570
+ ? `
2571
+ <div class="reply-image-preview-list">
2572
+ ${attachments
2573
+ .map(
2574
+ (attachment, index) => `
2575
+ <div class="reply-image-preview">
2576
+ <img class="reply-image-preview__image" src="${escapeHtml(attachment.previewUrl || "")}" alt="${escapeHtml(attachment.name || "")}">
2577
+ <div class="reply-image-preview__copy">
2578
+ <p class="reply-image-preview__name">${escapeHtml(attachment.name || "")}</p>
2579
+ <p class="reply-image-preview__meta">${escapeHtml(L("reply.imageAttached"))}</p>
2580
+ </div>
2581
+ <button
2582
+ class="secondary secondary--compact"
2583
+ type="button"
2584
+ data-reply-image-remove
2585
+ data-reply-token="${escapeHtml(detail.token)}"
2586
+ data-reply-image-index="${index}"
2587
+ >
2588
+ ${escapeHtml(L("reply.imageRemove"))}
2589
+ </button>
2590
+ </div>
2591
+ `
2592
+ )
2593
+ .join("")}
2594
+ </div>
2595
+ `
2596
+ : ""
2597
+ }
2430
2598
  `
2431
2599
  : ""
2432
2600
  }
@@ -2748,6 +2916,26 @@ function renderInstallGuideModal() {
2748
2916
  `;
2749
2917
  }
2750
2918
 
2919
+ function renderImageViewerModal() {
2920
+ const imageViewer = state.imageViewer;
2921
+ if (!imageViewer?.url) {
2922
+ return "";
2923
+ }
2924
+
2925
+ return `
2926
+ <div class="modal-backdrop modal-backdrop--image-viewer" data-close-image-viewer>
2927
+ <section class="image-viewer" role="dialog" aria-modal="true" aria-label="${escapeHtml(imageViewer.alt || L("common.detail"))}">
2928
+ <div class="image-viewer__chrome">
2929
+ <button class="secondary image-viewer__close" type="button" data-close-image-viewer>${escapeHtml(L("common.back"))}</button>
2930
+ </div>
2931
+ <div class="image-viewer__body">
2932
+ <img class="image-viewer__image" src="${escapeHtml(imageViewer.url)}" alt="${escapeHtml(imageViewer.alt || "")}">
2933
+ </div>
2934
+ </section>
2935
+ </div>
2936
+ `;
2937
+ }
2938
+
2751
2939
  function renderLogoutConfirmModal() {
2752
2940
  if (!state.logoutConfirmOpen || !state.session?.authenticated) {
2753
2941
  return "";
@@ -2907,6 +3095,18 @@ function bindShellInteractions() {
2907
3095
  });
2908
3096
  }
2909
3097
 
3098
+ for (const button of document.querySelectorAll("[data-open-image-viewer]")) {
3099
+ button.addEventListener("click", async (event) => {
3100
+ event.preventDefault();
3101
+ event.stopPropagation();
3102
+ state.imageViewer = {
3103
+ url: button.dataset.openImageViewer || "",
3104
+ alt: button.dataset.imageAlt || "",
3105
+ };
3106
+ await renderShell();
3107
+ });
3108
+ }
3109
+
2910
3110
  for (const button of document.querySelectorAll("[data-back-to-list]")) {
2911
3111
  button.addEventListener("click", async () => {
2912
3112
  clearChoiceLocalDraftForItem(state.currentItem);
@@ -2974,25 +3174,44 @@ function bindShellInteractions() {
2974
3174
  for (const input of document.querySelectorAll("[data-reply-image-input][data-reply-token]")) {
2975
3175
  input.addEventListener("change", async () => {
2976
3176
  const token = input.dataset.replyToken || "";
2977
- const [file] = Array.from(input.files || []);
2978
- const nextAttachment = createCompletionReplyAttachment(file);
2979
- if (!nextAttachment && file) {
3177
+ const files = Array.from(input.files || []);
3178
+ const nextAttachments = files.map((file) => createCompletionReplyAttachment(file)).filter(Boolean);
3179
+ if (files.length > 0 && nextAttachments.length !== files.length) {
2980
3180
  setCompletionReplyDraft(token, {
2981
3181
  error: L("error.completionReplyImageInvalidType"),
2982
3182
  notice: "",
2983
3183
  warning: null,
2984
3184
  confirmOverride: false,
2985
3185
  });
3186
+ input.value = "";
3187
+ await renderShell();
3188
+ return;
3189
+ }
3190
+ const existingAttachments = getCompletionReplyDraft(token).attachments || [];
3191
+ const mergedAttachments = [...existingAttachments, ...nextAttachments].slice(0, MAX_COMPLETION_REPLY_IMAGE_COUNT);
3192
+ if (existingAttachments.length + nextAttachments.length > MAX_COMPLETION_REPLY_IMAGE_COUNT) {
3193
+ for (const attachment of nextAttachments.slice(Math.max(0, MAX_COMPLETION_REPLY_IMAGE_COUNT - existingAttachments.length))) {
3194
+ releaseCompletionReplyAttachment(attachment);
3195
+ }
3196
+ setCompletionReplyDraft(token, {
3197
+ error: L("error.completionReplyImageLimit", { count: MAX_COMPLETION_REPLY_IMAGE_COUNT }),
3198
+ notice: "",
3199
+ warning: null,
3200
+ confirmOverride: false,
3201
+ attachments: mergedAttachments,
3202
+ });
3203
+ input.value = "";
2986
3204
  await renderShell();
2987
3205
  return;
2988
3206
  }
2989
3207
  setCompletionReplyDraft(token, {
2990
- attachment: nextAttachment,
3208
+ attachments: mergedAttachments,
2991
3209
  notice: "",
2992
3210
  error: "",
2993
3211
  warning: null,
2994
3212
  confirmOverride: false,
2995
3213
  });
3214
+ input.value = "";
2996
3215
  await renderShell();
2997
3216
  });
2998
3217
  }
@@ -3000,8 +3219,13 @@ function bindShellInteractions() {
3000
3219
  for (const button of document.querySelectorAll("[data-reply-image-remove][data-reply-token]")) {
3001
3220
  button.addEventListener("click", async () => {
3002
3221
  const token = button.dataset.replyToken || "";
3222
+ const index = Number(button.dataset.replyImageIndex ?? "-1");
3223
+ const existingAttachments = getCompletionReplyDraft(token).attachments || [];
3003
3224
  setCompletionReplyDraft(token, {
3004
- attachment: null,
3225
+ attachments:
3226
+ index >= 0
3227
+ ? existingAttachments.filter((_, attachmentIndex) => attachmentIndex !== index)
3228
+ : [],
3005
3229
  notice: "",
3006
3230
  error: "",
3007
3231
  warning: null,
@@ -3184,14 +3408,16 @@ function bindShellInteractions() {
3184
3408
  requestBody.set("text", text);
3185
3409
  requestBody.set("planMode", draft.mode === "plan" ? "true" : "false");
3186
3410
  requestBody.set("force", draft.confirmOverride === true ? "true" : "false");
3187
- if (COMPLETION_REPLY_IMAGE_SUPPORT && draft.attachment?.file) {
3188
- requestBody.append("image", draft.attachment.file, draft.attachment.name || draft.attachment.file.name);
3411
+ for (const attachment of draft.attachments || []) {
3412
+ if (attachment?.file) {
3413
+ requestBody.append("image", attachment.file, attachment.name || attachment.file.name);
3414
+ }
3189
3415
  }
3190
3416
  await apiPost(`/api/items/completion/${encodeURIComponent(token)}/reply`, requestBody);
3191
3417
  setCompletionReplyDraft(token, {
3192
3418
  text: "",
3193
3419
  sentText: text,
3194
- attachment: null,
3420
+ attachments: [],
3195
3421
  mode: draft.mode,
3196
3422
  sending: false,
3197
3423
  error: "",
@@ -3206,7 +3432,7 @@ function bindShellInteractions() {
3206
3432
  setCompletionReplyDraft(token, {
3207
3433
  text,
3208
3434
  sentText: "",
3209
- attachment: draft.attachment,
3435
+ attachments: draft.attachments,
3210
3436
  mode: draft.mode,
3211
3437
  sending: false,
3212
3438
  notice: "",
@@ -3221,7 +3447,7 @@ function bindShellInteractions() {
3221
3447
  setCompletionReplyDraft(token, {
3222
3448
  text,
3223
3449
  sentText: "",
3224
- attachment: draft.attachment,
3450
+ attachments: draft.attachments,
3225
3451
  mode: draft.mode,
3226
3452
  sending: false,
3227
3453
  notice: "",
@@ -3240,6 +3466,16 @@ function bindShellInteractions() {
3240
3466
  }
3241
3467
 
3242
3468
  function bindSharedUi(renderFn) {
3469
+ for (const button of document.querySelectorAll("[data-close-image-viewer]")) {
3470
+ button.addEventListener("click", async (event) => {
3471
+ if (button.classList.contains("modal-backdrop") && event.target.closest(".image-viewer")) {
3472
+ return;
3473
+ }
3474
+ state.imageViewer = null;
3475
+ await renderFn();
3476
+ });
3477
+ }
3478
+
3243
3479
  for (const button of document.querySelectorAll("[data-install-guide-open]")) {
3244
3480
  button.addEventListener("click", async () => {
3245
3481
  state.installGuideOpen = true;
@@ -3806,6 +4042,8 @@ function renderIcon(name) {
3806
4042
  return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.5c3.8 0 7 3.8 7 8.5s-3.2 8.5-7 8.5-7-3.8-7-8.5 3.2-8.5 7-8.5Z"/><path d="M5.8 9h12.4"/><path d="M5.8 15h12.4"/><path d="M12 3.8c1.9 2 3 4.9 3 8.2s-1.1 6.2-3 8.2c-1.9-2-3-4.9-3-8.2s1.1-6.2 3-8.2Z"/></svg>`;
3807
4043
  case "link":
3808
4044
  return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M10.4 13.6 8.3 15.7a3 3 0 0 1-4.2-4.2l2.8-2.8a3 3 0 0 1 4.2 0"/><path d="m13.6 10.4 2.1-2.1a3 3 0 1 1 4.2 4.2l-2.8 2.8a3 3 0 0 1-4.2 0"/><path d="m9.5 14.5 5-5"/></svg>`;
4045
+ case "clip":
4046
+ return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="m9.5 12.5 5.9-5.9a3 3 0 1 1 4.2 4.2l-7.7 7.7a5 5 0 1 1-7.1-7.1l8.1-8.1"/></svg>`;
3809
4047
  case "check":
3810
4048
  return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m6.8 12.5 3.2 3.2 7.2-7.4"/></svg>`;
3811
4049
  case "back":
@@ -3994,7 +4232,6 @@ function localizeApiError(value) {
3994
4232
  "completion-reply-image-too-large": "error.completionReplyImageTooLarge",
3995
4233
  "completion-reply-image-limit": "error.completionReplyImageLimit",
3996
4234
  "completion-reply-image-invalid-upload": "error.completionReplyImageInvalidUpload",
3997
- "completion-reply-image-disabled": "error.completionReplyImageDisabled",
3998
4235
  "codex-ipc-not-connected": "error.codexIpcNotConnected",
3999
4236
  "approval-not-found": "error.approvalNotFound",
4000
4237
  "approval-already-handled": "error.approvalAlreadyHandled",
@@ -4069,7 +4306,7 @@ function syncPairingTokenState(pairToken) {
4069
4306
  }
4070
4307
 
4071
4308
  function desiredBootstrapPairingToken() {
4072
- if (state.session?.authenticated) {
4309
+ if (state.session?.authenticated && !state.session?.temporaryPairing) {
4073
4310
  return "";
4074
4311
  }
4075
4312
  return initialPairToken;
@@ -4079,15 +4316,13 @@ function shouldAutoPairFromBootstrapToken() {
4079
4316
  if (!initialPairToken) {
4080
4317
  return false;
4081
4318
  }
4082
- if (isStandaloneMode()) {
4083
- return true;
4084
- }
4085
- if (isProbablySafari()) {
4086
- return false;
4087
- }
4088
4319
  return true;
4089
4320
  }
4090
4321
 
4322
+ function shouldUseTemporaryBootstrapPairing() {
4323
+ return Boolean(initialPairToken) && !isStandaloneMode() && isProbablySafari();
4324
+ }
4325
+
4091
4326
  function urlBase64ToUint8Array(base64String) {
4092
4327
  const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
4093
4328
  const normalized = `${base64String}${padding}`.replace(/-/gu, "+").replace(/_/gu, "/");