viveworker 0.1.4 → 0.1.5

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/README.md CHANGED
@@ -180,4 +180,4 @@ Planned next steps include:
180
180
 
181
181
  - Android support
182
182
  - Windows support
183
- - image attachment support from mobile
183
+ - ✅ ~~image attachment support from mobile~~ (Mar 26, 2026)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viveworker",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Local iPhone companion for Codex Desktop approvals, plan checks, questions, and notifications on your LAN.",
5
5
  "author": "Yuta Hoshino <hoshino.lireneo@gmail.com>",
6
6
  "license": "MIT",
@@ -267,6 +267,187 @@ function withNotificationIcon(kind, title) {
267
267
  return prefix ? `${prefix} ${title}` : title;
268
268
  }
269
269
 
270
+ function normalizeTimelineOutcome(value) {
271
+ const normalized = cleanText(value || "").toLowerCase();
272
+ return ["pending", "approved", "rejected", "implemented", "dismissed", "submitted"].includes(normalized)
273
+ ? normalized
274
+ : "";
275
+ }
276
+
277
+ function inferTimelineOutcome(kind, summary = "", messageText = "") {
278
+ const normalizedKind = cleanText(kind || "");
279
+ if (!normalizedKind) {
280
+ return "";
281
+ }
282
+
283
+ const candidates = [cleanText(summary || ""), cleanText(messageText || "")].filter(Boolean);
284
+ if (candidates.length === 0) {
285
+ return "";
286
+ }
287
+
288
+ const startsWithAny = (keys) =>
289
+ candidates.some((text) => SUPPORTED_LOCALES.some((locale) => keys.some((key) => text.startsWith(t(locale, key)))));
290
+
291
+ if (normalizedKind === "approval") {
292
+ if (startsWithAny(["server.message.approvalAccepted"])) {
293
+ return "approved";
294
+ }
295
+ if (startsWithAny(["server.message.approvalRejected"])) {
296
+ return "rejected";
297
+ }
298
+ return "";
299
+ }
300
+
301
+ if (normalizedKind === "plan") {
302
+ if (startsWithAny(["server.message.planImplemented"])) {
303
+ return "implemented";
304
+ }
305
+ if (startsWithAny(["server.message.planDismissed"])) {
306
+ return "dismissed";
307
+ }
308
+ return "";
309
+ }
310
+
311
+ if (normalizedKind === "choice") {
312
+ if (
313
+ startsWithAny([
314
+ "server.message.choiceSubmitted",
315
+ "server.message.choiceSubmittedTest",
316
+ "server.message.choiceSummarySubmitted",
317
+ "server.message.choiceSummaryReceivedTest",
318
+ ])
319
+ ) {
320
+ return "submitted";
321
+ }
322
+ return "";
323
+ }
324
+
325
+ return "";
326
+ }
327
+
328
+ function normalizeTimelineFileRefs(rawFileRefs) {
329
+ if (!Array.isArray(rawFileRefs)) {
330
+ return [];
331
+ }
332
+
333
+ const deduped = [];
334
+ const seen = new Set();
335
+ for (const rawRef of rawFileRefs) {
336
+ const normalized = cleanTimelineFileRef(rawRef);
337
+ if (!normalized || seen.has(normalized)) {
338
+ continue;
339
+ }
340
+ seen.add(normalized);
341
+ deduped.push(normalized);
342
+ if (deduped.length >= 8) {
343
+ break;
344
+ }
345
+ }
346
+ return deduped;
347
+ }
348
+
349
+ function cleanTimelineFileRef(value) {
350
+ let normalized = cleanText(value || "");
351
+ if (!normalized) {
352
+ return "";
353
+ }
354
+
355
+ normalized = normalized
356
+ .replace(/^[`"'([{<]+/u, "")
357
+ .replace(/[)`"'\]}>.,:;!?]+$/u, "")
358
+ .replace(/[#:]L?\d+(?::\d+)?$/u, "");
359
+
360
+ if (!normalized || /^https?:\/\//iu.test(normalized)) {
361
+ return "";
362
+ }
363
+
364
+ if (normalized.startsWith("/")) {
365
+ const segments = normalized.split("/").filter(Boolean);
366
+ if (segments.length >= 2 && looksLikeFileRefBasename(segments[segments.length - 1])) {
367
+ return normalized;
368
+ }
369
+ return "";
370
+ }
371
+
372
+ if (normalized.includes("/")) {
373
+ const segments = normalized.split("/").filter(Boolean);
374
+ if (
375
+ segments.length >= 2 &&
376
+ segments.every((segment) => /^[A-Za-z0-9._-]+$/u.test(segment)) &&
377
+ looksLikeFileRefBasename(segments[segments.length - 1])
378
+ ) {
379
+ return normalized;
380
+ }
381
+ return "";
382
+ }
383
+
384
+ return looksLikeFileRefBasename(normalized) ? normalized : "";
385
+ }
386
+
387
+ function looksLikeFileRefBasename(value) {
388
+ const normalized = cleanText(value || "");
389
+ if (!normalized || /[\s\\]/u.test(normalized)) {
390
+ return false;
391
+ }
392
+ if (
393
+ [
394
+ ".env",
395
+ ".env.example",
396
+ ".gitignore",
397
+ "Dockerfile",
398
+ "LICENSE",
399
+ "Makefile",
400
+ "README.md",
401
+ "package-lock.json",
402
+ "package.json",
403
+ "pnpm-lock.yaml",
404
+ "tsconfig.json",
405
+ "vite.config.ts",
406
+ ].includes(normalized)
407
+ ) {
408
+ return true;
409
+ }
410
+ return /^(?:\.[A-Za-z0-9_-]+(?:\.[A-Za-z][A-Za-z0-9_-]{0,9})?|[A-Za-z0-9_-][A-Za-z0-9._-]*\.[A-Za-z][A-Za-z0-9_-]{0,9})$/u.test(
411
+ normalized
412
+ );
413
+ }
414
+
415
+ function extractTimelineFileRefs(messageText = "") {
416
+ const sourceText = String(messageText || "");
417
+ if (!sourceText) {
418
+ return [];
419
+ }
420
+
421
+ const refs = [];
422
+ const pushCandidate = (candidate) => {
423
+ const normalized = cleanTimelineFileRef(candidate);
424
+ if (normalized) {
425
+ refs.push(normalized);
426
+ }
427
+ };
428
+ const collectCandidates = (text) => {
429
+ for (const candidate of String(text || "").split(/\s+/u)) {
430
+ pushCandidate(candidate);
431
+ }
432
+ };
433
+
434
+ for (const match of sourceText.matchAll(/\[[^\]]+\]\((\/[^)\s]+)\)/gu)) {
435
+ pushCandidate(match[1]);
436
+ }
437
+
438
+ for (const match of sourceText.matchAll(/`([^`\n]+)`/gu)) {
439
+ collectCandidates(match[1]);
440
+ }
441
+
442
+ collectCandidates(
443
+ sourceText
444
+ .replace(/`[^`\n]+`/gu, " ")
445
+ .replace(/\[[^\]]+\]\(([^)]+)\)/gu, " ")
446
+ );
447
+
448
+ return normalizeTimelineFileRefs(refs);
449
+ }
450
+
270
451
  function handleSignal() {
271
452
  runtime.stopping = true;
272
453
  }
