viveworker 0.5.3 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viveworker",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "description": "Local mobile companion for Codex Desktop and Claude Desktop — approvals, code review, Moltbook drafts, and A2A (Agent-to-Agent) task relay on your LAN.",
5
5
  "author": "Yuta Hoshino <hoshino.lireneo@gmail.com>",
6
6
  "license": "MIT",
@@ -29,7 +29,7 @@ const sessionCookieName = "viveworker_session";
29
29
  const deviceCookieName = "viveworker_device";
30
30
  const historyKinds = new Set(["completion", "assistant_final", "plan_ready", "approval", "plan", "choice", "info", "moltbook_reply", "moltbook_draft", "thread_share", "a2a_task", "a2a_task_result"]);
31
31
  const timelineMessageKinds = new Set(["user_message", "assistant_commentary", "assistant_final"]);
32
- const timelineKinds = new Set([...timelineMessageKinds, "approval", "plan", "choice", "plan_ready", "file_event", "moltbook_reply", "moltbook_draft", "thread_share", "a2a_task", "a2a_task_result"]);
32
+ const timelineKinds = new Set([...timelineMessageKinds, "ambient_suggestions", "approval", "plan", "choice", "plan_ready", "file_event", "moltbook_reply", "moltbook_draft", "thread_share", "a2a_task", "a2a_task_result"]);
33
33
  const SQLITE_COMPLETION_BATCH_SIZE = 200;
34
34
  const DEFAULT_DEVICE_TRUST_TTL_MS = 30 * 24 * 60 * 60 * 1000;
35
35
  const MAX_PAIRED_DEVICES = 200;
@@ -161,6 +161,7 @@ runtime.recentHistoryItems = initialHistoryItems;
161
161
  runtime.recentTimelineEntries = initialTimelineEntries;
162
162
  state.recentHistoryItems = initialHistoryItems;
163
163
  state.recentTimelineEntries = initialTimelineEntries;
164
+ const backfilledAmbientSuggestionsStateChanged = backfillAmbientSuggestionsState({ config, runtime, state });
164
165
  const migratedRecentCodeEventsStateChanged = migrateRecentCodeEventsState({ config, runtime, state });
165
166
  const restoredTimelineImagePathsStateChanged = await backfillPersistedTimelineImagePaths({ config, runtime, state });
166
167
  const backfilledMoltbookInboxChanged = await backfillMoltbookInboxHistory({ config, runtime, state });
@@ -291,6 +292,8 @@ function kindTitle(locale, kind) {
291
292
  return t(locale, "server.title.assistantCommentary");
292
293
  case "assistant_final":
293
294
  return t(locale, "server.title.assistantFinal");
295
+ case "ambient_suggestions":
296
+ return t(locale, "server.title.ambientSuggestions");
294
297
  case "approval":
295
298
  return t(locale, "server.title.approval");
296
299
  case "plan":
@@ -319,6 +322,117 @@ function kindTitle(locale, kind) {
319
322
  }
320
323
  }
321
324
 
