@oar-sh/oar 0.9.9 → 0.9.11

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.
Files changed (154) hide show
  1. package/CHANGELOG.md +561 -1
  2. package/README.md +162 -909
  3. package/bin/bat/oar.bat +46 -46
  4. package/package.json +14 -14
  5. package/server/README.md +369 -13
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  7. package/server/claude-worker/claude-session-process.mjs +121 -9
  8. package/server/claude-worker/claude-session-worker.mjs +9 -1
  9. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  10. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  11. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  12. package/server/copilot-worker/copilot-sdk-session-process.mjs +79 -12
  13. package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
  14. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  15. package/server/db-schema.mjs +95 -2
  16. package/server/grok-worker/acp-client.mjs +2 -1
  17. package/server/grok-worker/acp-host-services.mjs +4 -3
  18. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  19. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  20. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  21. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  22. package/server/migrations/0013-previews.mjs +130 -0
  23. package/server/migrations/0014-conversation-groups.mjs +89 -0
  24. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  25. package/server/migrations/0016-share-access-log.mjs +110 -0
  26. package/server/public/app/about-ui.js +73 -0
  27. package/server/public/app/about-view.mjs +234 -0
  28. package/server/public/app/action-confirmations.js +31 -14
  29. package/server/public/app/agent-exchange-fold-view.mjs +187 -0
  30. package/server/public/app/api-client.js +312 -16
  31. package/server/public/app/archive-confirm.mjs +28 -0
  32. package/server/public/app/ask-user-view.js +17 -12
  33. package/server/public/app/attachments-view.js +516 -6
  34. package/server/public/app/background-tasks-view.mjs +18 -26
  35. package/server/public/app/bootstrap.js +336 -59
  36. package/server/public/app/chat-actions-menu-state.mjs +126 -0
  37. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  38. package/server/public/app/composer-control-state.mjs +16 -0
  39. package/server/public/app/composer-keyboard.mjs +54 -0
  40. package/server/public/app/context-menu.mjs +90 -6
  41. package/server/public/app/conversation-group-drag.mjs +191 -0
  42. package/server/public/app/conversation-group-order.mjs +265 -0
  43. package/server/public/app/conversation-group-state.mjs +100 -0
  44. package/server/public/app/conversation-list-drag.mjs +449 -0
  45. package/server/public/app/conversation-turn-state.mjs +81 -0
  46. package/server/public/app/conversation-view.js +436 -117
  47. package/server/public/app/cwd-picker.js +1 -1
  48. package/server/public/app/explorer-edit-state.mjs +68 -0
  49. package/server/public/app/hidden-messages-view.mjs +312 -0
  50. package/server/public/app/host-suspend-ui.js +13 -4
  51. package/server/public/app/journal-view.js +930 -45
  52. package/server/public/app/no-conversation-state.mjs +58 -0
  53. package/server/public/app/preview-cards.mjs +117 -31
  54. package/server/public/app/preview-command.mjs +10 -1
  55. package/server/public/app/provider-readiness.mjs +120 -0
  56. package/server/public/app/relay-board-view.js +19 -3
  57. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  58. package/server/public/app/relay-event-view.mjs +234 -0
  59. package/server/public/app/relay-restart-error.mjs +16 -0
  60. package/server/public/app/settings-modal.js +93 -9
  61. package/server/public/app/settings-tabs.js +1 -1
  62. package/server/public/app/share-format.mjs +49 -0
  63. package/server/public/app/share-preview-marks.mjs +167 -0
  64. package/server/public/app/share-state.js +109 -0
  65. package/server/public/app/shared-conversations-settings.js +480 -0
  66. package/server/public/app/socket-handlers.js +137 -40
  67. package/server/public/app/store.js +115 -32
  68. package/server/public/app/subagent-row.mjs +138 -0
  69. package/server/public/app/subagent-timeline-view.mjs +186 -0
  70. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  71. package/server/public/app/subagent-viewer.mjs +558 -0
  72. package/server/public/app/task-row-format.mjs +29 -0
  73. package/server/public/app/transcript-jump-bottom-view.mjs +464 -0
  74. package/server/public/app/transcript-jump-bottom.mjs +210 -0
  75. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  76. package/server/public/app/transcript-viewport-anchor.mjs +140 -0
  77. package/server/public/app/update-ui.js +1 -1
  78. package/server/public/app/viewer-history.mjs +5 -3
  79. package/server/public/index.html +774 -12
  80. package/server/relay-tools.md +2 -1
  81. package/server/relay.mjs +2 -1
  82. package/server/repositories/conversation-group-repository.mjs +444 -0
  83. package/server/repositories/message-repository.mjs +30 -3
  84. package/server/repositories/question-repository.mjs +9 -3
  85. package/server/repositories/remote-relay-repository.mjs +6 -2
  86. package/server/repositories/session-repository.mjs +12 -37
  87. package/server/repositories/share-repository.mjs +322 -0
  88. package/server/repositories/share-visibility-statements.mjs +10 -0
  89. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  90. package/server/routes/about-routes.mjs +15 -0
  91. package/server/routes/ask-user-routes.mjs +3 -0
  92. package/server/routes/cache-routes.mjs +4 -0
  93. package/server/routes/conversation-group-routes.mjs +171 -0
  94. package/server/routes/messages-routes.mjs +698 -114
  95. package/server/routes/preview-routes.mjs +9 -1
  96. package/server/routes/relay-event-routes.mjs +157 -0
  97. package/server/routes/remote-relay-routes.mjs +15 -0
  98. package/server/routes/sessions-routes.mjs +843 -193
  99. package/server/server-runtime.mjs +559 -160
  100. package/server/services/about-service.mjs +461 -0
  101. package/server/services/background-task-store.mjs +128 -0
  102. package/server/services/background-work-view.mjs +228 -0
  103. package/server/services/cache-rebuild-service.mjs +30 -0
  104. package/server/services/delete-archive-service.mjs +4 -1
  105. package/server/services/detached-worker-watch-service.mjs +150 -0
  106. package/server/services/explorer-entry-name.mjs +26 -0
  107. package/server/services/explorer-mutations.mjs +122 -0
  108. package/server/services/hidden-message-list.mjs +21 -0
  109. package/server/services/host-suspend-command.mjs +2 -1
  110. package/server/services/message-pin-list.mjs +1 -1
  111. package/server/services/preview-health-probe.mjs +8 -1
  112. package/server/services/preview-proxy-server.mjs +49 -27
  113. package/server/services/preview-registry-service.mjs +676 -26
  114. package/server/services/preview-static-handler.mjs +40 -7
  115. package/server/services/preview-store.mjs +274 -0
  116. package/server/services/push-dispatch-service.mjs +18 -0
  117. package/server/services/rate-limit-service.mjs +12 -1
  118. package/server/services/relay-auth.mjs +183 -0
  119. package/server/services/relay-cli-launcher-service.mjs +3 -1
  120. package/server/services/relay-event-link-service.mjs +289 -0
  121. package/server/services/relay-event-service.mjs +1952 -0
  122. package/server/services/relay-shutdown-gate.mjs +31 -0
  123. package/server/services/remote-relay-caller-context.mjs +52 -7
  124. package/server/services/remote-relay-dispatcher.mjs +551 -7
  125. package/server/services/remote-relay-registry-service.mjs +11 -1
  126. package/server/services/request-boundary.mjs +183 -0
  127. package/server/services/session-activity-tracker.mjs +695 -0
  128. package/server/services/session-history-refresh-service.mjs +7 -0
  129. package/server/services/session-worker-launch-service.mjs +190 -6
  130. package/server/services/session-worker-process-service.mjs +10 -6
  131. package/server/services/share-access-log-service.mjs +123 -0
  132. package/server/services/share-path-redaction.mjs +424 -0
  133. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  134. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  135. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  136. package/server/services/status-event-service.mjs +7 -3
  137. package/server/services/subagent-record-retention.mjs +164 -0
  138. package/server/services/subagent-timeline-service.mjs +328 -0
  139. package/server/services/subagent-viewer-rooms.mjs +119 -0
  140. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  141. package/server/services/upgrade-socket-guard.mjs +16 -0
  142. package/server/services/windows-autostart-service.mjs +6 -1
  143. package/server/services/windows-boot-autostart-service.mjs +207 -23
  144. package/shared/preview-tool-core.mjs +15 -5
  145. package/shared/relay-events.mjs +371 -0
  146. package/shared/remote-relay-contract.mjs +160 -6
  147. package/shared/share-path-privacy.mjs +30 -0
  148. package/shared/subagent-record-retention.mjs +31 -0
  149. package/shared/subagent-timeline.mjs +79 -0
  150. package/shared/windows-system-tools.mjs +106 -0
  151. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  152. package/shared/worker-runtime/heartbeat.mjs +24 -0
  153. package/shared/worker-runtime/kept-posts.mjs +83 -0
  154. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -16,14 +16,12 @@ import {
16
16
  setRelayOnline,
17
17
  setCliOnline,
18
18
  setCloudflaredTunnelState,
19
- setCurrentConv,
20
19
  updateWorkspaceRootHints,
21
20
  updateCompactButton,
22
- updateSessionPill,
23
21
  clearPendingUserMessage,
24
22
  hasPendingUserMessageDuplicate,
25
- isMessagesAtBottom,
26
23
  upsertSubagentRun,
24
+ getSubagentRun,
27
25
  addSubagentActivity,
28
26
  addSubagentThought,
29
27
  clearSubagentCancelInFlight,
@@ -36,7 +34,9 @@ import {
36
34
  import { scheduleContextUsageRefresh } from './api-client.js';
37
35
  import { capLiveActivityEntries } from './activity-replay-state.mjs';
38
36
  import { publishStatusEvent, recordStatusEvent } from './status-store.mjs';
39
- import { renderConvList, refreshConversations, openConversation } from './journal-view.js';
37
+ import { clearOpenConversation, renderConvList, refreshConversations, scheduleLoadedConversationsRefresh, applyConversationGroupEvent, openConversation } from './journal-view.js';
38
+ import { applyBackgroundTasksToConversation, applyTurnStatusToConversation } from './conversation-turn-state.mjs';
39
+ import { applyRelayEventSubscriptions, loadRelayEventSubscriptions } from './relay-event-subscriptions-ui.js';
40
40
  import {
41
41
  upsertRelayQuestion,
42
42
  loadRelayQuestions,
@@ -45,7 +45,10 @@ import {
45
45
  import { upsertRelayBoard, loadRelayBoards, renderRelayBoards } from './relay-board-view.js';
46
46
  import { setConversationBackgroundTasks } from './background-tasks-view.mjs';
47
47
  import { setConversationPins } from './pinned-messages-view.mjs';
48
+ import { setConversationHiddenMessages } from './hidden-messages-view.mjs';
48
49
  import { setPreviews } from './preview-cards.mjs';
50
+ import { applyShareSettings, hasActiveShare, refreshShares } from './share-state.js';
51
+ import { handleShareOpened, handleViewerIpsForgotten } from './shared-conversations-settings.js';
49
52
  import { setRemoteRelaysSnapshot } from './remote-relays-store.mjs';
50
53
  import { REMOTE_RELAY_SOCKET_EVENT } from './remote-relay-shared.mjs';
51
54
  import { AGENT_SESSIONS_SOCKET_EVENT } from './agent-sessions-settings.mjs';
@@ -54,6 +57,7 @@ import { applyClaudeAuthState } from './claude-auth-ui.js';
54
57
  import { applyGrokAuthState } from './grok-auth-ui.js';
55
58
  import { applyClaudeCloudSettingsState, refreshClaudeCloudSettingsState } from './claude-cloud-settings-ui.js';
56
59
  import { applyClaudeCloudSessionEvent } from './claude-cloud-conversation-ui.js';
60
+ import { updateProviderReadinessInputs } from './provider-readiness.mjs';
57
61
  import { applyCliInstallState } from './cli-install-ui.js';
58
62
  import { renderUpdateSection } from './update-ui.js';
59
63
  import { applyHostSuspendState, applyRelayShutdownState } from './host-suspend-ui.js';
@@ -73,7 +77,10 @@ import {
73
77
  applyConversationTurnStatus,
74
78
  renderMessages,
75
79
  appendMessage,
80
+ captureLiveReplyReadingPosition,
81
+ restoreLiveReplyReadingPosition,
76
82
  replaceRenderedMessage,
83
+ noteTranscriptLiveRow,
77
84
  applyIncomingConversationDraftUpdate,
78
85
  getRenderedConversationMessageFingerprints,
79
86
  clearBubbleCancelState,
@@ -83,11 +90,18 @@ import {
83
90
  syncComposerButtonState,
84
91
  syncCancellableSteerButtons,
85
92
  } from './conversation-view.js';
93
+ import { followsTranscriptEnd, noteTranscriptNews } from './transcript-jump-bottom-view.mjs';
86
94
  import { loadRepoBrowserTree, refreshRepoBrowserIfWorkspaceOpen } from './attachments-view.js';
87
95
  import { clearMessageSearchRuntimeState } from './message-search-view.js';
88
96
  import { stripRelayPromptContext } from './relay-prompt-sanitizer.mjs';
89
97
  import { isLikelyLiveDuplicateMessage } from './live-message-dedupe.mjs';
90
98
  import { mergeRelayThoughts } from './relay-thoughts.mjs';
99
+ import {
100
+ handleSubagentStatusEventForViewer,
101
+ handleSubagentTimelineEvent,
102
+ handleSubagentViewerReconnect,
103
+ refreshSubagentRows,
104
+ } from './subagent-viewer.mjs';
91
105
 
92
106
  const FALLBACK_MODE = 'agent';
93
107
 
@@ -325,8 +339,17 @@ export async function connectSocket(overrideDeps) {
325
339
  setCliOnline(true);
326
340
  renderConvList();
327
341
  resyncAfterConnect();
342
+ // Every loaded page again, not page one only: events missed while the
343
+ // socket was down touched rows anywhere in the list.
344
+ scheduleLoadedConversationsRefresh();
328
345
  refreshSessionWorkerStatus().catch(() => {});
329
346
  refreshModelCatalog().catch(() => {});
347
+ // An open sub-agent viewer fetches whatever it missed while away.
348
+ handleSubagentViewerReconnect();
349
+ // Subscriptions that changed while away.
350
+ void loadRelayEventSubscriptions();
351
+ // Shares made or stopped while away.
352
+ void refreshShares().catch(() => {});
330
353
  });
331
354
  socket.on('connect_error', (e) => {
332
355
  setRelayOnline(false);
@@ -357,8 +380,11 @@ export async function connectSocket(overrideDeps) {
357
380
  updateModelCatalogState(payload || {});
358
381
  void reconcileOpenModelVariantModal();
359
382
  });
383
+ // Each settings event also feeds provider readiness (provider-readiness.mjs),
384
+ // so a Settings save changes the empty screen without a reload.
360
385
  socket.on('openai_settings_updated', (payload) => {
361
- deps?.applyOpenAISettingsState?.(payload || {});
386
+ const state = deps?.applyOpenAISettingsState?.(payload || {});
387
+ updateProviderReadinessInputs({ openai: state || payload || null });
362
388
  if (Number(payload?.reconciliation?.updatedUnstartedConversations || 0) > 0) {
363
389
  void Promise.resolve()
364
390
  .then(() => deps?.refreshCurrentView?.())
@@ -371,7 +397,8 @@ export async function connectSocket(overrideDeps) {
371
397
  deps?.applyCopilotSettingsState?.(payload || {});
372
398
  });
373
399
  socket.on('claude_settings_updated', (payload) => {
374
- deps?.applyClaudeSettingsState?.(payload || {});
400
+ const state = deps?.applyClaudeSettingsState?.(payload || {});
401
+ updateProviderReadinessInputs({ claude: state || payload || null });
375
402
  if (Number(payload?.reconciliation?.updatedUnstartedConversations || 0) > 0) {
376
403
  void Promise.resolve()
377
404
  .then(() => deps?.refreshCurrentView?.())
@@ -421,8 +448,13 @@ export async function connectSocket(overrideDeps) {
421
448
  // carries the same shape as GET /api/settings/claude-cloud; one without a
422
449
  // body is read as "something changed" and answered with a fresh read.
423
450
  socket.on('claude_cloud_settings_updated', (payload) => {
424
- if (payload && typeof payload === 'object') applyClaudeCloudSettingsState(payload);
425
- else void refreshClaudeCloudSettingsState();
451
+ if (payload && typeof payload === 'object') {
452
+ updateProviderReadinessInputs({ claudeCloud: applyClaudeCloudSettingsState(payload) || null });
453
+ } else {
454
+ void refreshClaudeCloudSettingsState().then((state) => {
455
+ updateProviderReadinessInputs({ claudeCloud: state || null });
456
+ });
457
+ }
426
458
  });
427
459
  // A cloud session got its id, pushed a branch or reported a new cost: the
428
460
  // conversation's `cloud` field changed, and with it the cloud line.
@@ -430,7 +462,8 @@ export async function connectSocket(overrideDeps) {
430
462
  if (applyClaudeCloudSessionEvent(payload || null)) deps?.syncChatTitleControls?.();
431
463
  });
432
464
  socket.on('grok_settings_updated', (payload) => {
433
- deps?.applyGrokSettingsState?.(payload || {});
465
+ const state = deps?.applyGrokSettingsState?.(payload || {});
466
+ updateProviderReadinessInputs({ grok: state || payload || null });
434
467
  if (Number(payload?.reconciliation?.updatedUnstartedConversations || 0) > 0) {
435
468
  void Promise.resolve()
436
469
  .then(() => deps?.refreshCurrentView?.())
@@ -438,7 +471,8 @@ export async function connectSocket(overrideDeps) {
438
471
  }
439
472
  });
440
473
  socket.on('cursor_settings_updated', (payload) => {
441
- deps?.applyCursorSettingsState?.(payload || {});
474
+ const state = deps?.applyCursorSettingsState?.(payload || {});
475
+ updateProviderReadinessInputs({ cursor: state || payload || null });
442
476
  if (Number(payload?.reconciliation?.updatedUnstartedConversations || 0) > 0) {
443
477
  void Promise.resolve()
444
478
  .then(() => deps?.refreshCurrentView?.())
@@ -485,12 +519,19 @@ export async function connectSocket(overrideDeps) {
485
519
  })) {
486
520
  return;
487
521
  }
488
- appendMessage(normalizedMessage, true, messageId);
522
+ // Another client's message (an agent's prompt included) follows the
523
+ // end like any live row: a reader scrolled up stays where they are,
524
+ // and the jump-to-bottom badge counts it.
525
+ appendMessage(normalizedMessage, followsTranscriptEnd(), messageId);
526
+ noteTranscriptLiveRow();
489
527
  }
490
528
  });
491
529
  socket.on('assistant_message', ({ conversationId, message, messageId, sourceMessageId }) => {
492
530
  const isCurrentConversation = conversationId === currentConvId;
493
- const autoScroll = isCurrentConversation ? isMessagesAtBottom() : false;
531
+ const autoScroll = isCurrentConversation ? followsTranscriptEnd() : false;
532
+ // A reader who is not at the end keeps what they read where it is while
533
+ // the reply takes the live bubble's place.
534
+ const reading = isCurrentConversation && !autoScroll ? captureLiveReplyReadingPosition() : null;
494
535
  // A background conversation finishing its turn must not wipe the viewed
495
536
  // conversation's live bubble.
496
537
  if (isCurrentConversation) {
@@ -509,9 +550,14 @@ export async function connectSocket(overrideDeps) {
509
550
  message.thoughts = mergedThoughts;
510
551
  }
511
552
  }
512
- if (messageId && seenMessageIds?.has(messageId)) return;
553
+ if (messageId && seenMessageIds?.has(messageId)) {
554
+ restoreLiveReplyReadingPosition(reading);
555
+ return;
556
+ }
513
557
  if (isCurrentConversation) {
514
558
  appendMessage(message, autoScroll, messageId || null, false, sourceMessageId || null);
559
+ restoreLiveReplyReadingPosition(reading);
560
+ noteTranscriptLiveRow();
515
561
  scheduleContextUsageRefresh(conversationId, 120);
516
562
  }
517
563
  if (sourceMessageId) relayActivities.delete(sourceMessageId);
@@ -530,8 +576,9 @@ export async function connectSocket(overrideDeps) {
530
576
  socket.on('message_updated', ({ conversationId, messageId, message }) => {
531
577
  if (!messageId || conversationId !== currentConvId || !message) return;
532
578
  if (!replaceRenderedMessage(message, messageId)) {
533
- appendMessage(message, isMessagesAtBottom(), messageId, false);
579
+ appendMessage(message, followsTranscriptEnd(), messageId, false);
534
580
  }
581
+ noteTranscriptLiveRow();
535
582
  });
536
583
  socket.on('relay_question', ({ question }) => upsertRelayQuestion(question));
537
584
  socket.on('relay_question_updated', ({ question }) => upsertRelayQuestion(question));
@@ -543,19 +590,55 @@ export async function connectSocket(overrideDeps) {
543
590
  socket.on('relay_board_changed', () => {
544
591
  loadRelayBoards();
545
592
  });
593
+ // Relay event subscriptions: the whole active list, after every change.
594
+ socket.on('relay_event_subscriptions', ({ subscriptions }) => {
595
+ applyRelayEventSubscriptions(subscriptions);
596
+ });
546
597
  socket.on('background_tasks', ({ conversationId, tasks }) => {
547
598
  setConversationBackgroundTasks(conversationId, tasks);
599
+ // The sidebar's row (and its group's head) count background work as
600
+ // running; the set arrives under the conversation or its session id.
601
+ const id = String(conversationId || '').trim();
602
+ const record = conversations[id]
603
+ || Object.values(conversations).find((conversation) => String(conversation?.sdkSessionId || '') === id)
604
+ || null;
605
+ if (record && applyBackgroundTasksToConversation(record, tasks)) renderConvList();
548
606
  });
549
607
  // A message was pinned or unpinned (any client): the conversation's whole
550
608
  // pin list, replacing what this page had.
551
609
  socket.on('conversation_pins_updated', ({ conversationId, pins, revision }) => {
552
610
  setConversationPins(conversationId, pins, revision);
553
611
  });
612
+ // A message was hidden from or shown to shared viewers (any client): the
613
+ // conversation's whole hidden list, replacing what this page had.
614
+ socket.on('conversation_hidden_messages_updated', ({ conversationId, hiddenMessages, revision }) => {
615
+ setConversationHiddenMessages(conversationId, hiddenMessages, revision);
616
+ });
554
617
  // REPLACE semantics for the whole set: the preview registry is relay-owned
555
618
  // and global, so every change ships the full list.
556
619
  socket.on('previews', ({ previews }) => {
557
620
  setPreviews(previews);
558
621
  });
622
+ // Public shares: the events name the conversation only; the list (Settings
623
+ // → Sharing, the ⋯ menu's Stop sharing) is read again.
624
+ for (const event of ['share_created', 'share_renewed', 'share_revoked']) {
625
+ socket.on(event, () => {
626
+ void refreshShares().catch(() => {});
627
+ });
628
+ }
629
+ // A deleted conversation takes its share along.
630
+ socket.on('conversation_deleted', ({ conversationId } = {}) => {
631
+ if (hasActiveShare(conversationId)) void refreshShares().catch(() => {});
632
+ });
633
+ socket.on('share_opened', ({ conversationId } = {}) => {
634
+ handleShareOpened(conversationId);
635
+ });
636
+ socket.on('share_settings_changed', (payload) => {
637
+ applyShareSettings(payload || null);
638
+ });
639
+ socket.on('share_viewer_ips_forgotten', () => {
640
+ handleViewerIpsForgotten();
641
+ });
559
642
  // REPLACE semantics too: the relay ships the whole remote-relay list (no
560
643
  // tokens) whenever a remote is added, removed, renamed or changes status.
561
644
  socket.on(REMOTE_RELAY_SOCKET_EVENT, (payload) => {
@@ -580,16 +663,17 @@ export async function connectSocket(overrideDeps) {
580
663
  const last = items[items.length - 1];
581
664
  const lastText = typeof last === 'string' ? last : String(last?.text || '');
582
665
  const lastSubagentRunId = typeof last === 'object' && last ? (last.subagentRunId || null) : null;
583
- if (lastText !== entry.text || lastSubagentRunId !== entry.subagentRunId) {
584
- relayActivities.set(messageId, capLiveActivityEntries(items.concat(entry)));
585
- }
666
+ // The same line again (a repeated event) adds nothing.
667
+ const added = lastText !== entry.text || lastSubagentRunId !== entry.subagentRunId;
668
+ if (added) relayActivities.set(messageId, capLiveActivityEntries(items.concat(entry)));
586
669
  if (entry.subagentRunId) {
587
670
  upsertSubagentRun({ subagentRunId: entry.subagentRunId, messageId, conversationId });
588
671
  addSubagentActivity(entry.subagentRunId, entry.text);
589
672
  }
590
673
  if (conversationId === currentConvId) {
591
- const autoScroll = isMessagesAtBottom();
674
+ const autoScroll = followsTranscriptEnd();
592
675
  appendThinkingActivity(entry, entry.subagentRunId, autoScroll);
676
+ if (added && !autoScroll) noteTranscriptNews();
593
677
  }
594
678
  });
595
679
  socket.on('relay_stream', ({ conversationId, messageId, text, done, seq, subagentRunId }) => {
@@ -598,8 +682,8 @@ export async function connectSocket(overrideDeps) {
598
682
  upsertSubagentRun({ subagentRunId: String(subagentRunId).trim(), messageId, conversationId });
599
683
  }
600
684
  if (conversationId !== currentConvId) return;
601
- const autoScroll = isMessagesAtBottom();
602
- applyRelayStreamEvent({
685
+ const autoScroll = followsTranscriptEnd();
686
+ const accepted = applyRelayStreamEvent({
603
687
  messageId,
604
688
  text: String(text || ''),
605
689
  done: !!done,
@@ -607,11 +691,16 @@ export async function connectSocket(overrideDeps) {
607
691
  subagentRunId: subagentRunId ? String(subagentRunId).trim() : null,
608
692
  autoScroll,
609
693
  });
694
+ // A frame that was dropped (out of order, a finished turn) is no news.
695
+ if (accepted && !autoScroll) noteTranscriptNews();
610
696
  });
611
697
  socket.on('relay_thought', ({ conversationId, messageId, reasoningId, text, done, subagentRunId }) => {
612
698
  if (!messageId) return;
613
699
  const key = String(reasoningId || 'reasoning');
614
700
  const thoughtMap = relayThoughts.get(messageId) || new Map();
701
+ const known = thoughtMap.get(key);
702
+ // The same thought again (a repeated event) adds nothing.
703
+ const added = !known || known.text !== String(text || '') || known.done !== !!done;
615
704
  thoughtMap.set(key, { reasoningId: key, text: String(text || ''), done: !!done, subagentRunId: subagentRunId || null });
616
705
  relayThoughts.set(messageId, thoughtMap);
617
706
  if (subagentRunId) {
@@ -619,28 +708,33 @@ export async function connectSocket(overrideDeps) {
619
708
  addSubagentThought(subagentRunId, { reasoningId: key, text: String(text || ''), done: !!done });
620
709
  }
621
710
  if (conversationId === currentConvId) {
622
- const autoScroll = isMessagesAtBottom();
711
+ const autoScroll = followsTranscriptEnd();
623
712
  appendThinkingThought(key, String(text || ''), !!done, subagentRunId, autoScroll);
713
+ if (added && !autoScroll) noteTranscriptNews();
624
714
  }
625
715
  });
626
- socket.on('subagent_status', ({ conversationId, messageId, subagentRunId, parentSubagentId, displayName, status, timestamp, stopUnsupported }) => {
627
- if (!messageId || !subagentRunId) return;
628
- upsertSubagentRun({
629
- subagentRunId,
630
- messageId,
631
- conversationId,
632
- parentSubagentId,
633
- displayName,
634
- status,
635
- timestamp,
636
- });
716
+ socket.on('subagent_status', (payload = {}) => {
717
+ const { conversationId, subagentRunId, status, stopUnsupported } = payload;
718
+ if (!subagentRunId) return;
719
+ // An event without its turn is placed on the run the store already
720
+ // knows; one for an unknown run without a turn cannot be placed.
721
+ const messageId = payload.messageId || getSubagentRun(subagentRunId)?.messageId || null;
722
+ if (!messageId) return;
723
+ upsertSubagentRun({ ...payload, messageId });
637
724
  clearSubagentCancelInFlight(subagentRunId);
638
725
  // The provider answered "not supported": pin the state so the button does
639
726
  // not re-arm into an endless click-and-fail loop.
640
727
  if (stopUnsupported) markSubagentStopUnsupported(subagentRunId);
641
728
  if (conversationId === currentConvId) {
642
729
  updateSubagentBubbleFromStatus(subagentRunId, status);
730
+ refreshSubagentRows(subagentRunId);
643
731
  }
732
+ handleSubagentStatusEventForViewer({ ...payload, messageId });
733
+ });
734
+ // Entries of a sub-agent's record; the relay sends them only while this
735
+ // page's viewer watches that run.
736
+ socket.on('subagent_timeline', (payload) => {
737
+ handleSubagentTimelineEvent(payload);
644
738
  });
645
739
  socket.on('conversation_compacted', async ({ sourceConversationId, targetConversationId }) => {
646
740
  if (!sourceConversationId || !targetConversationId) return;
@@ -733,6 +827,9 @@ export async function connectSocket(overrideDeps) {
733
827
  applyConversationTurnStatus({ conversationId, messageId, status });
734
828
  if (conversationId && conversations[conversationId]) {
735
829
  const conversation = conversations[conversationId];
830
+ // The row's turn fields follow the event, open or not: the dots, a
831
+ // group head's pill and its fold read them between list fetches.
832
+ applyTurnStatusToConversation(conversation, normalizedStatus);
736
833
  if (normalizedStatus === 'processing') {
737
834
  conversation.localTurnStatus = 'processing';
738
835
  conversation.localTurnStatusUpdatedAt = Date.now();
@@ -748,7 +845,7 @@ export async function connectSocket(overrideDeps) {
748
845
  }
749
846
  }
750
847
  if (conversationId === currentConvId && normalizedStatus === 'processing') {
751
- const autoScroll = isMessagesAtBottom();
848
+ const autoScroll = followsTranscriptEnd();
752
849
  showThinking(messageId || null, autoScroll);
753
850
  renderThinkingActivities();
754
851
  renderThinkingThoughts();
@@ -784,6 +881,12 @@ export async function connectSocket(overrideDeps) {
784
881
  }
785
882
  renderConvList();
786
883
  });
884
+ // A session group changed (a member joined or left, a name, a dissolve,
885
+ // here or from another device): every loaded page is fetched again with
886
+ // its groups, so a row far down the list changes too.
887
+ socket.on('conversation_group_changed', (payload) => {
888
+ applyConversationGroupEvent(payload);
889
+ });
787
890
  socket.on('conversation_archived', ({ conversationId, archived }) => {
788
891
  const id = String(conversationId || '').trim();
789
892
  if (!id) return;
@@ -811,13 +914,7 @@ export async function connectSocket(overrideDeps) {
811
914
  renderRelayBoards();
812
915
  renderConvList();
813
916
  if (currentConvId === conversationId) {
814
- setCurrentConv(null);
815
- renderMessages([]);
816
- document.getElementById('chat-title').textContent = 'Select or start a conversation';
817
- syncChatTitleControls();
818
- updateSessionPill(null, null);
819
- updateCompactButton();
820
- scheduleContextUsageRefresh(null);
917
+ clearOpenConversation();
821
918
  } else {
822
919
  updateCompactButton();
823
920
  }
@@ -5,6 +5,7 @@ import { readRepoBrowserPreferences } from './repo-browser-preferences.mjs';
5
5
  import { isChatInteractionHeld } from './selection-guard.mjs';
6
6
  import { nextSubagentStatus } from './subagent-fold.mjs';
7
7
  import { useStrictStrikethrough } from './markdown-strikethrough.mjs';
8
+ import { parseGroupFoldChoices, pruneGroupFoldChoices } from './conversation-group-state.mjs';
8
9
 
9
10
  function resolveAppBase() {
10
11
  const configuredBase = typeof window.__COPILOT_APP_CONFIG?.basePath === 'string'
@@ -16,6 +17,8 @@ function resolveAppBase() {
16
17
  const pathname = String(window.location.pathname || '/');
17
18
  const sharedIndex = pathname.indexOf('/shared/');
18
19
  if (sharedIndex >= 0) return pathname.slice(0, sharedIndex).replace(/\/+$/, '');
20
+ const previewIndex = pathname.indexOf('/share-preview/');
21
+ if (previewIndex >= 0) return pathname.slice(0, previewIndex).replace(/\/+$/, '');
19
22
  return pathname.replace(/\/+$/, '');
20
23
  }
21
24
 
@@ -32,8 +35,24 @@ function resolveSharedConversationToken() {
32
35
  if (configured) return configured;
33
36
  return extractSharedTokenFromPathname(window.location.pathname);
34
37
  }
35
- export const SHARED_CONVERSATION_TOKEN = resolveSharedConversationToken();
36
- export const IS_SHARED_VIEW = !!SHARED_CONVERSATION_TOKEN;
38
+ // "Preview as shared": the owner reads a conversation as a share link would
39
+ // serve it (marked redactions), through the owner-only preview routes. It is a
40
+ // shared view in every other respect.
41
+ function resolveSharePreviewConversationId() {
42
+ const configured = String(window.__COPILOT_APP_CONFIG?.sharePreviewConversationId || '').trim();
43
+ if (configured) return configured;
44
+ const match = String(window.location.pathname || '/').match(/\/share-preview\/([^/?#]+)\/?$/i);
45
+ if (!match) return '';
46
+ try {
47
+ return decodeURIComponent(String(match[1] || '')).trim();
48
+ } catch {
49
+ return '';
50
+ }
51
+ }
52
+ export const SHARE_PREVIEW_CONVERSATION_ID = resolveSharePreviewConversationId();
53
+ export const IS_SHARE_PREVIEW = !!SHARE_PREVIEW_CONVERSATION_ID;
54
+ export const SHARED_CONVERSATION_TOKEN = IS_SHARE_PREVIEW ? '' : resolveSharedConversationToken();
55
+ export const IS_SHARED_VIEW = !!SHARED_CONVERSATION_TOKEN || IS_SHARE_PREVIEW;
37
56
  export let TOKEN = '';
38
57
  export let socket = null;
39
58
  export let currentConvId = null;
@@ -82,6 +101,9 @@ export let updateState = null;
82
101
  export let activeRuntimeSessionCount = 0;
83
102
  export let runtimeSessionBindingCount = 0;
84
103
  export let conversations = {};
104
+ // Session groups by id, as the list payload describes them (`groups`):
105
+ // name, display name, head and member counts. Merged with every page.
106
+ export const conversationGroups = {};
85
107
  export let selectedAttachments = [];
86
108
  export let imageEditTarget = null;
87
109
  export const RELAY_QUESTION_POLL_MS = 3000;
@@ -172,6 +194,8 @@ const SIDEBAR_WIDTH_PERCENT_FALLBACK = 24;
172
194
  const SIDEBAR_WIDTH_PORTRAIT_STORAGE_KEY = 'copilot_sidebar_width_pct_portrait';
173
195
  const SIDEBAR_WIDTH_LANDSCAPE_STORAGE_KEY = 'copilot_sidebar_width_pct_landscape';
174
196
  const SIDEBAR_COLLAPSED_DESKTOP_STORAGE_KEY = 'copilot_sidebar_collapsed_desktop';
197
+ // The user's fold choices for session groups in the sidebar: { [groupId]: 'open' | 'folded' }.
198
+ const CONVERSATION_GROUP_FOLDS_STORAGE_KEY = 'copilot_conv_group_folds';
175
199
  let sidebarWidthPercent = SIDEBAR_WIDTH_PERCENT_FALLBACK;
176
200
  const CONVERSATION_SCROLL_STORAGE_PREFIX = 'copilot_message_scroll_';
177
201
 
@@ -197,6 +221,59 @@ export function setCurrentConv(id) {
197
221
  updateCompactButton();
198
222
  }
199
223
 
224
+ /** Merges a list payload's `groups` (by id) into the known session groups. */
225
+ export function mergeConversationGroups(groups) {
226
+ if (!groups || typeof groups !== 'object') return;
227
+ for (const [groupId, group] of Object.entries(groups)) {
228
+ const id = String(groupId || '').trim();
229
+ if (!id || !group || typeof group !== 'object') continue;
230
+ conversationGroups[id] = { ...(conversationGroups[id] || {}), ...group, id };
231
+ }
232
+ }
233
+
234
+ /** Forgets a session group the relay no longer lists. */
235
+ export function forgetConversationGroup(groupId) {
236
+ delete conversationGroups[String(groupId || '').trim()];
237
+ }
238
+
239
+ /**
240
+ * Keeps only the groups that a loaded row still belongs to: after a list
241
+ * refresh a dissolved group has no row left, and the picker must not offer
242
+ * it. Returns the ids dropped.
243
+ */
244
+ export function pruneConversationGroups(keepGroupIds) {
245
+ const keep = new Set((Array.isArray(keepGroupIds) ? keepGroupIds : []).map((id) => String(id || '').trim()).filter(Boolean));
246
+ const dropped = [];
247
+ for (const id of Object.keys(conversationGroups)) {
248
+ if (keep.has(id)) continue;
249
+ delete conversationGroups[id];
250
+ dropped.push(id);
251
+ }
252
+ return dropped;
253
+ }
254
+
255
+ export function readConversationGroupFoldChoices() {
256
+ return parseGroupFoldChoices(localStorage.getItem(CONVERSATION_GROUP_FOLDS_STORAGE_KEY) || '');
257
+ }
258
+
259
+ // The stored fold choices are trimmed to the loaded groups only past this
260
+ // size: a choice for a group on an older page or in the other view must
261
+ // not go with every toggle.
262
+ const CONVERSATION_GROUP_FOLDS_MAX = 300;
263
+
264
+ /** Stores the user's fold choice for a group. */
265
+ export function setConversationGroupFoldChoice(groupId, choice) {
266
+ const id = String(groupId || '').trim();
267
+ if (!id) return;
268
+ let next = readConversationGroupFoldChoices();
269
+ if (Object.keys(next).length > CONVERSATION_GROUP_FOLDS_MAX) {
270
+ next = pruneGroupFoldChoices(next, [...Object.keys(conversationGroups), id]);
271
+ }
272
+ if (choice === 'open' || choice === 'folded') next[id] = choice;
273
+ else delete next[id];
274
+ localStorage.setItem(CONVERSATION_GROUP_FOLDS_STORAGE_KEY, JSON.stringify(next));
275
+ }
276
+
200
277
  export function setConversationWatcherCount(conversationId, count) {
201
278
  const id = String(conversationId || '').trim();
202
279
  if (!id) return;
@@ -612,37 +689,18 @@ export function getMessagesDistanceFromBottom() {
612
689
  return Math.max(0, el.scrollHeight - el.clientHeight - el.scrollTop);
613
690
  }
614
691
 
615
- function resolveMessagesNearBottomThreshold(el, thresholdPx = null) {
616
- if (Number.isFinite(Number(thresholdPx)) && Number(thresholdPx) >= 0) {
617
- return Math.trunc(Number(thresholdPx));
618
- }
619
- return resolveNearBottomThresholdPx(el?.clientHeight);
620
- }
621
-
622
- export function isMessagesNearBottom(thresholdPx = null) {
623
- const el = document.getElementById('messages');
624
- if (!el) return true;
625
- const distance = Math.max(0, el.scrollHeight - el.clientHeight - el.scrollTop);
626
- const threshold = resolveMessagesNearBottomThreshold(el, thresholdPx);
627
- return distance <= threshold;
628
- }
629
-
630
- export function isMessagesAtBottom(epsilonPx = 1) {
692
+ /**
693
+ * "At the end" of the transcript, wherever that is decided: within the
694
+ * near-bottom margin (8% of the viewport, between 12 and 48 px). One margin
695
+ * and no parameter: a caller with a margin of its own (there was one with
696
+ * 80 px, one with 1 px, and a default that read as 0 px) breaks the rule that
697
+ * the transcript follows a reader who is at the end and nobody else.
698
+ */
699
+ export function isMessagesNearBottom() {
631
700
  const el = document.getElementById('messages');
632
701
  if (!el) return true;
633
- const epsilon = Math.max(0, Number.isFinite(Number(epsilonPx)) ? Number(epsilonPx) : 1);
634
702
  const distance = Math.max(0, el.scrollHeight - el.clientHeight - el.scrollTop);
635
- return distance <= epsilon;
636
- }
637
-
638
- export function scrollBottomAfterSend() {
639
- scrollBottom();
640
- requestAnimationFrame(() => {
641
- scrollBottom();
642
- });
643
- setTimeout(() => {
644
- scrollBottom();
645
- }, 120);
703
+ return distance <= resolveNearBottomThresholdPx(el.clientHeight);
646
704
  }
647
705
 
648
706
  export function isMobileComposerViewport() {
@@ -944,8 +1002,16 @@ function buildSessionWorkerStateHash(map) {
944
1002
 
945
1003
  let sessionWorkerStateHash = '';
946
1004
 
1005
+ // Whether a status poll has answered yet: before that, a session's reported
1006
+ // background work is taken as it is; after it, only a live worker carries it.
1007
+ let sessionWorkerStatusKnown = false;
1008
+ export function hasSessionWorkerStatus() {
1009
+ return sessionWorkerStatusKnown;
1010
+ }
1011
+
947
1012
  export function setSessionWorkerStatesFromStatusPayload(payload) {
948
1013
  const workers = Array.isArray(payload?.workers) ? payload.workers : [];
1014
+ sessionWorkerStatusKnown = true;
949
1015
  const next = new Map();
950
1016
  for (const worker of workers) {
951
1017
  const normalized = normalizeWorkerStateEntry(worker);
@@ -1498,6 +1564,17 @@ export function isSubagentCancelInFlight(subagentRunId) {
1498
1564
  return id ? subagentCancelInFlight.has(id) : false;
1499
1565
  }
1500
1566
 
1567
+ const SUBAGENT_HEADER_FIELDS = ['completedAt', 'model', 'modelInherited', 'subagentType', 'taskId', 'totalTokens', 'background', 'truncated', 'stoppable'];
1568
+
1569
+ function subagentHeaderFields(payload, existing) {
1570
+ const fields = {};
1571
+ for (const name of SUBAGENT_HEADER_FIELDS) {
1572
+ const value = payload?.[name];
1573
+ fields[name] = value === undefined ? (existing?.[name] ?? null) : value;
1574
+ }
1575
+ return fields;
1576
+ }
1577
+
1501
1578
  export function upsertSubagentRun(payload) {
1502
1579
  const subagentRunId = normalizeSubagentRunId(payload?.subagentRunId);
1503
1580
  if (!subagentRunId) return null;
@@ -1511,8 +1588,14 @@ export function upsertSubagentRun(payload) {
1511
1588
  parentSubagentId: normalizeSubagentRunId(payload?.parentSubagentId) || existing?.parentSubagentId || null,
1512
1589
  displayName: String(payload?.displayName || existing?.displayName || '').trim() || null,
1513
1590
  status: nextSubagentStatus({ reported: payload?.status, known: existing?.status, normalize: normalizeSubagentStatus }),
1514
- startedAt: existing?.startedAt || payload?.timestamp || now,
1515
- updatedAt: payload?.timestamp || now,
1591
+ startedAt: payload?.startedAt || existing?.startedAt || payload?.timestamp || now,
1592
+ updatedAt: payload?.updatedAt || payload?.timestamp || now,
1593
+ // The run header (relay 0009): what the sub-agent rows and the viewer
1594
+ // show. An event that does not name a field keeps the known value.
1595
+ ...subagentHeaderFields(payload, existing),
1596
+ // Whether `status` came from the relay rather than the default a live
1597
+ // lane's first frame starts with.
1598
+ statusReported: Boolean(String(payload?.status || '').trim()) || existing?.statusReported === true,
1516
1599
  activities: existing?.activities || [],
1517
1600
  thoughts: existing?.thoughts || [],
1518
1601
  // Cumulative streamed text for this run, replayed when its bubble is