@@ -304,11 +485,14 @@ function normalizeHistoryItem(raw) {
304
485
  const kind = cleanText(raw.kind ?? "");
305
486
  const title = cleanText(raw.title ?? "");
306
487
  const messageText = normalizeTimelineMessageText(raw.messageText ?? "");
488
+ const summary = normalizeNotificationText(raw.summary ?? "") || formatNotificationBody(messageText, 100) || "";
307
489
  const createdAtMs = Number(raw.createdAtMs) || Date.now();
308
490
  if (!stableId || !historyKinds.has(kind) || !title) {
309
491
  return null;
310
492
  }
311
493
 
494
+ const outcome = normalizeTimelineOutcome(raw.outcome ?? "") || inferTimelineOutcome(kind, summary, messageText);
495
+
312
496
  return {
313
497
  stableId,
314
498
  token: cleanText(raw.token ?? "") || historyToken(stableId),
@@ -316,9 +500,11 @@ function normalizeHistoryItem(raw) {
316
500
  threadId: cleanText(raw.threadId ?? extractConversationIdFromStableId(stableId) ?? ""),
317
501
  title,
318
502
  threadLabel: cleanText(raw.threadLabel ?? ""),
319
- summary: normalizeNotificationText(raw.summary ?? "") || formatNotificationBody(messageText, 100) || "",
503
+ summary,
320
504
  messageText,
321
505
  imagePaths: normalizeTimelineImagePaths(raw.imagePaths ?? raw.localImagePaths ?? []),
506
+ fileRefs: normalizeTimelineFileRefs(raw.fileRefs ?? extractTimelineFileRefs(messageText)),
507
+ outcome,
322
508
  createdAtMs,
323
509
  readOnly: raw.readOnly !== false,
324
510
  primaryLabel: cleanText(raw.primaryLabel ?? "") || "詳細",
@@ -400,6 +586,7 @@ function normalizeTimelineEntry(raw) {
400
586
  "";
401
587
  const threadLabel = cleanText(raw.threadLabel ?? "");
402
588
  const title = cleanText(raw.title ?? "") || threadLabel || kindTitle(DEFAULT_LOCALE, kind);
589
+ const outcome = normalizeTimelineOutcome(raw.outcome ?? "") || inferTimelineOutcome(kind, summary, messageText);
403
590
 
404
591
  return {
405
592
  stableId,
@@ -411,6 +598,8 @@ function normalizeTimelineEntry(raw) {
411
598
  summary,
412
599
  messageText,
413
600
  imagePaths: normalizeTimelineImagePaths(raw.imagePaths ?? raw.localImagePaths ?? []),
601
+ fileRefs: normalizeTimelineFileRefs(raw.fileRefs ?? extractTimelineFileRefs(messageText)),
602
+ outcome,
414
603
  createdAtMs,
415
604
  readOnly: raw.readOnly !== false,
416
605
  primaryLabel: cleanText(raw.primaryLabel ?? "") || "詳細",
@@ -498,26 +687,48 @@ function recordHistoryItem({ config, runtime, state, item }) {
498
687
  return changed;
499
688
  }
500
689
 
501
- function recordActionHistoryItem({ config, runtime, state, kind, stableId, token, title, threadLabel = "", messageText, summary }) {
502
- return recordHistoryItem({
690
+ function recordActionHistoryItem({
691
+ config,
692
+ runtime,
693
+ state,
694
+ kind,
695
+ stableId,
696
+ token,
697
+ title,
698
+ threadLabel = "",
699
+ messageText,
700
+ summary,
701
+ outcome = "",
702
+ }) {
703
+ const item = {
704
+ stableId,
705
+ token,
706
+ kind,
707
+ title,
708
+ threadId: cleanText(extractConversationIdFromStableId(stableId) ?? ""),
709
+ threadLabel,
710
+ summary,
711
+ messageText,
712
+ outcome,
713
+ createdAtMs: Date.now(),
714
+ readOnly: true,
715
+ primaryLabel: "詳細",
716
+ tone: "secondary",
717
+ };
718
+
719
+ const historyChanged = recordHistoryItem({
503
720
  config,
504
721
  runtime,
505
722
  state,
506
- item: {
507
- stableId,
508
- token,
509
- kind,
510
- title,
511
- threadId: cleanText(extractConversationIdFromStableId(stableId) ?? ""),
512
- threadLabel,
513
- summary,
514
- messageText,
515
- createdAtMs: Date.now(),
516
- readOnly: true,
517
- primaryLabel: "詳細",
518
- tone: "secondary",
519
- },
723
+ item,
724
+ });
725
+ const timelineChanged = recordTimelineEntry({
726
+ config,
727
+ runtime,
728
+ state,
729
+ entry: item,
520
730
  });
731
+ return historyChanged || timelineChanged;
521
732
  }
522
733
 
523
734
  function pendingApprovalStableId(approval) {
@@ -5685,6 +5896,7 @@ function buildCompletedInboxItems(runtime, state, config, locale) {
5685
5896
  const items = normalizeHistoryItems(state.recentHistoryItems ?? runtime.recentHistoryItems, config.maxHistoryItems);
5686
5897
  runtime.recentHistoryItems = items;
5687
5898
  return items
5899
+ .filter((item) => cleanText(item?.kind || "") === "completion")
5688
5900
  .slice()
5689
5901
  .sort((left, right) => Number(right.createdAtMs ?? 0) - Number(left.createdAtMs ?? 0))
5690
5902
  .map((item) => ({
@@ -5694,6 +5906,7 @@ function buildCompletedInboxItems(runtime, state, config, locale) {
5694
5906
  threadLabel: item.threadLabel || "",
5695
5907
  title: item.threadLabel ? formatTitle(kindTitle(locale, item.kind), item.threadLabel) : item.title,
5696
5908
  summary: item.summary,
5909
+ fileRefs: normalizeTimelineFileRefs(item.fileRefs ?? []),
5697
5910
  primaryLabel: t(locale, "server.action.detail"),
5698
5911
  createdAtMs: item.createdAtMs,
5699
5912
  }));
@@ -5724,6 +5937,7 @@ function buildOperationalTimelineEntries(runtime, state, config, locale) {
5724
5937
  title: formatLocalizedTitle(locale, "server.title.approval", approval.threadLabel),
5725
5938
  summary: formatNotificationBody(approval.messageText, 180) || approval.messageText,
5726
5939
  messageText: approval.messageText,
5940
+ outcome: "pending",
5727
5941
  createdAtMs: Number(approval.createdAtMs) || now,
5728
5942
  })
5729
5943
  );
@@ -5746,6 +5960,7 @@ function buildOperationalTimelineEntries(runtime, state, config, locale) {
5746
5960
  title: formatLocalizedTitle(locale, "server.title.plan", planRequest.threadLabel),
5747
5961
  summary: formatNotificationBody(planRequest.messageText, 180) || planRequest.messageText,
5748
5962
  messageText: planRequest.messageText,
5963
+ outcome: "pending",
5749
5964
  createdAtMs: Number(planRequest.createdAtMs) || now,
5750
5965
  })
5751
5966
  );
@@ -5772,6 +5987,7 @@ function buildOperationalTimelineEntries(runtime, state, config, locale) {
5772
5987
  ),
5773
5988
  summary: userInputRequest.notificationText || formatNotificationBody(userInputRequest.messageText, 180),
5774
5989
  messageText: userInputRequest.messageText,
5990
+ outcome: "pending",
5775
5991
  createdAtMs: Number(userInputRequest.createdAtMs) || now,
5776
5992
  })
5777
5993
  );
@@ -5791,6 +6007,7 @@ function buildOperationalTimelineEntries(runtime, state, config, locale) {
5791
6007
  title: historyItem.threadLabel ? formatTitle(kindTitle(locale, historyItem.kind), historyItem.threadLabel) : historyItem.title,
5792
6008
  summary: historyItem.summary,
5793
6009
  messageText: historyItem.messageText,
6010
+ outcome: historyItem.outcome,
5794
6011
  createdAtMs: historyItem.createdAtMs,
5795
6012
  })
5796
6013
  );
@@ -5872,6 +6089,8 @@ function buildTimelineResponse(runtime, state, config, locale) {
5872
6089
  threadLabel: entry.threadLabel,
5873
6090
  summary: entry.summary,
5874
6091
  imageUrls: buildTimelineEntryImageUrls(entry),
6092
+ fileRefs: normalizeTimelineFileRefs(entry.fileRefs ?? []),
6093
+ outcome: entry.outcome || "",
5875
6094
  createdAtMs: entry.createdAtMs,
5876
6095
  }));
5877
6096
 
@@ -6120,6 +6339,7 @@ function buildHistoryDetail(item, locale, runtime = null) {
6120
6339
  threadLabel: item.threadLabel || "",
6121
6340
  createdAtMs: Number(item.createdAtMs) || 0,
6122
6341
  messageHtml: renderMessageHtml(item.messageText, `<p>${escapeHtml(t(locale, "detail.detailUnavailable"))}</p>`),
6342
+ fileRefs: normalizeTimelineFileRefs(item.fileRefs ?? []),
6123
6343
  interruptNotice: interruptedDetailNotice(item.messageText, locale),
6124
6344
  readOnly: true,
6125
6345
  reply: replyEnabled
@@ -6155,6 +6375,7 @@ function buildTimelineMessageDetail(entry, locale, runtime = null) {
6155
6375
  createdAtMs: Number(entry.createdAtMs) || 0,
6156
6376
  messageHtml: renderMessageHtml(entry.messageText, `<p>${escapeHtml(t(locale, "detail.detailUnavailable"))}</p>`),
6157
6377
  imageUrls: buildTimelineEntryImageUrls(entry),
6378
+ fileRefs: normalizeTimelineFileRefs(entry.fileRefs ?? []),
6158
6379
  previousContext: buildInterruptedTimelineContext(runtime, entry, locale),
6159
6380
  interruptNotice: interruptedDetailNotice(entry.messageText, locale),
6160
6381
  readOnly: true,
@@ -6234,6 +6455,7 @@ async function submitGenericUserInputDecision({ config, runtime, state, userInpu
6234
6455
  summary: userInputRequest.testRequest
6235
6456
  ? t(config.defaultLocale, "server.message.choiceSummaryReceivedTest")
6236
6457
  : t(config.defaultLocale, "server.message.choiceSummarySubmitted"),
6458
+ outcome: "submitted",
6237
6459
  }) || stateChanged;
6238
6460
  if (stateChanged) {
6239
6461
  await saveState(config.stateFile, state);
@@ -6659,6 +6881,7 @@ async function handlePlanDecision({ config, runtime, state, planRequest, decisio
6659
6881
  title: planRequest.title,
6660
6882
  messageText: `${planDecisionMessage(decision, config.defaultLocale)}\n\n${planRequest.messageText}`,
6661
6883
  summary: planDecisionMessage(decision, config.defaultLocale),
6884
+ outcome: decision === "implement" ? "implemented" : "dismissed",
6662
6885
  }) || stateChanged;
6663
6886
  if (stateChanged) {
6664
6887
  await saveState(config.stateFile, state);
@@ -6682,6 +6905,7 @@ async function handleNativeApprovalDecision({ config, runtime, state, approval,
6682
6905
  title: approval.title,
6683
6906
  messageText: `${approvalDecisionMessage(decision, config.defaultLocale)}\n\n${approval.messageText}`,
6684
6907
  summary: approvalDecisionMessage(decision, config.defaultLocale),
6908
+ outcome: decision === "accept" ? "approved" : "rejected",
6685
6909
  });
6686
6910
  if (stateChanged) {
6687
6911
  await saveState(config.stateFile, state);
package/web/app.css CHANGED
@@ -974,6 +974,13 @@ code {
974
974
  gap: 0.38rem;
975
975
  }
976
976
 
977
+ .timeline-thread-filter__row {
978
+ display: grid;
979
+ grid-template-columns: minmax(0, 1fr) auto;
980
+ gap: 0.6rem;
981
+ align-items: start;
982
+ }
983
+
977
984
  .timeline-thread-filter__label {
978
985
  color: var(--muted);
979
986
  font-size: 0.8rem;
@@ -1009,6 +1016,92 @@ code {
1009
1016
  pointer-events: none;
1010
1017
  }
1011
1018
 
1019
+ .timeline-kind-filter {
1020
+ position: relative;
1021
+ }
1022
+
1023
+ .timeline-kind-filter__button {
1024
+ width: 2.72rem;
1025
+ min-width: 2.72rem;
1026
+ min-height: 2.72rem;
1027
+ display: inline-flex;
1028
+ align-items: center;
1029
+ justify-content: center;
1030
+ border-radius: 15px;
1031
+ border: 1px solid rgba(156, 181, 197, 0.14);
1032
+ background: rgba(255, 255, 255, 0.05);
1033
+ color: rgba(236, 248, 255, 0.78);
1034
+ box-shadow: var(--shadow-card);
1035
+ }
1036
+
1037
+ .timeline-kind-filter__button.is-active {
1038
+ border-color: rgba(104, 211, 164, 0.34);
1039
+ background: rgba(31, 83, 63, 0.46);
1040
+ color: rgba(224, 255, 240, 0.96);
1041
+ }
1042
+
1043
+ .timeline-kind-filter__button-icon,
1044
+ .timeline-kind-filter__option-icon,
1045
+ .timeline-kind-filter__option-check {
1046
+ width: 1rem;
1047
+ height: 1rem;
1048
+ display: inline-flex;
1049
+ align-items: center;
1050
+ justify-content: center;
1051
+ }
1052
+
1053
+ .timeline-kind-filter__button-icon svg,
1054
+ .timeline-kind-filter__option-icon svg,
1055
+ .timeline-kind-filter__option-check svg {
1056
+ width: 100%;
1057
+ height: 100%;
1058
+ }
1059
+
1060
+ .timeline-kind-filter__popover {
1061
+ position: absolute;
1062
+ right: 0;
1063
+ top: calc(100% + 0.46rem);
1064
+ z-index: 14;
1065
+ width: min(15rem, calc(100vw - 2.25rem));
1066
+ display: grid;
1067
+ gap: 0.22rem;
1068
+ padding: 0.4rem;
1069
+ border: 1px solid rgba(156, 181, 197, 0.16);
1070
+ border-radius: 18px;
1071
+ background: rgba(15, 23, 31, 0.94);
1072
+ box-shadow: 0 24px 46px rgba(5, 9, 14, 0.34);
1073
+ -webkit-backdrop-filter: blur(24px) saturate(1.08);
1074
+ backdrop-filter: blur(24px) saturate(1.08);
1075
+ }
1076
+
1077
+ .timeline-kind-filter__option {
1078
+ width: 100%;
1079
+ display: grid;
1080
+ grid-template-columns: auto 1fr auto;
1081
+ gap: 0.62rem;
1082
+ align-items: center;
1083
+ padding: 0.74rem 0.78rem;
1084
+ border: 0;
1085
+ border-radius: 14px;
1086
+ background: transparent;
1087
+ color: var(--text);
1088
+ text-align: left;
1089
+ }
1090
+
1091
+ .timeline-kind-filter__option.is-selected {
1092
+ background: rgba(255, 255, 255, 0.06);
1093
+ }
1094
+
1095
+ .timeline-kind-filter__option-label {
1096
+ font-size: 0.9rem;
1097
+ font-weight: 600;
1098
+ line-height: 1.3;
1099
+ }
1100
+
1101
+ .timeline-kind-filter__option-check {
1102
+ color: rgba(104, 211, 164, 0.94);
1103
+ }
1104
+
1012
1105
  .timeline-list {
1013
1106
  display: grid;
1014
1107
  gap: 0.72rem;
@@ -1090,6 +1183,42 @@ code {
1090
1183
  margin-top: 0.08rem;
1091
1184
  }
1092
1185
 
1186
+ .timeline-entry__files {
1187
+ display: flex;
1188
+ flex-wrap: wrap;
1189
+ gap: 0.42rem;
1190
+ margin-top: 0.14rem;
1191
+ }
1192
+
1193
+ .file-ref-chip {
1194
+ display: inline-flex;
1195
+ align-items: center;
1196
+ gap: 0.34rem;
1197
+ min-width: 0;
1198
+ padding: 0.4rem 0.56rem;
1199
+ border-radius: 999px;
1200
+ background: rgba(16, 24, 31, 0.72);
1201
+ border: 1px solid rgba(156, 181, 197, 0.12);
1202
+ color: var(--muted);
1203
+ font-size: 0.76rem;
1204
+ line-height: 1.2;
1205
+ }
1206
+
1207
+ .file-ref-chip__icon {
1208
+ width: 0.82rem;
1209
+ height: 0.82rem;
1210
+ color: rgba(202, 220, 233, 0.72);
1211
+ flex: 0 0 auto;
1212
+ }
1213
+
1214
+ .file-ref-chip__label {
1215
+ min-width: 0;
1216
+ overflow: hidden;
1217
+ text-overflow: ellipsis;
1218
+ white-space: nowrap;
1219
+ max-width: 13.5rem;
1220
+ }
1221
+
1093
1222
  .timeline-entry__image-frame {
1094
1223
  display: block;
1095
1224
  border-radius: 14px;
@@ -1463,6 +1592,12 @@ code {
1463
1592
  padding: 0.5rem;
1464
1593
  }
1465
1594
 
1595
+ .detail-card--files {
1596
+ margin-top: 0.24rem;
1597
+ gap: 0.62rem;
1598
+ padding: 0.72rem;
1599
+ }
1600
+
1466
1601
  .detail-card--reply {
1467
1602
  margin-top: 0.32rem;
1468
1603
  gap: 0.72rem;
@@ -1475,6 +1610,50 @@ code {
1475
1610
  width: 100%;
1476
1611
  }
1477
1612
 
1613
+ .detail-files-card__header {
1614
+ display: inline-flex;
1615
+ align-items: center;
1616
+ gap: 0.42rem;
1617
+ font-size: 0.84rem;
1618
+ color: var(--muted);
1619
+ }
1620
+
1621
+ .detail-files-card__icon {
1622
+ width: 0.92rem;
1623
+ height: 0.92rem;
1624
+ color: rgba(202, 220, 233, 0.72);
1625
+ }
1626
+
1627
+ .detail-file-grid {
1628
+ display: grid;
1629
+ gap: 0.52rem;
1630
+ }
1631
+
1632
+ .detail-file-chip {
1633
+ display: grid;
1634
+ gap: 0.14rem;
1635
+ padding: 0.68rem 0.78rem;
1636
+ border-radius: 16px;
1637
+ background:
1638
+ linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.02)),
1639
+ rgba(10, 17, 22, 0.76);
1640
+ border: 1px solid rgba(156, 181, 197, 0.12);
1641
+ }
1642
+
1643
+ .detail-file-chip__label {
1644
+ font-size: 0.88rem;
1645
+ line-height: 1.35;
1646
+ color: var(--text);
1647
+ overflow-wrap: anywhere;
1648
+ }
1649
+
1650
+ .detail-file-chip__path {
1651
+ font-size: 0.76rem;
1652
+ line-height: 1.45;
1653
+ color: var(--muted);
1654
+ overflow-wrap: anywhere;
1655
+ }
1656
+
1478
1657
  .detail-image-link {
1479
1658
  display: block;
1480
1659
  padding: 0;
package/web/app.js CHANGED
@@ -8,6 +8,8 @@ const TIMELINE_MESSAGE_KINDS = new Set(["user_message", "assistant_commentary",
8
8
  const TIMELINE_OPERATIONAL_KINDS = new Set(["approval", "plan", "plan_ready", "choice", "completion"]);
9
9
  const THREAD_FILTER_INTERACTION_DEFER_MS = 8000;
10
10
  const MAX_COMPLETION_REPLY_IMAGE_COUNT = 4;
11
+ const NOTIFICATION_INTENT_CACHE = "viveworker-notification-intent-v1";
12
+ const NOTIFICATION_INTENT_PATH = "/__viveworker_notification_intent__";
11
13
 
12
14
  const state = {
13
15
  session: null,
@@ -21,6 +23,8 @@ const state = {
21
23
  detailLoadingItem: null,
22
24
  detailOpen: false,
23
25
  timelineThreadFilter: "all",
26
+ timelineKindFilter: "all",
27
+ timelineKindFilterOpen: false,
24
28
  completedThreadFilter: "all",
25
29
  settingsSubpage: "",
26
30
  settingsScrollState: null,
@@ -86,6 +90,9 @@ async function boot() {
86
90
  await registerServiceWorker();
87
91
  navigator.serviceWorker?.addEventListener("message", handleServiceWorkerMessage);
88
92
  window.addEventListener("resize", handleViewportChange, { passive: true });
93
+ window.addEventListener("focus", handlePotentialExternalNavigation, { passive: true });
94
+ window.addEventListener("pageshow", handlePotentialExternalNavigation, { passive: true });
95
+ document.addEventListener("visibilitychange", handleDocumentVisibilityChange);
89
96
 
90
97
  await refreshSession();
91
98
 
@@ -121,6 +128,7 @@ async function boot() {
121
128
  return;
122
129
  }
123
130
 
131
+ await consumePendingNotificationIntent();
124
132
  await syncDetectedLocalePreference();
125
133
  await refreshAuthenticatedState();
126
134
  ensureCurrentSelection();
@@ -130,6 +138,10 @@ async function boot() {
130
138
  if (!state.session?.authenticated) {
131
139
  return;
132
140
  }
141
+ const consumedNotificationIntent = await consumePendingNotificationIntent();
142
+ if (consumedNotificationIntent) {
143
+ return;
144
+ }
133
145
  await refreshAuthenticatedState();
134
146
  if (!shouldDeferRenderForActiveInteraction()) {
135
147
  await renderShell();
@@ -314,6 +326,7 @@ async function refreshInbox() {
314
326
  async function refreshTimeline() {
315
327
  state.timeline = await apiGet("/api/timeline");
316
328
  syncTimelineThreadFilter();
329
+ syncTimelineKindFilter();
317
330
  }
318
331
 
319
332
  async function refreshDevices() {
@@ -420,10 +433,14 @@ function filteredTimelineEntries() {
420
433
  if (!entries.length) {
421
434
  return [];
422
435
  }
423
- if (!state.timelineThreadFilter || state.timelineThreadFilter === "all") {
424
- return entries;
436
+ let filtered = entries;
437
+ if (state.timelineThreadFilter && state.timelineThreadFilter !== "all") {
438
+ filtered = filtered.filter((entry) => entry.threadId === state.timelineThreadFilter);
439
+ }
440
+ if (!state.timelineKindFilter || state.timelineKindFilter === "all") {
441
+ return filtered;
425
442
  }
426
- return entries.filter((entry) => entry.threadId === state.timelineThreadFilter);
443
+ return filtered.filter((entry) => timelineEntryMatchesKindFilter(entry, state.timelineKindFilter));
427
444
  }
428
445
 
429
446
  function filteredCompletedEntries() {
@@ -448,6 +465,49 @@ function syncTimelineThreadFilter() {
448
465
  }
449
466
  }
450
467
 
468
+ function syncTimelineKindFilter() {
469
+ const validIds = new Set(timelineKindFilterOptions().map((option) => option.id));
470
+ if (!state.timelineKindFilter || !validIds.has(state.timelineKindFilter)) {
471
+ state.timelineKindFilter = "all";
472
+ }
473
+ }
474
+
475
+ function timelineKindFilterOptions() {
476
+ return [
477
+ { id: "all", label: L("timeline.kindFilter.all"), icon: "filter" },
478
+ { id: "messages", label: L("timeline.kindFilter.messages"), icon: "timeline" },
479
+ { id: "approvals", label: L("timeline.kindFilter.approvals"), icon: "approval" },
480
+ { id: "plans", label: L("timeline.kindFilter.plans"), icon: "plan" },
481
+ { id: "choices", label: L("timeline.kindFilter.choices"), icon: "choice" },
482
+ { id: "completions", label: L("timeline.kindFilter.completions"), icon: "completion-item" },
483
+ ];
484
+ }
485
+
486
+ function currentTimelineKindFilterOption() {
487
+ return (
488
+ timelineKindFilterOptions().find((option) => option.id === state.timelineKindFilter) ||
489
+ timelineKindFilterOptions()[0]
490
+ );
491
+ }
492
+
493
+ function timelineEntryMatchesKindFilter(entry, filterId) {
494
+ const kind = normalizeClientText(entry?.kind || entry?.item?.kind || "");
495
+ switch (filterId) {
496
+ case "messages":
497
+ return TIMELINE_MESSAGE_KINDS.has(kind);
498
+ case "approvals":
499
+ return kind === "approval";
500
+ case "plans":
501
+ return kind === "plan" || kind === "plan_ready";
502
+ case "choices":
503
+ return kind === "choice";
504
+ case "completions":
505
+ return kind === "completion";
506
+ default:
507
+ return true;
508
+ }
509
+ }
510
+
451
511
  function completedThreads() {
452
512
  const items = Array.isArray(state.inbox?.completed) ? state.inbox.completed : [];
453
513
  if (!items.length) {
@@ -1459,6 +1519,7 @@ function renderTimelineThreadDropdown() {
1459
1519
  inputId: "timeline-thread-select",
1460
1520
  dataAttribute: "data-timeline-thread-select",
1461
1521
  selectedThreadId: state.timelineThreadFilter,
1522
+ controlsHtml: renderTimelineKindFilterControls(),
1462
1523
  threads: threads.map((thread) => ({
1463
1524
  id: thread.id,
1464
1525
  label: dropdownThreadLabel(thread.id, thread.label || ""),
@@ -1475,7 +1536,7 @@ function renderCompletedThreadDropdown() {
1475
1536
  });
1476
1537
  }
1477
1538
 
1478
- function renderThreadDropdown({ inputId, dataAttribute, selectedThreadId, threads }) {
1539
+ function renderThreadDropdown({ inputId, dataAttribute, selectedThreadId, threads, controlsHtml = "" }) {
1479
1540
  const options = [
1480
1541
  {
1481
1542
  id: "all",
@@ -1490,24 +1551,72 @@ function renderThreadDropdown({ inputId, dataAttribute, selectedThreadId, thread
1490
1551
  return `
1491
1552
  <div class="timeline-thread-filter">
1492
1553
  <label class="timeline-thread-filter__label" for="${escapeHtml(inputId)}">${escapeHtml(L("timeline.filterLabel"))}</label>
1493
- <div class="timeline-thread-select-wrap">
1494
- <select id="${escapeHtml(inputId)}" class="timeline-thread-select" ${dataAttribute}>
1495
- ${options
1496
- .map(
1497
- (thread) => `
1498
- <option value="${escapeHtml(thread.id)}" ${selectedThreadId === thread.id ? "selected" : ""}>
1499
- ${escapeHtml(thread.label)}
1500
- </option>
1501
- `
1502
- )
1503
- .join("")}
1504
- </select>
1505
- <span class="timeline-thread-select__chevron" aria-hidden="true">${renderIcon("chevron-down")}</span>
1554
+ <div class="timeline-thread-filter__row">
1555
+ <div class="timeline-thread-select-wrap">
1556
+ <select id="${escapeHtml(inputId)}" class="timeline-thread-select" ${dataAttribute}>
1557
+ ${options
1558
+ .map(
1559
+ (thread) => `
1560
+ <option value="${escapeHtml(thread.id)}" ${selectedThreadId === thread.id ? "selected" : ""}>
1561
+ ${escapeHtml(thread.label)}
1562
+ </option>
1563
+ `
1564
+ )
1565
+ .join("")}
1566
+ </select>
1567
+ <span class="timeline-thread-select__chevron" aria-hidden="true">${renderIcon("chevron-down")}</span>
1568
+ </div>
1569
+ ${controlsHtml}
1506
1570
  </div>
1507
1571
  </div>
1508
1572
  `;
1509
1573
  }
1510
1574
 
1575
+ function renderTimelineKindFilterControls() {
1576
+ const current = currentTimelineKindFilterOption();
1577
+ const options = timelineKindFilterOptions();
1578
+ return `
1579
+ <div class="timeline-kind-filter" data-timeline-kind-filter-root>
1580
+ <button
1581
+ type="button"
1582
+ class="timeline-kind-filter__button ${current.id !== "all" ? "is-active" : ""}"
1583
+ data-timeline-kind-filter-toggle
1584
+ aria-expanded="${state.timelineKindFilterOpen ? "true" : "false"}"
1585
+ aria-label="${escapeHtml(L("timeline.kindFilterButtonLabel"))}"
1586
+ >
1587
+ <span class="timeline-kind-filter__button-icon" aria-hidden="true">${renderIcon(current.icon)}</span>
1588
+ </button>
1589
+ ${
1590
+ state.timelineKindFilterOpen
1591
+ ? `
1592
+ <div class="timeline-kind-filter__popover" role="menu" aria-label="${escapeHtml(L("timeline.kindFilterLabel"))}">
1593
+ ${options
1594
+ .map(
1595
+ (option) => `
1596
+ <button
1597
+ type="button"
1598
+ class="timeline-kind-filter__option ${option.id === current.id ? "is-selected" : ""}"
1599
+ data-timeline-kind-filter-option="${escapeHtml(option.id)}"
1600
+ role="menuitemradio"
1601
+ aria-checked="${option.id === current.id ? "true" : "false"}"
1602
+ >
1603
+ <span class="timeline-kind-filter__option-icon" aria-hidden="true">${renderIcon(option.icon)}</span>
1604
+ <span class="timeline-kind-filter__option-label">${escapeHtml(option.label)}</span>
1605
+ <span class="timeline-kind-filter__option-check" aria-hidden="true">${
1606
+ option.id === current.id ? renderIcon("check") : ""
1607
+ }</span>
1608
+ </button>
1609
+ `
1610
+ )
1611
+ .join("")}
1612
+ </div>
1613
+ `
1614
+ : ""
1615
+ }
1616
+ </div>
1617
+ `;
1618
+ }
1619
+
1511
1620
  function renderTimelineEntry(entry, { desktop }) {
1512
1621
  const item = entry.item;
1513
1622
  const kindInfo = kindMeta(item.kind);
@@ -1515,13 +1624,14 @@ function renderTimelineEntry(entry, { desktop }) {
1515
1624
  const kindNameClass = escapeHtml(String(item.kind || "item").replace(/_/gu, "-"));
1516
1625
  const isMessageLike = TIMELINE_MESSAGE_KINDS.has(item.kind) || item.kind === "completion";
1517
1626
  const imageUrls = Array.isArray(item.imageUrls) ? item.imageUrls.filter(Boolean) : [];
1627
+ const fileRefs = normalizeClientFileRefs(item.fileRefs);
1518
1628
  const primaryText = isMessageLike
1519
1629
  ? item.summary || fallbackSummaryForKind(item.kind, entry.status)
1520
1630
  : item.title || L("common.untitledItem");
1521
1631
  const secondaryText = isMessageLike ? "" : item.summary || fallbackSummaryForKind(item.kind, entry.status);
1522
1632
  const threadLabel = timelineEntryThreadLabel(item, isMessageLike);
1523
1633
  const timestampLabel = formatTimelineTimestamp(item.createdAtMs);
1524
- const statusLabel = isMessageLike ? "" : L("common.actionNeeded");
1634
+ const statusLabel = timelineEntryStatusLabel(item, isMessageLike);
1525
1635
 
1526
1636
  return `
1527
1637
  <button
@@ -1545,12 +1655,42 @@ function renderTimelineEntry(entry, { desktop }) {
1545
1655
  <p class="timeline-entry__title">${escapeHtml(primaryText)}</p>
1546
1656
  ${secondaryText ? `<p class="timeline-entry__summary">${escapeHtml(secondaryText)}</p>` : ""}
1547
1657
  ${renderTimelineEntryImageStrip(imageUrls)}
1658
+ ${renderTimelineEntryFileStrip(fileRefs)}
1548
1659
  </div>
1549
1660
  ${statusLabel ? `<div class="timeline-entry__footer"><span class="timeline-entry__status">${escapeHtml(statusLabel)}</span></div>` : ""}
1550
1661
  </button>
1551
1662
  `;
1552
1663
  }
1553
1664
 
1665
+ function timelineEntryStatusLabel(item, isMessageLike) {
1666
+ if (isMessageLike) {
1667
+ return "";
1668
+ }
1669
+
1670
+ const outcome = normalizeClientText(item?.outcome || "");
1671
+ switch (outcome) {
1672
+ case "pending":
1673
+ return L("common.actionNeeded");
1674
+ case "approved":
1675
+ return L("timeline.status.approved");
1676
+ case "rejected":
1677
+ return L("timeline.status.rejected");
1678
+ case "implemented":
1679
+ return L("timeline.status.implemented");
1680
+ case "dismissed":
1681
+ return L("timeline.status.dismissed");
1682
+ case "submitted":
1683
+ return L("timeline.status.submitted");
1684
+ default:
1685
+ break;
1686
+ }
1687
+
1688
+ if (item?.kind === "approval" || item?.kind === "plan" || item?.kind === "plan_ready" || item?.kind === "choice") {
1689
+ return L("common.actionNeeded");
1690
+ }
1691
+ return "";
1692
+ }
1693
+
1554
1694
  function renderTimelineEntryImageStrip(imageUrls) {
1555
1695
  if (!Array.isArray(imageUrls) || imageUrls.length === 0) {
1556
1696
  return "";
@@ -1577,6 +1717,28 @@ function renderTimelineEntryImageStrip(imageUrls) {
1577
1717
  `;
1578
1718
  }
1579
1719
 
1720
+ function renderTimelineEntryFileStrip(fileRefs) {
1721
+ if (!Array.isArray(fileRefs) || fileRefs.length === 0) {
1722
+ return "";
1723
+ }
1724
+
1725
+ return `
1726
+ <div class="timeline-entry__files" aria-label="${escapeHtml(L("detail.filesTitle"))}">
1727
+ ${fileRefs
1728
+ .slice(0, 4)
1729
+ .map(
1730
+ (fileRef) => `
1731
+ <span class="file-ref-chip" title="${escapeHtml(fileRef)}">
1732
+ <span class="file-ref-chip__icon" aria-hidden="true">${renderIcon("item")}</span>
1733
+ <span class="file-ref-chip__label">${escapeHtml(fileRefLabel(fileRef))}</span>
1734
+ </span>
1735
+ `
1736
+ )
1737
+ .join("")}
1738
+ </div>
1739
+ `;
1740
+ }
1741
+
1580
1742
  function timelineEntryThreadLabel(item, isMessage) {
1581
1743
  const threadLabel = resolvedThreadLabel(item.threadId || "", item.threadLabel || "");
1582
1744
  if (!threadLabel) {
@@ -2307,6 +2469,7 @@ function renderStandardDetailDesktop(detail) {
2307
2469
  <div class="detail-body ${spaciousBodyDetail ? "detail-body--message " : ""}markdown">${detail.messageHtml || ""}</div>
2308
2470
  </section>
2309
2471
  ${renderDetailImageGallery(detail)}
2472
+ ${renderDetailFileRefs(detail)}
2310
2473
  ${renderCompletionReplyComposer(detail)}
2311
2474
  ${detail.readOnly ? "" : renderActionButtons(detail.actions || [])}
2312
2475
  </div>
@@ -2328,6 +2491,7 @@ function renderStandardDetailMobile(detail) {
2328
2491
  <div class="detail-body ${spaciousBodyDetail ? "detail-body--message " : ""}markdown">${detail.messageHtml || ""}</div>
2329
2492
  </section>
2330
2493
  ${renderDetailImageGallery(detail, { mobile: true })}
2494
+ ${renderDetailFileRefs(detail, { mobile: true })}
2331
2495
  ${renderCompletionReplyComposer(detail, { mobile: true })}
2332
2496
  </div>
2333
2497
  ${detail.readOnly ? "" : renderActionButtons(detail.actions || [], { mobileSticky: true })}
@@ -2435,6 +2599,34 @@ function renderDetailImageGallery(detail, options = {}) {
2435
2599
  `;
2436
2600
  }
2437
2601
 
2602
+ function renderDetailFileRefs(detail, options = {}) {
2603
+ const fileRefs = normalizeClientFileRefs(detail?.fileRefs);
2604
+ if (fileRefs.length === 0) {
2605
+ return "";
2606
+ }
2607
+
2608
+ return `
2609
+ <section class="detail-card detail-card--files ${options.mobile ? "detail-card--mobile" : ""}">
2610
+ <div class="detail-files-card__header">
2611
+ <span class="detail-files-card__icon" aria-hidden="true">${renderIcon("item")}</span>
2612
+ <span>${escapeHtml(L("detail.filesTitle"))}</span>
2613
+ </div>
2614
+ <div class="detail-file-grid">
2615
+ ${fileRefs
2616
+ .map(
2617
+ (fileRef) => `
2618
+ <div class="detail-file-chip" title="${escapeHtml(fileRef)}">
2619
+ <span class="detail-file-chip__label">${escapeHtml(fileRefLabel(fileRef))}</span>
2620
+ <span class="detail-file-chip__path">${escapeHtml(fileRef)}</span>
2621
+ </div>
2622
+ `
2623
+ )
2624
+ .join("")}
2625
+ </div>
2626
+ </section>
2627
+ `;
2628
+ }
2629
+
2438
2630
  function renderCompletionReplyComposer(detail, options = {}) {
2439
2631
  if (detail.kind !== "completion" || detail.reply?.enabled !== true) {
2440
2632
  return "";
@@ -3060,6 +3252,27 @@ function bindShellInteractions() {
3060
3252
  select.addEventListener("change", async () => {
3061
3253
  clearThreadFilterInteraction();
3062
3254
  state.timelineThreadFilter = select.value || "all";
3255
+ state.timelineKindFilterOpen = false;
3256
+ alignCurrentItemToVisibleEntries();
3257
+ await renderShell();
3258
+ });
3259
+ }
3260
+
3261
+ for (const button of document.querySelectorAll("[data-timeline-kind-filter-toggle]")) {
3262
+ button.addEventListener("click", async (event) => {
3263
+ event.preventDefault();
3264
+ markThreadFilterInteraction();
3265
+ state.timelineKindFilterOpen = !state.timelineKindFilterOpen;
3266
+ await renderShell();
3267
+ });
3268
+ }
3269
+
3270
+ for (const button of document.querySelectorAll("[data-timeline-kind-filter-option]")) {
3271
+ button.addEventListener("click", async (event) => {
3272
+ event.preventDefault();
3273
+ clearThreadFilterInteraction();
3274
+ state.timelineKindFilter = button.dataset.timelineKindFilterOption || "all";
3275
+ state.timelineKindFilterOpen = false;
3063
3276
  alignCurrentItemToVisibleEntries();
3064
3277
  await renderShell();
3065
3278
  });
@@ -3550,6 +3763,7 @@ function closeSettingsSubpage() {
3550
3763
 
3551
3764
  async function switchTab(tab) {
3552
3765
  state.currentTab = tab;
3766
+ state.timelineKindFilterOpen = false;
3553
3767
  state.pushNotice = "";
3554
3768
  state.pushError = "";
3555
3769
  state.settingsSubpage = "";
@@ -3570,6 +3784,7 @@ function openItem({ kind, token, sourceTab }) {
3570
3784
  const previousItem = state.currentItem ? { ...state.currentItem } : null;
3571
3785
  clearPinnedDetailState();
3572
3786
  const nextTab = sourceTab || tabForItemKind(kind, state.currentTab);
3787
+ state.timelineKindFilterOpen = false;
3573
3788
  if (previousItem && (previousItem.kind !== kind || previousItem.token !== token)) {
3574
3789
  clearChoiceLocalDraftForItem(previousItem);
3575
3790
  }
@@ -4044,6 +4259,8 @@ function renderIcon(name) {
4044
4259
  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
4260
  case "clip":
4046
4261
  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>`;
4262
+ case "filter":
4263
+ return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M5 7h14"/><path d="M8 12h8"/><path d="M10.5 17h3"/></svg>`;
4047
4264
  case "check":
4048
4265
  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>`;
4049
4266
  case "back":
@@ -4151,9 +4368,31 @@ function handleServiceWorkerMessage(event) {
4151
4368
  const type = event?.data?.type || "";
4152
4369
  if (type === "pushsubscriptionchange") {
4153
4370
  refreshPushStatus().then(renderCurrentSurface).catch(() => {});
4371
+ return;
4372
+ }
4373
+ if (type === "open-target-url" && event?.data?.url) {
4374
+ applyExternalTargetUrl(event.data.url).catch(() => {});
4154
4375
  }
4155
4376
  }
4156
4377
 
4378
+ function handlePotentialExternalNavigation() {
4379
+ consumePendingNotificationIntent()
4380
+ .then((consumed) => {
4381
+ if (consumed) {
4382
+ return;
4383
+ }
4384
+ return applyExternalTargetUrl(window.location.href, { allowRefresh: false });
4385
+ })
4386
+ .catch(() => {});
4387
+ }
4388
+
4389
+ function handleDocumentVisibilityChange() {
4390
+ if (document.visibilityState !== "visible") {
4391
+ return;
4392
+ }
4393
+ handlePotentialExternalNavigation();
4394
+ }
4395
+
4157
4396
  async function apiGet(url) {
4158
4397
  const response = await fetch(url, {
4159
4398
  credentials: "same-origin",
@@ -4250,11 +4489,124 @@ function normalizeClientText(value) {
4250
4489
  return String(value ?? "").trim();
4251
4490
  }
4252
4491
 
4492
+ function normalizeClientFileRefs(fileRefs) {
4493
+ if (!Array.isArray(fileRefs)) {
4494
+ return [];
4495
+ }
4496
+ const deduped = [];
4497
+ const seen = new Set();
4498
+ for (const fileRef of fileRefs) {
4499
+ const normalized = normalizeClientText(fileRef);
4500
+ if (!normalized || seen.has(normalized)) {
4501
+ continue;
4502
+ }
4503
+ seen.add(normalized);
4504
+ deduped.push(normalized);
4505
+ if (deduped.length >= 8) {
4506
+ break;
4507
+ }
4508
+ }
4509
+ return deduped;
4510
+ }
4511
+
4512
+ function fileRefLabel(fileRef) {
4513
+ const normalized = normalizeClientText(fileRef);
4514
+ if (!normalized) {
4515
+ return "";
4516
+ }
4517
+ const segments = normalized.split("/").filter(Boolean);
4518
+ return segments[segments.length - 1] || normalized;
4519
+ }
4520
+
4253
4521
  function parseItemRef(value) {
4254
4522
  const [kind, token] = String(value || "").split(":");
4255
4523
  return kind && token ? { kind, token } : null;
4256
4524
  }
4257
4525
 
4526
+ async function applyExternalTargetUrl(urlString, { allowRefresh = true } = {}) {
4527
+ if (!state.session?.authenticated) {
4528
+ return;
4529
+ }
4530
+
4531
+ let nextUrl;
4532
+ try {
4533
+ nextUrl = new URL(urlString, window.location.origin);
4534
+ } catch {
4535
+ return;
4536
+ }
4537
+
4538
+ const itemRef = parseItemRef(nextUrl.searchParams.get("item"));
4539
+ if (!itemRef) {
4540
+ return;
4541
+ }
4542
+
4543
+ const sameItem =
4544
+ Boolean(state.currentItem) &&
4545
+ isSameItemRef(state.currentItem, itemRef) &&
4546
+ (isDesktopLayout() || state.detailOpen);
4547
+ if (sameItem) {
4548
+ if (allowRefresh) {
4549
+ await refreshAuthenticatedState();
4550
+ ensureCurrentSelection();
4551
+ await renderShell();
4552
+ }
4553
+ return;
4554
+ }
4555
+
4556
+ openItem({
4557
+ kind: itemRef.kind,
4558
+ token: itemRef.token,
4559
+ sourceTab: tabForItemKind(itemRef.kind, state.currentTab),
4560
+ });
4561
+ if (isFastPathItemRef(itemRef)) {
4562
+ state.launchItemIntent = {
4563
+ ...itemRef,
4564
+ status: "pending",
4565
+ };
4566
+ }
4567
+ await renderShell();
4568
+
4569
+ if (!allowRefresh) {
4570
+ return;
4571
+ }
4572
+ await refreshAuthenticatedState();
4573
+ ensureCurrentSelection();
4574
+ await renderShell();
4575
+ }
4576
+
4577
+ async function consumePendingNotificationIntent() {
4578
+ if (!state.session?.authenticated || typeof caches === "undefined") {
4579
+ return false;
4580
+ }
4581
+ let cache;
4582
+ try {
4583
+ cache = await caches.open(NOTIFICATION_INTENT_CACHE);
4584
+ } catch {
4585
+ return false;
4586
+ }
4587
+
4588
+ const request = new Request(NOTIFICATION_INTENT_PATH);
4589
+ const match = await cache.match(request).catch(() => null);
4590
+ if (!match) {
4591
+ return false;
4592
+ }
4593
+
4594
+ let payload = null;
4595
+ try {
4596
+ payload = await match.json();
4597
+ } catch {
4598
+ payload = null;
4599
+ }
4600
+ await cache.delete(request).catch(() => {});
4601
+
4602
+ const url = normalizeClientText(payload?.url || "");
4603
+ if (!url) {
4604
+ return false;
4605
+ }
4606
+ await applyExternalTargetUrl(url, { allowRefresh: true });
4607
+ return true;
4608
+ }
4609
+
4258
4610
  function buildAppUrl(nextParams) {
4259
4611
  const query = nextParams.toString();
4260
4612
  return `/app${query ? `?${query}` : ""}`;
package/web/i18n.js CHANGED
@@ -108,6 +108,7 @@ const translations = {
108
108
  "detail.previousMessage": "Previous message",
109
109
  "detail.interruptedTask": "Interrupted task",
110
110
  "detail.imageAlt": ({ index }) => `Attached image ${index}`,
111
+ "detail.filesTitle": "Files",
111
112
  "detail.turnAbortedNotice": "This task was interrupted.",
112
113
  "detail.macOnlyChoice": "Please answer this type of input on the Mac.",
113
114
  "detail.detailUnavailable": "Detail not available.",
@@ -167,6 +168,19 @@ const translations = {
167
168
  "timeline.allThreads": "All",
168
169
  "timeline.unknownThread": "Unknown thread",
169
170
  "timeline.filterLabel": "Thread filter",
171
+ "timeline.status.approved": "Approved",
172
+ "timeline.status.rejected": "Rejected",
173
+ "timeline.status.implemented": "Started",
174
+ "timeline.status.dismissed": "Dismissed",
175
+ "timeline.status.submitted": "Sent",
176
+ "timeline.kindFilterLabel": "Event filter",
177
+ "timeline.kindFilterButtonLabel": "Filter timeline events",
178
+ "timeline.kindFilter.all": "All events",
179
+ "timeline.kindFilter.messages": "Messages",
180
+ "timeline.kindFilter.approvals": "Approvals",
181
+ "timeline.kindFilter.plans": "Plans",
182
+ "timeline.kindFilter.choices": "Choices",
183
+ "timeline.kindFilter.completions": "Completed",
170
184
  "settings.intro": "Check pairing, language, install status, and Web Push health for this iPhone.",
171
185
  "settings.section.overview": "Quick setup",
172
186
  "settings.section.notifications": "Notifications",
@@ -565,6 +579,7 @@ const translations = {
565
579
  "detail.previousMessage": "ひとつ前のメッセージ",
566
580
  "detail.interruptedTask": "中断されたタスク",
567
581
  "detail.imageAlt": ({ index }) => `添付画像 ${index}`,
582
+ "detail.filesTitle": "関連ファイル",
568
583
  "detail.turnAbortedNotice": "タスクを中断しました。",
569
584
  "detail.macOnlyChoice": "この種類の入力は Mac で回答してください。",
570
585
  "detail.detailUnavailable": "詳細は利用できません。",
@@ -624,6 +639,19 @@ const translations = {
624
639
  "timeline.allThreads": "すべて",
625
640
  "timeline.unknownThread": "不明なスレッド",
626
641
  "timeline.filterLabel": "スレッドフィルター",
642
+ "timeline.status.approved": "承認済み",
643
+ "timeline.status.rejected": "拒否済み",
644
+ "timeline.status.implemented": "実装開始",
645
+ "timeline.status.dismissed": "見送り",
646
+ "timeline.status.submitted": "送信済み",
647
+ "timeline.kindFilterLabel": "イベントフィルター",
648
+ "timeline.kindFilterButtonLabel": "タイムラインのイベントを絞り込む",
649
+ "timeline.kindFilter.all": "すべてのイベント",
650
+ "timeline.kindFilter.messages": "メッセージ",
651
+ "timeline.kindFilter.approvals": "承認",
652
+ "timeline.kindFilter.plans": "プラン",
653
+ "timeline.kindFilter.choices": "選択",
654
+ "timeline.kindFilter.completions": "完了",
627
655
  "settings.intro": "この iPhone のペアリング、言語、インストール状況、Web Push の状態を確認できます。",
628
656
  "settings.section.overview": "クイック確認",
629
657
  "settings.section.notifications": "通知",
package/web/sw.js CHANGED
@@ -1,4 +1,6 @@
1
- const CACHE_NAME = "viveworker-v6";
1
+ const CACHE_NAME = "viveworker-v7";
2
+ const NOTIFICATION_INTENT_CACHE = "viveworker-notification-intent-v1";
3
+ const NOTIFICATION_INTENT_PATH = "/__viveworker_notification_intent__";
2
4
  const APP_ASSETS = ["/app.css", "/app.js", "/i18n.js"];
3
5
  const APP_ROUTES = new Set(["/", "/app", "/app/"]);
4
6
  const CACHED_PATHS = new Set(APP_ASSETS);
@@ -74,6 +76,7 @@ self.addEventListener("push", (event) => {
74
76
  });
75
77
 
76
78
  self.addEventListener("notificationclick", (event) => {
79
+ event.preventDefault?.();
77
80
  event.notification.close();
78
81
  const targetUrl = event.notification?.data?.url || "/app";
79
82
  event.waitUntil(openTargetWindow(targetUrl));
@@ -85,20 +88,26 @@ self.addEventListener("pushsubscriptionchange", (event) => {
85
88
 
86
89
  async function openTargetWindow(targetUrl) {
87
90
  const target = new URL(targetUrl, self.location.origin);
91
+ await persistNotificationIntent(target.toString());
88
92
  const clients = await self.clients.matchAll({
89
93
  type: "window",
90
94
  includeUncontrolled: true,
91
95
  });
96
+ broadcastTargetUrl(target.toString(), clients);
92
97
 
93
98
  const preferredClients = clients
94
99
  .slice()
95
- .sort((left, right) => scoreClient(right.url) - scoreClient(left.url));
100
+ .sort((left, right) => scoreClient(right) - scoreClient(left));
96
101
 
97
102
  for (const client of preferredClients) {
98
103
  if (typeof client.focus === "function") {
99
104
  if (typeof client.navigate === "function") {
100
- await client.navigate(target.toString());
105
+ await client.navigate(target.toString()).catch(() => {});
101
106
  }
107
+ client.postMessage({
108
+ type: "open-target-url",
109
+ url: target.toString(),
110
+ });
102
111
  await client.focus();
103
112
  return;
104
113
  }
@@ -109,9 +118,9 @@ async function openTargetWindow(targetUrl) {
109
118
  }
110
119
  }
111
120
 
112
- function scoreClient(urlString) {
121
+ function scoreClient(client) {
113
122
  try {
114
- const url = new URL(urlString);
123
+ const url = new URL(client?.url || "");
115
124
  let score = 0;
116
125
  if (APP_ROUTES.has(url.pathname)) {
117
126
  score += 20;
@@ -119,12 +128,50 @@ function scoreClient(urlString) {
119
128
  if (url.pathname === "/app" || url.pathname === "/app/") {
120
129
  score += 5;
121
130
  }
131
+ if (client?.focused) {
132
+ score += 4;
133
+ }
134
+ if (client?.visibilityState === "visible") {
135
+ score += 2;
136
+ }
122
137
  return score;
123
138
  } catch {
124
139
  return 0;
125
140
  }
126
141
  }
127
142
 
143
+ function broadcastTargetUrl(url, clients) {
144
+ for (const client of clients) {
145
+ client.postMessage({
146
+ type: "open-target-url",
147
+ url,
148
+ });
149
+ }
150
+ }
151
+
152
+ async function persistNotificationIntent(url) {
153
+ try {
154
+ const cache = await caches.open(NOTIFICATION_INTENT_CACHE);
155
+ const request = new Request(NOTIFICATION_INTENT_PATH);
156
+ const response = new Response(
157
+ JSON.stringify({
158
+ url,
159
+ nonce: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
160
+ createdAtMs: Date.now(),
161
+ }),
162
+ {
163
+ headers: {
164
+ "Content-Type": "application/json",
165
+ "Cache-Control": "no-store",
166
+ },
167
+ }
168
+ );
169
+ await cache.put(request, response);
170
+ } catch {
171
+ // Best-effort fallback for iOS warm-start notification routing.
172
+ }
173
+ }
174
+
128
175
  async function notifyClients(type) {
129
176
  const clients = await self.clients.matchAll({
130
177
  type: "window",