325
+ function normalizeAmbientSuggestion(raw) {
326
+ if (!isPlainObject(raw)) {
327
+ return null;
328
+ }
329
+
330
+ const title = cleanText(raw.title ?? "");
331
+ const prompt = normalizeLongText(raw.prompt ?? "");
332
+ if (!title || !prompt) {
333
+ return null;
334
+ }
335
+
336
+ const threadAction = isPlainObject(raw.threadAction)
337
+ ? {
338
+ ...(cleanText(raw.threadAction.type ?? "") ? { type: cleanText(raw.threadAction.type) } : {}),
339
+ }
340
+ : null;
341
+ const appIds = Array.isArray(raw.appIds)
342
+ ? raw.appIds.map((value) => cleanText(value ?? "")).filter(Boolean)
343
+ : [];
344
+
345
+ return {
346
+ ...(cleanText(raw.id ?? "") ? { id: cleanText(raw.id) } : {}),
347
+ title,
348
+ prompt,
349
+ ...(cleanText(raw.description ?? "") ? { description: cleanText(raw.description) } : {}),
350
+ ...(threadAction && threadAction.type ? { threadAction } : {}),
351
+ ...(appIds.length > 0 ? { appIds } : {}),
352
+ };
353
+ }
354
+
355
+ function normalizeAmbientSuggestions(raw) {
356
+ if (!Array.isArray(raw)) {
357
+ return [];
358
+ }
359
+ return raw.map((entry) => normalizeAmbientSuggestion(entry)).filter(Boolean);
360
+ }
361
+
362
+ function parseAmbientSuggestionsEnvelope(value) {
363
+ const normalized = cleanText(value ?? "");
364
+ if (!normalized) {
365
+ return null;
366
+ }
367
+
368
+ let payload;
369
+ try {
370
+ payload = JSON.parse(normalized);
371
+ } catch {
372
+ return null;
373
+ }
374
+
375
+ if (!isPlainObject(payload)) {
376
+ return null;
377
+ }
378
+
379
+ const hasSuggestionsField = Array.isArray(payload.suggestions);
380
+ const hasExcludeField = Array.isArray(payload.exclude);
381
+ if (!hasSuggestionsField && !hasExcludeField) {
382
+ return null;
383
+ }
384
+
385
+ const suggestions = hasSuggestionsField ? normalizeAmbientSuggestions(payload.suggestions) : [];
386
+ if (suggestions.length > 0) {
387
+ return { type: "suggestions", suggestions };
388
+ }
389
+
390
+ return {
391
+ type: "metadata",
392
+ hasSuggestionsField,
393
+ hasExcludeField,
394
+ };
395
+ }
396
+
397
+ function parseAmbientSuggestionsPayload(value) {
398
+ const envelope = parseAmbientSuggestionsEnvelope(value);
399
+ return envelope?.type === "suggestions" ? envelope.suggestions : null;
400
+ }
401
+
402
+ function isAmbientSuggestionsMetadataPayload(value) {
403
+ return parseAmbientSuggestionsEnvelope(value) !== null;
404
+ }
405
+
406
+ function ambientSuggestionsSummary(locale, suggestions) {
407
+ const count = Math.max(0, Array.isArray(suggestions) ? suggestions.length : 0);
408
+ if (count <= 0) {
409
+ return "";
410
+ }
411
+ const firstTitle = cleanText(suggestions[0]?.title || "");
412
+ return t(locale, "summary.ambientSuggestions", {
413
+ count,
414
+ firstTitle,
415
+ more: Math.max(0, count - 1),
416
+ });
417
+ }
418
+
419
+ function ambientSuggestionsMessageText(locale, suggestions) {
420
+ if (!Array.isArray(suggestions) || suggestions.length === 0) {
421
+ return "";
422
+ }
423
+ return [
424
+ kindTitle(locale, "ambient_suggestions"),
425
+ "",
426
+ ...suggestions.flatMap((suggestion, index) => {
427
+ const description = cleanText(suggestion.description || "");
428
+ return [
429
+ `${index + 1}. ${cleanText(suggestion.title || "")}`,
430
+ ...(description ? [description] : []),
431
+ ];
432
+ }),
433
+ ].join("\n");
434
+ }
435
+
322
436
  function looksLikeGeneratedThreadTitle(value) {
323
437
  const normalized = cleanText(value || "");
324
438
  if (!normalized.includes("|")) {
@@ -2250,6 +2364,8 @@ function timelineKindSortPriority(kind) {
2250
2364
  case "plan_ready":
2251
2365
  case "choice":
2252
2366
  return 60;
2367
+ case "ambient_suggestions":
2368
+ return 52;
2253
2369
  case "file_event":
2254
2370
  return 45;
2255
2371
  case "assistant_final":
@@ -2281,6 +2397,10 @@ function normalizeTimelineEntry(raw) {
2281
2397
  const diffText = normalizeTimelineDiffText(raw.diffText ?? "");
2282
2398
  const diffSource = normalizeTimelineDiffSource(raw.diffSource ?? "");
2283
2399
  const diffCounts = diffLineCounts(diffText);
2400
+ const suggestions = normalizeAmbientSuggestions(raw.suggestions ?? []);
2401
+ if (kind === "ambient_suggestions" && suggestions.length === 0) {
2402
+ return null;
2403
+ }
2284
2404
  const summary =
2285
2405
  normalizeNotificationText(raw.summary ?? "") ||
2286
2406
  formatNotificationBody(messageText, 180) ||
@@ -2351,6 +2471,7 @@ function normalizeTimelineEntry(raw) {
2351
2471
  // A2A task-specific fields
2352
2472
  ...(raw.instruction != null ? { instruction: cleanText(raw.instruction) } : {}),
2353
2473
  ...(raw.taskStatus != null ? { taskStatus: cleanText(raw.taskStatus) } : {}),
2474
+ ...(suggestions.length > 0 ? { suggestions } : {}),
2354
2475
  };
2355
2476
  }
2356
2477
 
@@ -2525,6 +2646,9 @@ function historyItemFromEvent(event) {
2525
2646
 
2526
2647
  const kind = event.kind === "task_complete" ? "completion" : "plan_ready";
2527
2648
  const messageText = normalizeLongText(event.detailText || event.message || "");
2649
+ if (kind === "completion" && isAmbientSuggestionsMetadataPayload(messageText)) {
2650
+ return null;
2651
+ }
2528
2652
  return normalizeHistoryItem({
2529
2653
  stableId: event.id,
2530
2654
  kind,
@@ -2558,6 +2682,74 @@ function recordHistoryItem({ config, runtime, state, item }) {
2558
2682
  return changed;
2559
2683
  }
2560
2684
 
2685
+ function backfillAmbientSuggestionsState({ config, runtime, state }) {
2686
+ const nextTimelineEntries = normalizeTimelineEntries(
2687
+ (runtime.recentTimelineEntries ?? []).map((entry) => {
2688
+ const entryKind = cleanText(entry?.kind || "");
2689
+ if (entryKind === "ambient_suggestions") {
2690
+ const suggestions = normalizeAmbientSuggestions(entry?.suggestions ?? []);
2691
+ if (suggestions.length === 0) {
2692
+ return null;
2693
+ }
2694
+ return {
2695
+ ...entry,
2696
+ title: kindTitle(DEFAULT_LOCALE, "ambient_suggestions"),
2697
+ summary: ambientSuggestionsSummary(DEFAULT_LOCALE, suggestions),
2698
+ messageText: ambientSuggestionsMessageText(DEFAULT_LOCALE, suggestions),
2699
+ suggestions,
2700
+ };
2701
+ }
2702
+ if (entryKind !== "assistant_final") {
2703
+ return entry;
2704
+ }
2705
+ const envelope = parseAmbientSuggestionsEnvelope(entry?.messageText ?? "");
2706
+ if (!envelope) {
2707
+ return entry;
2708
+ }
2709
+ if (envelope.type !== "suggestions") {
2710
+ return null;
2711
+ }
2712
+ const suggestions = envelope.suggestions;
2713
+ return {
2714
+ ...entry,
2715
+ kind: "ambient_suggestions",
2716
+ title: kindTitle(DEFAULT_LOCALE, "ambient_suggestions"),
2717
+ summary: ambientSuggestionsSummary(DEFAULT_LOCALE, suggestions),
2718
+ messageText: ambientSuggestionsMessageText(DEFAULT_LOCALE, suggestions),
2719
+ suggestions,
2720
+ };
2721
+ }),
2722
+ config.maxTimelineEntries
2723
+ );
2724
+
2725
+ const nextHistoryItems = normalizeHistoryItems(
2726
+ (runtime.recentHistoryItems ?? []).filter((item) => {
2727
+ const kind = cleanText(item?.kind || "");
2728
+ if (kind !== "assistant_final" && kind !== "completion") {
2729
+ return true;
2730
+ }
2731
+ return !isAmbientSuggestionsMetadataPayload(item?.messageText ?? "");
2732
+ }),
2733
+ config.maxHistoryItems
2734
+ );
2735
+
2736
+ const timelineChanged =
2737
+ JSON.stringify(nextTimelineEntries) !== JSON.stringify(runtime.recentTimelineEntries ?? []);
2738
+ const historyChanged =
2739
+ JSON.stringify(nextHistoryItems) !== JSON.stringify(runtime.recentHistoryItems ?? []);
2740
+
2741
+ if (timelineChanged) {
2742
+ runtime.recentTimelineEntries = nextTimelineEntries;
2743
+ state.recentTimelineEntries = nextTimelineEntries;
2744
+ }
2745
+ if (historyChanged) {
2746
+ runtime.recentHistoryItems = nextHistoryItems;
2747
+ state.recentHistoryItems = nextHistoryItems;
2748
+ }
2749
+
2750
+ return timelineChanged || historyChanged;
2751
+ }
2752
+
2561
2753
  // Flip a previously-recorded history item's readOnly flag without disturbing
2562
2754
  // its ordering or other fields. Used by moltbook_reply / moltbook_draft flows
2563
2755
  // to transition pending → resolved so the entry starts matching the completed
@@ -4140,6 +4332,20 @@ function buildSqliteTimelineEntry({ row, config, runtime }) {
4140
4332
  if (!messageText) {
4141
4333
  return null;
4142
4334
  }
4335
+ const ambientSuggestionsEnvelope = kind === "assistant_final" ? parseAmbientSuggestionsEnvelope(messageText) : null;
4336
+ if (ambientSuggestionsEnvelope?.type === "metadata") {
4337
+ return null;
4338
+ }
4339
+ const ambientSuggestions = ambientSuggestionsEnvelope?.type === "suggestions"
4340
+ ? ambientSuggestionsEnvelope.suggestions
4341
+ : null;
4342
+ const entryKind = ambientSuggestions ? "ambient_suggestions" : kind;
4343
+ const entryMessageText = ambientSuggestions
4344
+ ? ambientSuggestionsMessageText(config.defaultLocale, ambientSuggestions)
4345
+ : messageText;
4346
+ const entrySummary = ambientSuggestions
4347
+ ? ambientSuggestionsSummary(config.defaultLocale, ambientSuggestions)
4348
+ : formatNotificationBody(messageText, 180) || messageText;
4143
4349
 
4144
4350
  const createdAtMs = Math.max(0, Number(row.ts) || 0) * 1000 || Date.now();
4145
4351
  const threadLabel = getNativeThreadLabel({
@@ -4147,17 +4353,18 @@ function buildSqliteTimelineEntry({ row, config, runtime }) {
4147
4353
  conversationId: threadId,
4148
4354
  cwd: "",
4149
4355
  });
4150
- const stableId = messageTimelineStableId(kind, threadId, item.id || row.id, messageText, createdAtMs);
4356
+ const stableId = messageTimelineStableId(entryKind, threadId, item.id || row.id, entryMessageText, createdAtMs);
4151
4357
 
4152
4358
  return normalizeTimelineEntry({
4153
4359
  stableId,
4154
4360
  token: historyToken(stableId),
4155
- kind,
4361
+ kind: entryKind,
4156
4362
  threadId,
4157
4363
  threadLabel,
4158
- title: threadLabel || kindTitle(config.defaultLocale, kind),
4159
- summary: formatNotificationBody(messageText, 180) || messageText,
4160
- messageText,
4364
+ title: ambientSuggestions ? kindTitle(config.defaultLocale, entryKind) : threadLabel || kindTitle(config.defaultLocale, entryKind),
4365
+ summary: entrySummary,
4366
+ messageText: entryMessageText,
4367
+ ...(ambientSuggestions ? { suggestions: ambientSuggestions } : {}),
4161
4368
  createdAtMs,
4162
4369
  readOnly: true,
4163
4370
  });
@@ -4609,7 +4816,11 @@ async function processScannedEvent({ config, runtime, state, event }) {
4609
4816
  item: historyItemFromEvent(event),
4610
4817
  }) || dirty;
4611
4818
 
4612
- if (event.kind === "task_complete") {
4819
+ const isAmbientSuggestionsCompletion =
4820
+ event.kind === "task_complete" &&
4821
+ isAmbientSuggestionsMetadataPayload(normalizeLongText(event.detailText || event.message || ""));
4822
+
4823
+ if (event.kind === "task_complete" && !isAmbientSuggestionsCompletion) {
4613
4824
  dirty =
4614
4825
  (await deliverWebPushItem({
4615
4826
  config,
@@ -4626,7 +4837,7 @@ async function processScannedEvent({ config, runtime, state, event }) {
4626
4837
  })) || dirty;
4627
4838
  }
4628
4839
 
4629
- if (config.enableNtfy) {
4840
+ if (config.enableNtfy && !isAmbientSuggestionsCompletion) {
4630
4841
  try {
4631
4842
  await publishNtfy(config, event);
4632
4843
  } catch (error) {
@@ -9351,7 +9562,18 @@ function buildCompletedInboxItems(runtime, state, config, locale) {
9351
9562
  const items = normalizeHistoryItems(state.recentHistoryItems ?? runtime.recentHistoryItems, config.maxHistoryItems);
9352
9563
  runtime.recentHistoryItems = items;
9353
9564
  const completedKinds = new Set(["assistant_final", "approval", "moltbook_reply", "moltbook_draft", "thread_share", "a2a_task_result"]);
9354
- return items
9565
+
9566
+ // Infrequent kinds (A2A, thread_share) get evicted from the fixed-size
9567
+ // history FIFO by the high volume of approval/assistant_final entries.
9568
+ // Fall back to recentTimelineEntries so completed A2A tasks are never lost
9569
+ // while they're still within the timeline window.
9570
+ const infrequentKinds = new Set(["a2a_task_result", "thread_share"]);
9571
+ const historyStableIds = new Set(items.map((i) => i.stableId).filter(Boolean));
9572
+ const timelineEntries = (runtime.recentTimelineEntries ?? [])
9573
+ .filter((e) => infrequentKinds.has(cleanText(e?.kind || "")) && !historyStableIds.has(e.stableId));
9574
+ const merged = [...items, ...timelineEntries];
9575
+
9576
+ return merged
9355
9577
  .filter((item) => {
9356
9578
  const k = cleanText(item?.kind || "");
9357
9579
  if (!completedKinds.has(k)) return false;
@@ -9631,22 +9853,44 @@ function buildOperationalTimelineEntries(runtime, state, config, locale) {
9631
9853
  }
9632
9854
 
9633
9855
  for (const historyItem of normalizeHistoryItems(state.recentHistoryItems ?? runtime.recentHistoryItems, config.maxHistoryItems)) {
9634
- if (!timelineKinds.has(historyItem.kind)) {
9856
+ let historyKind = historyItem.kind;
9857
+ let historySummary = historyItem.summary;
9858
+ let historyMessageText = historyItem.messageText;
9859
+ let historyTitle = historyItem.threadLabel ? formatTitle(kindTitle(locale, historyItem.kind), historyItem.threadLabel) : historyItem.title;
9860
+ let historySuggestions = [];
9861
+
9862
+ if (historyItem.kind === "assistant_final" || historyItem.kind === "completion") {
9863
+ const ambientSuggestionsEnvelope = parseAmbientSuggestionsEnvelope(historyItem.messageText ?? "");
9864
+ if (ambientSuggestionsEnvelope?.type === "metadata") {
9865
+ continue;
9866
+ }
9867
+ if (ambientSuggestionsEnvelope?.type === "suggestions") {
9868
+ const ambientSuggestions = ambientSuggestionsEnvelope.suggestions;
9869
+ historyKind = "ambient_suggestions";
9870
+ historyTitle = kindTitle(locale, historyKind);
9871
+ historySummary = ambientSuggestionsSummary(locale, ambientSuggestions);
9872
+ historyMessageText = ambientSuggestionsMessageText(locale, ambientSuggestions);
9873
+ historySuggestions = ambientSuggestions;
9874
+ }
9875
+ }
9876
+
9877
+ if (!timelineKinds.has(historyKind)) {
9635
9878
  continue;
9636
9879
  }
9637
9880
  items.push(
9638
9881
  normalizeTimelineEntry({
9639
9882
  stableId: historyItem.stableId,
9640
9883
  token: historyItem.token,
9641
- kind: historyItem.kind,
9884
+ kind: historyKind,
9642
9885
  threadId: cleanText(extractConversationIdFromStableId(historyItem.stableId) || ""),
9643
9886
  threadLabel: historyItem.threadLabel,
9644
- title: historyItem.threadLabel ? formatTitle(kindTitle(locale, historyItem.kind), historyItem.threadLabel) : historyItem.title,
9645
- summary: historyItem.summary,
9646
- messageText: historyItem.messageText,
9887
+ title: historyTitle,
9888
+ summary: historySummary,
9889
+ messageText: historyMessageText,
9647
9890
  outcome: historyItem.outcome,
9648
9891
  createdAtMs: historyItem.createdAtMs,
9649
9892
  provider: normalizeProvider(historyItem.provider),
9893
+ ...(historySuggestions.length > 0 && historyKind === "ambient_suggestions" ? { suggestions: historySuggestions } : {}),
9650
9894
  })
9651
9895
  );
9652
9896
  }
@@ -10070,6 +10314,25 @@ function buildTimelineMessageDetail(entry, locale, runtime = null) {
10070
10314
  };
10071
10315
  }
10072
10316
 
10317
+ function buildAmbientSuggestionsDetail(entry, locale) {
10318
+ const suggestions = normalizeAmbientSuggestions(entry?.suggestions ?? []);
10319
+ return {
10320
+ kind: "ambient_suggestions",
10321
+ token: cleanText(entry?.token || ""),
10322
+ threadId: cleanText(entry?.threadId || ""),
10323
+ title: cleanText(entry?.title || "") || kindTitle(locale, "ambient_suggestions"),
10324
+ threadLabel: cleanText(entry?.threadLabel || ""),
10325
+ createdAtMs: Number(entry?.createdAtMs) || 0,
10326
+ messageHtml: renderMessageHtml(
10327
+ t(locale, "detail.ambientSuggestions.copy"),
10328
+ `<p>${escapeHtml(t(locale, "detail.detailUnavailable"))}</p>`
10329
+ ),
10330
+ suggestions,
10331
+ readOnly: true,
10332
+ actions: [],
10333
+ };
10334
+ }
10335
+
10073
10336
  function buildTimelineFileEventDetail(entry, locale) {
10074
10337
  const fileEventType = normalizeTimelineFileEventType(entry?.fileEventType ?? "");
10075
10338
  return {
@@ -10791,6 +11054,33 @@ async function buildApiItemDetail({ config, runtime, state, kind, token, locale
10791
11054
  const entry = timelineEntryByToken(runtime, token, kind);
10792
11055
  return entry ? buildTimelineFileEventDetail(entry, locale) : null;
10793
11056
  }
11057
+ if (kind === "ambient_suggestions") {
11058
+ const entry = timelineEntryByToken(runtime, token, kind);
11059
+ if (entry) {
11060
+ return buildAmbientSuggestionsDetail(entry, locale);
11061
+ }
11062
+ const historicalSource = (runtime.recentHistoryItems ?? []).find((item) => {
11063
+ if (cleanText(item?.token || "") !== cleanText(token || "")) {
11064
+ return false;
11065
+ }
11066
+ const itemKind = cleanText(item?.kind || "");
11067
+ if (itemKind !== "assistant_final" && itemKind !== "completion") {
11068
+ return false;
11069
+ }
11070
+ return parseAmbientSuggestionsPayload(item?.messageText ?? "") !== null;
11071
+ });
11072
+ if (!historicalSource) {
11073
+ return null;
11074
+ }
11075
+ return buildAmbientSuggestionsDetail({
11076
+ token: historicalSource.token,
11077
+ threadId: historyItemThreadId(historicalSource),
11078
+ threadLabel: historicalSource.threadLabel,
11079
+ title: kindTitle(locale, "ambient_suggestions"),
11080
+ createdAtMs: historicalSource.createdAtMs,
11081
+ suggestions: parseAmbientSuggestionsPayload(historicalSource.messageText ?? "") ?? [],
11082
+ }, locale);
11083
+ }
10794
11084
  if (timelineMessageKinds.has(kind)) {
10795
11085
  const entry = timelineEntryByToken(runtime, token, kind);
10796
11086
  if (!entry) return null;
@@ -16213,6 +16503,7 @@ async function main() {
16213
16503
  migratedPairedDevicesStateChanged ||
16214
16504
  restoredPendingPlanStateChanged ||
16215
16505
  restoredTimelineImagePathsStateChanged ||
16506
+ backfilledAmbientSuggestionsStateChanged ||
16216
16507
  migratedRecentCodeEventsStateChanged ||
16217
16508
  restoredPendingUserInputStateChanged ||
16218
16509
  backfilledMoltbookInboxChanged ||
package/web/app.css CHANGED
@@ -611,6 +611,7 @@ code {
611
611
 
612
612
  .settings-compose-badge {
613
613
  display: inline-flex;
614
+ align-items: center;
614
615
  align-self: flex-start;
615
616
  flex: 0 0 auto;
616
617
  padding: 0.12rem 0.42rem;
@@ -622,6 +623,16 @@ code {
622
623
  white-space: nowrap;
623
624
  }
624
625
 
626
+ .settings-compose-badge svg {
627
+ width: 0.85rem;
628
+ height: 0.85rem;
629
+ display: block;
630
+ }
631
+
632
+ .settings-compose-badge:has(> svg) {
633
+ padding: 0.18rem 0.3rem;
634
+ }
635
+
625
636
  .settings-compose-badge--post {
626
637
  background: rgba(67, 160, 255, 0.18);
627
638
  color: rgba(121, 196, 255, 0.92);
@@ -2182,6 +2193,95 @@ code {
2182
2193
  padding-inline: 0.1rem;
2183
2194
  }
2184
2195
 
2196
+ .ambient-suggestions {
2197
+ display: grid;
2198
+ gap: 0.95rem;
2199
+ }
2200
+
2201
+ .ambient-suggestions__intro > :first-child {
2202
+ margin-top: 0;
2203
+ }
2204
+
2205
+ .ambient-suggestions__intro > :last-child {
2206
+ margin-bottom: 0;
2207
+ }
2208
+
2209
+ .ambient-suggestions__list {
2210
+ display: grid;
2211
+ gap: 0.8rem;
2212
+ }
2213
+
2214
+ .ambient-suggestion-card {
2215
+ display: grid;
2216
+ gap: 0.72rem;
2217
+ padding: 0.95rem 1rem;
2218
+ border-radius: 18px;
2219
+ background: rgba(10, 17, 22, 0.54);
2220
+ border: 1px solid rgba(156, 181, 197, 0.12);
2221
+ }
2222
+
2223
+ .ambient-suggestion-card__header {
2224
+ display: grid;
2225
+ grid-template-columns: minmax(0, 1fr) auto;
2226
+ gap: 0.7rem;
2227
+ align-items: start;
2228
+ }
2229
+
2230
+ .ambient-suggestion-card__title,
2231
+ .ambient-suggestion-card__description,
2232
+ .ambient-suggestion-card__prompt-label,
2233
+ .ambient-suggestion-card__prompt {
2234
+ margin: 0;
2235
+ }
2236
+
2237
+ .ambient-suggestion-card__title {
2238
+ font-size: 1rem;
2239
+ line-height: 1.4;
2240
+ color: var(--text);
2241
+ overflow-wrap: anywhere;
2242
+ }
2243
+
2244
+ .ambient-suggestion-card__description {
2245
+ color: var(--muted);
2246
+ line-height: 1.55;
2247
+ overflow-wrap: anywhere;
2248
+ }
2249
+
2250
+ .ambient-suggestion-card__copy-button {
2251
+ min-height: 2.35rem;
2252
+ padding: 0.65rem 0.9rem;
2253
+ border-radius: 14px;
2254
+ font-size: 0.84rem;
2255
+ white-space: nowrap;
2256
+ flex: 0 0 auto;
2257
+ }
2258
+
2259
+ .ambient-suggestion-card__prompt-wrap {
2260
+ display: grid;
2261
+ gap: 0.42rem;
2262
+ }
2263
+
2264
+ .ambient-suggestion-card__prompt-label {
2265
+ color: var(--muted);
2266
+ font-size: 0.78rem;
2267
+ font-weight: 700;
2268
+ letter-spacing: 0.01em;
2269
+ }
2270
+
2271
+ .ambient-suggestion-card__prompt {
2272
+ padding: 0.88rem 0.92rem;
2273
+ border-radius: 15px;
2274
+ background: rgba(255, 255, 255, 0.04);
2275
+ border: 1px solid rgba(156, 181, 197, 0.12);
2276
+ color: #eef8ff;
2277
+ font-size: 0.9rem;
2278
+ line-height: 1.55;
2279
+ white-space: pre-wrap;
2280
+ word-break: break-word;
2281
+ overflow-wrap: anywhere;
2282
+ font-family: "SFMono-Regular", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
2283
+ }
2284
+
2185
2285
  .detail-diff-thread__header {
2186
2286
  width: 100%;
2187
2287
  display: grid;
package/web/app.js CHANGED
@@ -55,6 +55,7 @@ const state = {
55
55
  a2aTaskExecutorPick: "codex",
56
56
  pushNotice: "",
57
57
  pushError: "",
58
+ ambientSuggestionCopyState: null,
58
59
  deviceNotice: "",
59
60
  deviceError: "",
60
61
  imageViewer: null,
@@ -676,6 +677,7 @@ function timelineKindFilterOptions() {
676
677
  const codexClaudeOptions = [
677
678
  allOption,
678
679
  { id: "messages", label: L("timeline.kindFilter.messages"), icon: "timeline" },
680
+ { id: "suggestions", label: L("timeline.kindFilter.suggestions"), icon: "suggestions" },
679
681
  { id: "files", label: L("timeline.kindFilter.files"), icon: "file-event" },
680
682
  { id: "approvals", label: L("timeline.kindFilter.approvals"), icon: "approval" },
681
683
  { id: "plans", label: L("timeline.kindFilter.plans"), icon: "plan" },
@@ -710,6 +712,8 @@ function timelineEntryMatchesKindFilter(entry, filterId) {
710
712
  switch (filterId) {
711
713
  case "messages":
712
714
  return TIMELINE_MESSAGE_KINDS.has(kind);
715
+ case "suggestions":
716
+ return kind === "ambient_suggestions";
713
717
  case "files":
714
718
  return kind === "file_event";
715
719
  case "approvals":
@@ -2347,6 +2351,10 @@ function timelineEntryThreadLabel(item, isMessage) {
2347
2351
  }
2348
2352
 
2349
2353
  function timelineEntryPrimaryText(item, status, { isMessageLike = false, isFileEvent = false } = {}) {
2354
+ if (item?.kind === "ambient_suggestions") {
2355
+ return item.summary || fallbackSummaryForKind(item.kind, status, item.provider);
2356
+ }
2357
+
2350
2358
  if (isMessageLike) {
2351
2359
  return item.summary || fallbackSummaryForKind(item.kind, status, item.provider);
2352
2360
  }
@@ -2359,6 +2367,10 @@ function timelineEntryPrimaryText(item, status, { isMessageLike = false, isFileE
2359
2367
  }
2360
2368
 
2361
2369
  function timelineEntrySecondaryText(item, status, primaryText, { isMessageLike = false, isFileEvent = false } = {}) {
2370
+ if (item?.kind === "ambient_suggestions") {
2371
+ return "";
2372
+ }
2373
+
2362
2374
  if (isMessageLike) {
2363
2375
  return "";
2364
2376
  }
@@ -3607,8 +3619,9 @@ function renderSettingsA2aSharePage(context) {
3607
3619
  const visible = items.slice(0, visibleCount);
3608
3620
  const hasMore = items.length > visibleCount;
3609
3621
  const filesList = visible.map((item) => {
3622
+ const passwordLabel = L("settings.a2aShare.passwordProtected");
3610
3623
  const lock = item.hasPassword
3611
- ? `<span class="settings-compose-badge settings-compose-badge--reply" title="${escapeHtml(L("settings.a2aShare.passwordProtected"))}">🔒</span>`
3624
+ ? `<span class="settings-compose-badge settings-compose-badge--reply" role="img" title="${escapeHtml(passwordLabel)}" aria-label="${escapeHtml(passwordLabel)}">${renderIcon("lock")}</span>`
3612
3625
  : "";
3613
3626
  const label = escapeHtml(item.originalName || item.slug);
3614
3627
  const link = item.url
@@ -3825,8 +3838,9 @@ function renderStandardDetailDesktop(detail) {
3825
3838
  ${renderMoltbookDraftComposer(detail)}
3826
3839
  ${renderA2ATaskDetail(detail)}
3827
3840
  ${renderThreadShareDetail(detail)}
3841
+ ${renderAmbientSuggestionsSection(detail)}
3828
3842
  ${
3829
- detail.kind === "moltbook_draft" || detail.kind === "moltbook_reply" || detail.kind === "a2a_task" || detail.kind === "a2a_task_result" || detail.kind === "thread_share"
3843
+ detail.kind === "moltbook_draft" || detail.kind === "moltbook_reply" || detail.kind === "a2a_task" || detail.kind === "a2a_task_result" || detail.kind === "thread_share" || detail.kind === "ambient_suggestions"
3830
3844
  ? ""
3831
3845
  : plainIntro
3832
3846
  ? plainIntro
@@ -3863,8 +3877,9 @@ function renderStandardDetailMobile(detail) {
3863
3877
  ${renderMoltbookDraftComposer(detail, { mobile: true })}
3864
3878
  ${renderA2ATaskDetail(detail, { mobile: true })}
3865
3879
  ${renderThreadShareDetail(detail, { mobile: true })}
3880
+ ${renderAmbientSuggestionsSection(detail, { mobile: true })}
3866
3881
  ${
3867
- detail.kind === "moltbook_draft" || detail.kind === "moltbook_reply" || detail.kind === "a2a_task" || detail.kind === "a2a_task_result" || detail.kind === "thread_share"
3882
+ detail.kind === "moltbook_draft" || detail.kind === "moltbook_reply" || detail.kind === "a2a_task" || detail.kind === "a2a_task_result" || detail.kind === "thread_share" || detail.kind === "ambient_suggestions"
3868
3883
  ? ""
3869
3884
  : plainIntro
3870
3885
  ? plainIntro
@@ -3889,6 +3904,65 @@ function renderStandardDetailMobile(detail) {
3889
3904
  `;
3890
3905
  }
3891
3906
 
3907
+ function renderAmbientSuggestionsSection(detail, options = {}) {
3908
+ if (detail?.kind !== "ambient_suggestions") {
3909
+ return "";
3910
+ }
3911
+
3912
+ const suggestions = normalizeClientAmbientSuggestions(detail?.suggestions);
3913
+ if (suggestions.length === 0) {
3914
+ return `
3915
+ <section class="detail-card detail-card--body ${options.mobile ? "detail-card--mobile" : ""}">
3916
+ <div class="detail-body markdown">${detail.messageHtml || ""}</div>
3917
+ </section>
3918
+ `;
3919
+ }
3920
+
3921
+ return `
3922
+ <section class="detail-card detail-card--body ${options.mobile ? "detail-card--mobile" : ""}">
3923
+ <div class="ambient-suggestions">
3924
+ ${detail.messageHtml ? `<div class="ambient-suggestions__intro markdown">${detail.messageHtml}</div>` : ""}
3925
+ <div class="ambient-suggestions__list">
3926
+ ${suggestions
3927
+ .map((suggestion, index) => renderAmbientSuggestionCard(detail, suggestion, index))
3928
+ .join("")}
3929
+ </div>
3930
+ </div>
3931
+ </section>
3932
+ `;
3933
+ }
3934
+
3935
+ function renderAmbientSuggestionCard(detail, suggestion, index) {
3936
+ const copyKey = ambientSuggestionCopyKey(detail?.token || "", suggestion?.id || "", index);
3937
+ const copyStatus = state.ambientSuggestionCopyState?.key === copyKey
3938
+ ? state.ambientSuggestionCopyState?.status || ""
3939
+ : "";
3940
+ const copyLabel = copyStatus === "success"
3941
+ ? L("detail.ambientSuggestions.copyPromptDone")
3942
+ : copyStatus === "error"
3943
+ ? L("detail.ambientSuggestions.copyPromptFailed")
3944
+ : L("detail.ambientSuggestions.copyPrompt");
3945
+ return `
3946
+ <article class="ambient-suggestion-card">
3947
+ <div class="ambient-suggestion-card__header">
3948
+ <h3 class="ambient-suggestion-card__title">${escapeHtml(suggestion.title)}</h3>
3949
+ <button
3950
+ type="button"
3951
+ class="secondary ambient-suggestion-card__copy-button"
3952
+ data-copy-ambient-suggestion
3953
+ data-copy-ambient-suggestion-token="${escapeHtml(detail?.token || "")}"
3954
+ data-copy-ambient-suggestion-index="${escapeHtml(String(index))}"
3955
+ >${escapeHtml(copyLabel)}</button>
3956
+ </div>
3957
+ ${suggestion.description ? `<p class="ambient-suggestion-card__description">${escapeHtml(suggestion.description)}</p>` : ""}
3958
+ <div class="ambient-suggestion-card__prompt-wrap">
3959
+ <p class="ambient-suggestion-card__prompt-label">${escapeHtml(L("detail.ambientSuggestions.prompt"))}</p>
3960
+ <pre class="ambient-suggestion-card__prompt">${escapeHtml(suggestion.prompt)}</pre>
3961
+ </div>
3962
+ </article>
3963
+ `;
3964
+ }
3965
+
3892
3966
  function renderDetailPlainIntro(detail, options = {}) {
3893
3967
  if (!["approval", "diff_thread", "file_event"].includes(detail?.kind || "")) {
3894
3968
  return "";
@@ -5290,6 +5364,41 @@ function bindShellInteractions() {
5290
5364
  });
5291
5365
  }
5292
5366
 
5367
+ for (const button of document.querySelectorAll("[data-copy-ambient-suggestion]")) {
5368
+ button.addEventListener("click", async (event) => {
5369
+ event.preventDefault();
5370
+ event.stopPropagation();
5371
+ const detail = state.currentDetail;
5372
+ if (!detail || detail.kind !== "ambient_suggestions") {
5373
+ return;
5374
+ }
5375
+ if ((button.dataset.copyAmbientSuggestionToken || "") !== (detail.token || "")) {
5376
+ return;
5377
+ }
5378
+ const index = Math.max(0, Number(button.dataset.copyAmbientSuggestionIndex) || 0);
5379
+ const suggestions = normalizeClientAmbientSuggestions(detail.suggestions);
5380
+ const suggestion = suggestions[index];
5381
+ if (!suggestion?.prompt) {
5382
+ return;
5383
+ }
5384
+ const copyKey = ambientSuggestionCopyKey(detail.token || "", suggestion.id || "", index);
5385
+ try {
5386
+ await copyTextToClipboard(suggestion.prompt);
5387
+ state.ambientSuggestionCopyState = { key: copyKey, status: "success" };
5388
+ } catch {
5389
+ state.ambientSuggestionCopyState = { key: copyKey, status: "error" };
5390
+ }
5391
+ await renderShell();
5392
+ window.setTimeout(async () => {
5393
+ if (state.ambientSuggestionCopyState?.key !== copyKey) {
5394
+ return;
5395
+ }
5396
+ state.ambientSuggestionCopyState = null;
5397
+ await renderShell();
5398
+ }, 1600);
5399
+ });
5400
+ }
5401
+
5293
5402
  for (const button of document.querySelectorAll("[data-diff-thread-file-toggle]")) {
5294
5403
  button.addEventListener("click", async (event) => {
5295
5404
  event.preventDefault();
@@ -6314,7 +6423,7 @@ function tabForItemKind(kind, fallback) {
6314
6423
  if (kind === "diff_thread") {
6315
6424
  return "diff";
6316
6425
  }
6317
- if (kind === "file_event") {
6426
+ if (kind === "file_event" || kind === "ambient_suggestions") {
6318
6427
  return "timeline";
6319
6428
  }
6320
6429
  if (TIMELINE_MESSAGE_KINDS.has(kind)) {
@@ -6346,6 +6455,8 @@ function kindMeta(kind, item) {
6346
6455
  return { label: L("common.assistantCommentary"), tone: "plan", icon: "assistant-commentary" };
6347
6456
  case "assistant_final":
6348
6457
  return { label: L("common.assistantFinal"), tone: "completion", icon: "assistant-final" };
6458
+ case "ambient_suggestions":
6459
+ return { label: L("common.ambientSuggestions"), tone: "neutral", icon: "suggestions" };
6349
6460
  case "approval":
6350
6461
  return { label: L("common.approval"), tone: "approval", icon: "approval" };
6351
6462
  case "plan":
@@ -6391,6 +6502,9 @@ function itemIntentText(kind, status = "pending", provider) {
6391
6502
  if (kind === "file_event") {
6392
6503
  return L("intent.fileEvent");
6393
6504
  }
6505
+ if (kind === "ambient_suggestions") {
6506
+ return L("intent.ambientSuggestions");
6507
+ }
6394
6508
  if (kind === "user_message") {
6395
6509
  return L("intent.userMessage");
6396
6510
  }
@@ -6425,6 +6539,9 @@ function detailIntentText(detail) {
6425
6539
  if (detail.kind === "file_event") {
6426
6540
  return itemIntentText(detail.kind, "timeline", provider);
6427
6541
  }
6542
+ if (detail.kind === "ambient_suggestions") {
6543
+ return itemIntentText(detail.kind, "timeline", provider);
6544
+ }
6428
6545
  if (TIMELINE_MESSAGE_KINDS.has(detail.kind)) {
6429
6546
  return itemIntentText(detail.kind, "timeline", provider);
6430
6547
  }
@@ -6443,7 +6560,11 @@ function renderDetailTitle(detail) {
6443
6560
  }
6444
6561
 
6445
6562
  function detailDisplayTitle(detail) {
6446
- const threadLabel = normalizeClientText(detail?.threadLabel || "");
6563
+ const threadLabel = sanitizeThreadLabelForDisplay(detail?.threadLabel || "", detail?.threadId || "");
6564
+ if (detail?.kind === "ambient_suggestions") {
6565
+ const ambientTitle = sanitizeThreadLabelForDisplay(detail?.title || "", detail?.threadId || "");
6566
+ return ambientTitle || L("common.ambientSuggestions");
6567
+ }
6447
6568
  if (threadLabel) {
6448
6569
  return threadLabel;
6449
6570
  }
@@ -6491,6 +6612,8 @@ function fallbackSummaryForKind(kind, status, provider) {
6491
6612
  return L("summary.diffThread");
6492
6613
  case "file_event":
6493
6614
  return L("summary.fileEvent", vars);
6615
+ case "ambient_suggestions":
6616
+ return L("summary.ambientSuggestions", { count: 0, firstTitle: "", more: 0 });
6494
6617
  case "user_message":
6495
6618
  return L("summary.userMessage");
6496
6619
  case "assistant_commentary":
@@ -6707,6 +6830,8 @@ function renderIcon(name) {
6707
6830
  return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><path d="M12 6.2v5.6"/><path d="M9.2 9h5.6"/><path d="M6 14.8a6.7 6.7 0 0 0 12 0"/><path d="M8 4.8a7.6 7.6 0 0 1 8 0"/></svg>`;
6708
6831
  case "assistant-final":
6709
6832
  return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><path d="M6 6.5h12a1.8 1.8 0 0 1 1.8 1.8v6.1A1.8 1.8 0 0 1 18 16.2h-5.3L9 19.4v-3.2H6a1.8 1.8 0 0 1-1.8-1.8V8.3A1.8 1.8 0 0 1 6 6.5Z"/><path d="m9.2 11.3 1.7 1.7 3.6-3.8"/></svg>`;
6833
+ case "suggestions":
6834
+ 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.8 13 6.7l3 .5-2.2 2.2.5 3-2.3-1.2-2.3 1.2.5-3L8 7.2l3-.5Z"/><path d="M6.2 14.6h11.6"/><path d="M8.4 18.2h7.2"/></svg>`;
6710
6835
  case "completed":
6711
6836
  return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8.5"/><path d="m8.7 12.2 2.1 2.1 4.6-4.8"/></svg>`;
6712
6837
  case "settings":
@@ -6733,6 +6858,8 @@ function renderIcon(name) {
6733
6858
  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>`;
6734
6859
  case "check":
6735
6860
  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>`;
6861
+ case "lock":
6862
+ return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="5.5" y="10.5" width="13" height="9" rx="2"/><path d="M8 10.5V7.5a4 4 0 0 1 8 0v3"/></svg>`;
6736
6863
  case "back":
6737
6864
  return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>`;
6738
6865
  case "chevron-down":
@@ -6979,6 +7106,58 @@ function normalizeClientFileRefs(fileRefs) {
6979
7106
  return deduped;
6980
7107
  }
6981
7108
 
7109
+ function normalizeClientAmbientSuggestions(value) {
7110
+ if (!Array.isArray(value)) {
7111
+ return [];
7112
+ }
7113
+ return value
7114
+ .map((entry) => {
7115
+ const title = normalizeClientText(entry?.title);
7116
+ const prompt = normalizeClientText(entry?.prompt);
7117
+ if (!title || !prompt) {
7118
+ return null;
7119
+ }
7120
+ return {
7121
+ id: normalizeClientText(entry?.id),
7122
+ title,
7123
+ prompt,
7124
+ description: normalizeClientText(entry?.description),
7125
+ };
7126
+ })
7127
+ .filter(Boolean);
7128
+ }
7129
+
7130
+ function ambientSuggestionCopyKey(token, suggestionId, index) {
7131
+ return [normalizeClientText(token), normalizeClientText(suggestionId), String(Math.max(0, Number(index) || 0))].join(":");
7132
+ }
7133
+
7134
+ async function copyTextToClipboard(text) {
7135
+ const normalized = String(text ?? "");
7136
+ if (!normalized) {
7137
+ throw new Error("empty");
7138
+ }
7139
+
7140
+ if (navigator.clipboard?.writeText) {
7141
+ await navigator.clipboard.writeText(normalized);
7142
+ return;
7143
+ }
7144
+
7145
+ const textarea = document.createElement("textarea");
7146
+ textarea.value = normalized;
7147
+ textarea.setAttribute("readonly", "true");
7148
+ textarea.style.position = "fixed";
7149
+ textarea.style.opacity = "0";
7150
+ textarea.style.pointerEvents = "none";
7151
+ document.body.appendChild(textarea);
7152
+ textarea.select();
7153
+ textarea.setSelectionRange(0, textarea.value.length);
7154
+ const copied = document.execCommand("copy");
7155
+ document.body.removeChild(textarea);
7156
+ if (!copied) {
7157
+ throw new Error("copy-failed");
7158
+ }
7159
+ }
7160
+
6982
7161
  function fileRefLabel(fileRef) {
6983
7162
  const normalized = normalizeClientText(fileRef);
6984
7163
  if (!normalized) {
package/web/i18n.js CHANGED
@@ -47,6 +47,7 @@ const translations = {
47
47
  "common.userMessage": "You",
48
48
  "common.assistantCommentary": "Update",
49
49
  "common.assistantFinal": "Final reply",
50
+ "common.ambientSuggestions": "Suggestions",
50
51
  "common.item": "Item",
51
52
  "common.sns": "SNS",
52
53
  "common.moltbookReply": "Reply",
@@ -138,6 +139,11 @@ const translations = {
138
139
  "detail.filesTitle": "Files",
139
140
  "detail.diffTitle": "Diff",
140
141
  "detail.diffUnavailable": "Diff is not available for this change. Showing touched files only.",
142
+ "detail.ambientSuggestions.copy": "Review the suggested next steps and copy any prompt into a new turn when you want to try it.",
143
+ "detail.ambientSuggestions.prompt": "Prompt",
144
+ "detail.ambientSuggestions.copyPrompt": "Copy prompt",
145
+ "detail.ambientSuggestions.copyPromptDone": "Copied",
146
+ "detail.ambientSuggestions.copyPromptFailed": "Copy failed",
141
147
  "detail.diffThread.copy": ({ count }) =>
142
148
  `This thread has ${count} ${count === 1 ? "changed file" : "changed files"}. Review each file below.`,
143
149
  "detail.diffThread.copy.current": ({ count }) =>
@@ -194,6 +200,13 @@ const translations = {
194
200
  "summary.userMessage": "Open the message again and read it in context.",
195
201
  "summary.assistantCommentary": "Read {provider}'s latest working update.",
196
202
  "summary.assistantFinal": "Read {provider}'s final reply for this turn.",
203
+ "summary.ambientSuggestions": ({ count, firstTitle, more }) => {
204
+ if (!count) return "Review suggested next steps.";
205
+ const parts = [`${count} ${count === 1 ? "suggestion" : "suggestions"}`];
206
+ if (firstTitle) parts.push(firstTitle);
207
+ if (more > 0) parts.push(`+${more} more`);
208
+ return parts.join(" · ");
209
+ },
197
210
  "summary.approval": "Review what {provider} wants to do and decide whether to allow it.",
198
211
  "summary.plan": "Check the proposed plan before {provider} starts implementing it.",
199
212
  "summary.choice": "Pick the option {provider} needs to continue.",
@@ -206,6 +219,7 @@ const translations = {
206
219
  "intent.completed": "Finished. Open to review the result.",
207
220
  "intent.fileEvent": "Review the files involved in this step.",
208
221
  "intent.diffThread": "Review the file changes for this thread.",
222
+ "intent.ambientSuggestions": "Review suggested next steps and copy a prompt if you want to try one.",
209
223
  "intent.userMessage": "Open this message again.",
210
224
  "intent.assistantCommentary": "Read the latest working update.",
211
225
  "intent.assistantFinal": "Read {provider}'s final reply for this turn.",
@@ -223,6 +237,7 @@ const translations = {
223
237
  "timeline.kindFilterButtonLabel": "Filter timeline events",
224
238
  "timeline.kindFilter.all": "All events",
225
239
  "timeline.kindFilter.messages": "Messages",
240
+ "timeline.kindFilter.suggestions": "Suggested next steps",
226
241
  "timeline.kindFilter.files": "Files",
227
242
  "timeline.kindFilter.approvals": "Approvals",
228
243
  "timeline.kindFilter.plans": "Plans",
@@ -430,10 +445,10 @@ const translations = {
430
445
  "settings.a2aExecutor.ask": "Ask every time",
431
446
  "settings.a2aExecutor.unavailable": "Not detected",
432
447
  "settings.a2aExecutor.detected": "Detected",
433
- "settings.a2aShare.title": "A2A Share",
434
- "settings.a2aShare.subtitle": "HTML hosting",
435
- "settings.a2aShare.copy": "Host static HTML files on a private URL. Shares the A2A credentials; no separate setup needed.",
436
- "settings.a2aShare.unavailable": "A2A Share is not configured. Run `viveworker a2a setup` to provision credentials.",
448
+ "settings.a2aShare.title": "File Share",
449
+ "settings.a2aShare.subtitle": "File hosting",
450
+ "settings.a2aShare.copy": "Host static files (HTML, PDF, images, CSV) on a private URL. Shares the A2A credentials; no separate setup needed.",
451
+ "settings.a2aShare.unavailable": "File Share is not configured. Run `viveworker a2a setup` to provision credentials.",
437
452
  "settings.a2aShare.storage.title": "Storage",
438
453
  "settings.a2aShare.files.title": "Active shares",
439
454
  "settings.a2aShare.files.empty": "No active shares yet.",
@@ -519,6 +534,7 @@ const translations = {
519
534
  "server.title.userMessage": "User",
520
535
  "server.title.assistantCommentary": "Update",
521
536
  "server.title.assistantFinal": "Final reply",
537
+ "server.title.ambientSuggestions": "Suggested next steps",
522
538
  "server.action.review": "Review",
523
539
  "server.action.detail": "Detail",
524
540
  "server.action.select": "Choose",
@@ -734,6 +750,7 @@ const translations = {
734
750
  "common.userMessage": "あなた",
735
751
  "common.assistantCommentary": "途中経過",
736
752
  "common.assistantFinal": "最終回答",
753
+ "common.ambientSuggestions": "候補",
737
754
  "common.item": "項目",
738
755
  "common.sns": "SNS",
739
756
  "common.moltbookReply": "返信",
@@ -824,6 +841,11 @@ const translations = {
824
841
  "detail.filesTitle": "関連ファイル",
825
842
  "detail.diffTitle": "差分",
826
843
  "detail.diffUnavailable": "この変更の差分はまだ利用できません。対象ファイルのみ表示します。",
844
+ "detail.ambientSuggestions.copy": "次にやる候補を確認して、試したい prompt があればコピーできます。",
845
+ "detail.ambientSuggestions.prompt": "Prompt",
846
+ "detail.ambientSuggestions.copyPrompt": "Prompt をコピー",
847
+ "detail.ambientSuggestions.copyPromptDone": "コピーしました",
848
+ "detail.ambientSuggestions.copyPromptFailed": "コピーに失敗しました",
827
849
  "detail.diffThread.copy": ({ count }) => `このスレッドで変更があるファイルは ${count}件です。下でファイルごとに確認できます。`,
828
850
  "detail.diffThread.copy.current": ({ count }) => `このスレッドで現在未ステージの変更があるファイルは ${count}件です。下でファイルごとに確認できます。`,
829
851
  "detail.diffThread.copy.latest": ({ count }) => `このスレッドで最後に観測した変更があるファイルは ${count}件です。下でファイルごとに確認できます。`,
@@ -877,6 +899,13 @@ const translations = {
877
899
  "summary.userMessage": "このメッセージをもう一度開いて文脈付きで確認します。",
878
900
  "summary.assistantCommentary": "{provider} の最新の途中経過を確認します。",
879
901
  "summary.assistantFinal": "このターンの {provider} の最終回答を確認します。",
902
+ "summary.ambientSuggestions": ({ count, firstTitle, more }) => {
903
+ if (!count) return "次にやる候補を確認します。";
904
+ const parts = [`${count}件の候補`];
905
+ if (firstTitle) parts.push(firstTitle);
906
+ if (more > 0) parts.push(`ほか${more}件`);
907
+ return parts.join(" · ");
908
+ },
880
909
  "summary.approval": "{provider} がやろうとしていることを確認して、許可するか決めます。",
881
910
  "summary.plan": "{provider} が実装を始める前に、提案されたプランを確認します。",
882
911
  "summary.choice": "{provider} が先へ進むために必要な選択肢を選びます。",
@@ -889,6 +918,7 @@ const translations = {
889
918
  "intent.completed": "完了済みです。結果を確認できます。",
890
919
  "intent.fileEvent": "このステップで触れたファイルを確認します。",
891
920
  "intent.diffThread": "このスレッドのファイル変更を確認します。",
921
+ "intent.ambientSuggestions": "次にやる候補を確認して、必要なら prompt をコピーします。",
892
922
  "intent.userMessage": "このメッセージを開き直します。",
893
923
  "intent.assistantCommentary": "最新の途中経過を確認します。",
894
924
  "intent.assistantFinal": "このターンの {provider} の最終回答を確認します。",
@@ -906,6 +936,7 @@ const translations = {
906
936
  "timeline.kindFilterButtonLabel": "タイムラインのイベントを絞り込む",
907
937
  "timeline.kindFilter.all": "すべてのイベント",
908
938
  "timeline.kindFilter.messages": "メッセージ",
939
+ "timeline.kindFilter.suggestions": "候補提案",
909
940
  "timeline.kindFilter.files": "ファイル",
910
941
  "timeline.kindFilter.approvals": "承認",
911
942
  "timeline.kindFilter.plans": "プラン",
@@ -1113,10 +1144,10 @@ const translations = {
1113
1144
  "settings.a2aExecutor.ask": "毎回確認する",
1114
1145
  "settings.a2aExecutor.unavailable": "未検出",
1115
1146
  "settings.a2aExecutor.detected": "検出",
1116
- "settings.a2aShare.title": "A2A Share",
1117
- "settings.a2aShare.subtitle": "HTML ホスティング",
1118
- "settings.a2aShare.copy": "静的 HTML ファイルを非公開 URL でホストします。A2A と認証情報を共有するため追加の設定は不要です。",
1119
- "settings.a2aShare.unavailable": "A2A Share が設定されていません。`viveworker a2a setup` を実行して認証情報を設定してください。",
1147
+ "settings.a2aShare.title": "File Share",
1148
+ "settings.a2aShare.subtitle": "ファイルホスティング",
1149
+ "settings.a2aShare.copy": "静的ファイル (HTML / PDF / 画像 / CSV) を非公開 URL でホストします。A2A と認証情報を共有するため追加の設定は不要です。",
1150
+ "settings.a2aShare.unavailable": "File Share が設定されていません。`viveworker a2a setup` を実行して認証情報を設定してください。",
1120
1151
  "settings.a2aShare.storage.title": "ストレージ",
1121
1152
  "settings.a2aShare.files.title": "公開中のファイル",
1122
1153
  "settings.a2aShare.files.empty": "公開中のファイルはありません。",
@@ -1202,6 +1233,7 @@ const translations = {
1202
1233
  "server.title.userMessage": "ユーザー",
1203
1234
  "server.title.assistantCommentary": "途中経過",
1204
1235
  "server.title.assistantFinal": "最終回答",
1236
+ "server.title.ambientSuggestions": "次の候補",
1205
1237
  "server.action.review": "判断する",
1206
1238
  "server.action.detail": "詳細",
1207
1239
  "server.action.select": "選ぶ",