@oar-sh/oar 0.9.10 → 0.9.12

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 (135) hide show
  1. package/CHANGELOG.md +440 -1
  2. package/README.md +156 -972
  3. package/package.json +13 -13
  4. package/server/README.md +112 -25
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
  7. package/server/claude-worker/claude-session-process.mjs +217 -11
  8. package/server/claude-worker/claude-session-worker.mjs +26 -1
  9. package/server/claude-worker/task-output-discovery.mjs +126 -0
  10. package/server/copilot-worker/copilot-sdk-session-process.mjs +235 -14
  11. package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -0
  12. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  13. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  14. package/server/db-schema.mjs +68 -2
  15. package/server/grok-worker/acp-client.mjs +2 -1
  16. package/server/grok-worker/acp-host-services.mjs +46 -6
  17. package/server/grok-worker/grok-background-tasks.mjs +241 -0
  18. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  19. package/server/grok-worker/grok-session-worker.mjs +33 -1
  20. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  21. package/server/migrations/0013-previews.mjs +130 -0
  22. package/server/migrations/0014-conversation-groups.mjs +89 -0
  23. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  24. package/server/migrations/0016-share-access-log.mjs +110 -0
  25. package/server/migrations/0017-conversation-history-indexes.mjs +89 -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 +40 -101
  30. package/server/public/app/api-client.js +264 -16
  31. package/server/public/app/ask-user-view.js +17 -15
  32. package/server/public/app/background-tasks-view.mjs +285 -16
  33. package/server/public/app/bootstrap.js +253 -37
  34. package/server/public/app/chat-actions-menu-state.mjs +20 -10
  35. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  36. package/server/public/app/context-menu.mjs +90 -6
  37. package/server/public/app/conversation-group-drag.mjs +191 -0
  38. package/server/public/app/conversation-group-order.mjs +265 -0
  39. package/server/public/app/conversation-group-state.mjs +100 -0
  40. package/server/public/app/conversation-list-drag.mjs +449 -0
  41. package/server/public/app/conversation-payload-cache.mjs +357 -0
  42. package/server/public/app/conversation-turn-state.mjs +81 -0
  43. package/server/public/app/conversation-view.js +567 -94
  44. package/server/public/app/conversation-window-retention.mjs +96 -0
  45. package/server/public/app/cwd-picker.js +1 -1
  46. package/server/public/app/hidden-messages-view.mjs +312 -0
  47. package/server/public/app/host-suspend-ui.js +7 -1
  48. package/server/public/app/journal-view.js +1033 -82
  49. package/server/public/app/preview-cards.mjs +117 -31
  50. package/server/public/app/preview-command.mjs +10 -1
  51. package/server/public/app/relay-board-view.js +19 -6
  52. package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
  53. package/server/public/app/relay-restart-error.mjs +16 -0
  54. package/server/public/app/settings-modal.js +150 -10
  55. package/server/public/app/settings-tabs.js +1 -1
  56. package/server/public/app/share-format.mjs +49 -0
  57. package/server/public/app/share-preview-marks.mjs +167 -0
  58. package/server/public/app/share-state.js +109 -0
  59. package/server/public/app/shared-conversations-settings.js +480 -0
  60. package/server/public/app/socket-handlers.js +138 -20
  61. package/server/public/app/store.js +121 -30
  62. package/server/public/app/subagent-row.mjs +3 -3
  63. package/server/public/app/subagent-viewer.mjs +37 -7
  64. package/server/public/app/task-console-render.mjs +237 -0
  65. package/server/public/app/task-console-watch.mjs +42 -0
  66. package/server/public/app/task-console.mjs +630 -0
  67. package/server/public/app/task-output-text.mjs +234 -0
  68. package/server/public/app/thinking-follow.js +0 -4
  69. package/server/public/app/transcript-island.mjs +73 -0
  70. package/server/public/app/transcript-jump-bottom-view.mjs +230 -26
  71. package/server/public/app/transcript-jump-bottom.mjs +170 -22
  72. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  73. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  74. package/server/public/app/transcript-viewport-anchor.mjs +159 -0
  75. package/server/public/index.html +625 -21
  76. package/server/relay-tools.md +3 -1
  77. package/server/relay.mjs +2 -1
  78. package/server/repositories/conversation-group-repository.mjs +444 -0
  79. package/server/repositories/message-repository.mjs +9 -0
  80. package/server/repositories/question-repository.mjs +4 -0
  81. package/server/repositories/session-repository.mjs +12 -37
  82. package/server/repositories/share-repository.mjs +322 -0
  83. package/server/repositories/share-visibility-statements.mjs +10 -0
  84. package/server/routes/about-routes.mjs +15 -0
  85. package/server/routes/cache-routes.mjs +4 -0
  86. package/server/routes/conversation-group-routes.mjs +171 -0
  87. package/server/routes/messages-routes.mjs +433 -68
  88. package/server/routes/preview-routes.mjs +9 -1
  89. package/server/routes/sessions-routes.mjs +901 -253
  90. package/server/server-runtime.mjs +392 -99
  91. package/server/services/about-service.mjs +461 -0
  92. package/server/services/background-task-store.mjs +365 -0
  93. package/server/services/background-work-view.mjs +228 -0
  94. package/server/services/cache-rebuild-service.mjs +30 -0
  95. package/server/services/cli-process-runner.mjs +4 -21
  96. package/server/services/conversation-history-page.mjs +199 -0
  97. package/server/services/delete-archive-service.mjs +4 -1
  98. package/server/services/detached-worker-watch-service.mjs +150 -0
  99. package/server/services/hidden-message-list.mjs +21 -0
  100. package/server/services/host-suspend-command.mjs +2 -1
  101. package/server/services/message-pin-list.mjs +1 -1
  102. package/server/services/preview-health-probe.mjs +8 -1
  103. package/server/services/preview-proxy-server.mjs +8 -4
  104. package/server/services/preview-registry-service.mjs +676 -26
  105. package/server/services/preview-static-handler.mjs +40 -7
  106. package/server/services/preview-store.mjs +274 -0
  107. package/server/services/rate-limit-service.mjs +12 -1
  108. package/server/services/relay-auth.mjs +183 -0
  109. package/server/services/relay-cli-launcher-service.mjs +3 -1
  110. package/server/services/relay-event-service.mjs +17 -0
  111. package/server/services/relay-prompt-sanitizer.mjs +11 -4
  112. package/server/services/remote-relay-dispatcher.mjs +187 -7
  113. package/server/services/request-boundary.mjs +183 -0
  114. package/server/services/session-activity-tracker.mjs +231 -19
  115. package/server/services/session-worker-launch-service.mjs +190 -6
  116. package/server/services/session-worker-process-service.mjs +10 -6
  117. package/server/services/share-access-log-service.mjs +123 -0
  118. package/server/services/share-path-redaction.mjs +424 -0
  119. package/server/services/status-event-service.mjs +7 -3
  120. package/server/services/task-output-rooms.mjs +259 -0
  121. package/server/services/task-output-service.mjs +562 -0
  122. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  123. package/server/services/windows-autostart-service.mjs +6 -1
  124. package/server/services/windows-boot-autostart-service.mjs +207 -23
  125. package/shared/claude-background-command-limit.mjs +83 -0
  126. package/shared/preview-tool-core.mjs +15 -5
  127. package/shared/remote-relay-contract.mjs +35 -3
  128. package/shared/share-path-privacy.mjs +30 -0
  129. package/shared/task-output-text.mjs +737 -0
  130. package/shared/windows-system-tools.mjs +106 -0
  131. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  132. package/shared/worker-runtime/heartbeat.mjs +24 -0
  133. package/shared/worker-runtime/kept-posts.mjs +83 -0
  134. package/shared/worker-runtime/task-output-spool.mjs +181 -0
  135. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -20,7 +20,6 @@ import {
20
20
  updateCompactButton,
21
21
  clearPendingUserMessage,
22
22
  hasPendingUserMessageDuplicate,
23
- isMessagesAtBottom,
24
23
  upsertSubagentRun,
25
24
  getSubagentRun,
26
25
  addSubagentActivity,
@@ -31,11 +30,13 @@ import {
31
30
  applySessionWorkerSteering,
32
31
  refreshSummaryModal,
33
32
  summaryModalState,
33
+ IS_SHARED_VIEW,
34
34
  } from './store.js';
35
35
  import { scheduleContextUsageRefresh } from './api-client.js';
36
36
  import { capLiveActivityEntries } from './activity-replay-state.mjs';
37
37
  import { publishStatusEvent, recordStatusEvent } from './status-store.mjs';
38
- import { clearOpenConversation, renderConvList, refreshConversations, openConversation } from './journal-view.js';
38
+ import { clearOpenConversation, renderConvList, refreshConversations, scheduleLoadedConversationsRefresh, applyConversationGroupEvent, openConversation } from './journal-view.js';
39
+ import { applyBackgroundTasksToConversation, applyTurnStatusToConversation } from './conversation-turn-state.mjs';
39
40
  import { applyRelayEventSubscriptions, loadRelayEventSubscriptions } from './relay-event-subscriptions-ui.js';
40
41
  import {
41
42
  upsertRelayQuestion,
@@ -43,9 +44,13 @@ import {
43
44
  updatePendingQuestionBanner,
44
45
  } from './ask-user-view.js';
45
46
  import { upsertRelayBoard, loadRelayBoards, renderRelayBoards } from './relay-board-view.js';
46
- import { setConversationBackgroundTasks } from './background-tasks-view.mjs';
47
+ import { setConversationBackgroundTasks, setConversationTaskHealth } from './background-tasks-view.mjs';
48
+ import { handleTaskConsoleReconnect, handleTaskOutputChunk } from './task-console.mjs';
47
49
  import { setConversationPins } from './pinned-messages-view.mjs';
50
+ import { setConversationHiddenMessages } from './hidden-messages-view.mjs';
48
51
  import { setPreviews } from './preview-cards.mjs';
52
+ import { applyShareSettings, hasActiveShare, refreshShares } from './share-state.js';
53
+ import { handleShareOpened, handleViewerIpsForgotten } from './shared-conversations-settings.js';
49
54
  import { setRemoteRelaysSnapshot } from './remote-relays-store.mjs';
50
55
  import { REMOTE_RELAY_SOCKET_EVENT } from './remote-relay-shared.mjs';
51
56
  import { AGENT_SESSIONS_SOCKET_EVENT } from './agent-sessions-settings.mjs';
@@ -74,10 +79,14 @@ import {
74
79
  applyConversationTurnStatus,
75
80
  renderMessages,
76
81
  appendMessage,
82
+ captureLiveReplyReadingPosition,
83
+ restoreLiveReplyReadingPosition,
77
84
  replaceRenderedMessage,
78
85
  noteTranscriptLiveRow,
79
86
  applyIncomingConversationDraftUpdate,
80
87
  getRenderedConversationMessageFingerprints,
88
+ holdBackLiveRow,
89
+ isConversationWindowAtTail,
81
90
  clearBubbleCancelState,
82
91
  removeUserBubbleCancelButton,
83
92
  updateSubagentBubbleFromStatus,
@@ -85,11 +94,23 @@ import {
85
94
  syncComposerButtonState,
86
95
  syncCancellableSteerButtons,
87
96
  } from './conversation-view.js';
97
+ import { followsTranscriptEnd, noteTranscriptNews } from './transcript-jump-bottom-view.mjs';
88
98
  import { loadRepoBrowserTree, refreshRepoBrowserIfWorkspaceOpen } from './attachments-view.js';
89
99
  import { clearMessageSearchRuntimeState } from './message-search-view.js';
90
100
  import { stripRelayPromptContext } from './relay-prompt-sanitizer.mjs';
91
101
  import { isLikelyLiveDuplicateMessage } from './live-message-dedupe.mjs';
92
102
  import { mergeRelayThoughts } from './relay-thoughts.mjs';
103
+ import { conversationPayloadCache } from './conversation-payload-cache.mjs';
104
+
105
+ // A live row of the conversation on screen joins its kept window, so a
106
+ // switch away and back still shows it. Rows of other conversations do not:
107
+ // their kept copy is what the reader saw, and the switch back fetches the rest.
108
+ function keepLiveRow(conversationId, message, messageId) {
109
+ if (IS_SHARED_VIEW || !message || typeof message !== 'object') return;
110
+ const id = String(message.id || messageId || '').trim();
111
+ if (!id) return;
112
+ conversationPayloadCache.upsertMessage(conversationId, message.id ? message : { ...message, id });
113
+ }
93
114
  import {
94
115
  handleSubagentStatusEventForViewer,
95
116
  handleSubagentTimelineEvent,
@@ -329,16 +350,26 @@ export async function connectSocket(overrideDeps) {
329
350
  // session, whose restored socket.data the server deliberately resets.
330
351
  emitDeviceVisibility(document.visibilityState === 'visible');
331
352
  clearMessageSearchRuntimeState();
353
+ // Events missed while the socket was down may have changed any of the
354
+ // conversations kept for a quick switch back.
355
+ conversationPayloadCache.clear();
332
356
  setRelayOnline(true);
333
357
  setCliOnline(true);
334
358
  renderConvList();
335
359
  resyncAfterConnect();
360
+ // Every loaded page again, not page one only: events missed while the
361
+ // socket was down touched rows anywhere in the list.
362
+ scheduleLoadedConversationsRefresh();
336
363
  refreshSessionWorkerStatus().catch(() => {});
337
364
  refreshModelCatalog().catch(() => {});
338
365
  // An open sub-agent viewer fetches whatever it missed while away.
339
366
  handleSubagentViewerReconnect();
367
+ // An open task console watches again and reads what it missed.
368
+ void handleTaskConsoleReconnect();
340
369
  // Subscriptions that changed while away.
341
370
  void loadRelayEventSubscriptions();
371
+ // Shares made or stopped while away.
372
+ void refreshShares().catch(() => {});
342
373
  });
343
374
  socket.on('connect_error', (e) => {
344
375
  setRelayOnline(false);
@@ -508,16 +539,23 @@ export async function connectSocket(overrideDeps) {
508
539
  })) {
509
540
  return;
510
541
  }
542
+ // A window that ends above the conversation's end takes no live row:
543
+ // the newer pages bring it, and the jump-to-bottom badge counts it.
544
+ if (holdBackLiveRow(conversationId, messageId || normalizedMessage.id)) return;
511
545
  // Another client's message (an agent's prompt included) follows the
512
546
  // end like any live row: a reader scrolled up stays where they are,
513
547
  // and the jump-to-bottom badge counts it.
514
- appendMessage(normalizedMessage, isMessagesAtBottom(), messageId);
548
+ appendMessage(normalizedMessage, followsTranscriptEnd(), messageId);
515
549
  noteTranscriptLiveRow();
550
+ keepLiveRow(conversationId, normalizedMessage, messageId);
516
551
  }
517
552
  });
518
553
  socket.on('assistant_message', ({ conversationId, message, messageId, sourceMessageId }) => {
519
554
  const isCurrentConversation = conversationId === currentConvId;
520
- const autoScroll = isCurrentConversation ? isMessagesAtBottom() : false;
555
+ const autoScroll = isCurrentConversation ? followsTranscriptEnd() : false;
556
+ // A reader who is not at the end keeps what they read where it is while
557
+ // the reply takes the live bubble's place.
558
+ const reading = isCurrentConversation && !autoScroll ? captureLiveReplyReadingPosition() : null;
521
559
  // A background conversation finishing its turn must not wipe the viewed
522
560
  // conversation's live bubble.
523
561
  if (isCurrentConversation) {
@@ -536,12 +574,19 @@ export async function connectSocket(overrideDeps) {
536
574
  message.thoughts = mergedThoughts;
537
575
  }
538
576
  }
539
- if (messageId && seenMessageIds?.has(messageId)) return;
540
- if (isCurrentConversation) {
577
+ if (messageId && seenMessageIds?.has(messageId)) {
578
+ restoreLiveReplyReadingPosition(reading);
579
+ return;
580
+ }
581
+ // Held back under a window that ends above the conversation's end, as
582
+ // in user_message.
583
+ if (isCurrentConversation && !holdBackLiveRow(conversationId, messageId || message?.id)) {
541
584
  appendMessage(message, autoScroll, messageId || null, false, sourceMessageId || null);
585
+ restoreLiveReplyReadingPosition(reading);
542
586
  noteTranscriptLiveRow();
543
- scheduleContextUsageRefresh(conversationId, 120);
587
+ keepLiveRow(conversationId, message, messageId);
544
588
  }
589
+ if (isCurrentConversation) scheduleContextUsageRefresh(conversationId, 120);
545
590
  if (sourceMessageId) relayActivities.delete(sourceMessageId);
546
591
  if (sourceMessageId) relayThoughts.delete(sourceMessageId);
547
592
  if (sourceMessageId) clearRelayStreamStateForMessage(sourceMessageId);
@@ -556,11 +601,22 @@ export async function connectSocket(overrideDeps) {
556
601
  // cannot be started): same id, new text or kind. Not on the page yet (the
557
602
  // conversation was opened after it was written): appended like any other.
558
603
  socket.on('message_updated', ({ conversationId, messageId, message }) => {
559
- if (!messageId || conversationId !== currentConvId || !message) return;
604
+ // A row rewritten in place. Another conversation's kept window would show
605
+ // the old one: it is dropped. The window on screen takes the new row as
606
+ // the page does, so a switch away and back keeps both the row and the
607
+ // window around it.
608
+ if (!messageId || conversationId !== currentConvId || !message) {
609
+ conversationPayloadCache.forget(conversationId);
610
+ return;
611
+ }
560
612
  if (!replaceRenderedMessage(message, messageId)) {
561
- appendMessage(message, isMessagesAtBottom(), messageId, false);
613
+ // A row outside a window that ends above the conversation's end: the
614
+ // newer pages bring it as it is now.
615
+ if (!isConversationWindowAtTail()) return;
616
+ appendMessage(message, followsTranscriptEnd(), messageId, false);
562
617
  }
563
618
  noteTranscriptLiveRow();
619
+ keepLiveRow(conversationId, message, messageId);
564
620
  });
565
621
  socket.on('relay_question', ({ question }) => upsertRelayQuestion(question));
566
622
  socket.on('relay_question_updated', ({ question }) => upsertRelayQuestion(question));
@@ -576,19 +632,61 @@ export async function connectSocket(overrideDeps) {
576
632
  socket.on('relay_event_subscriptions', ({ subscriptions }) => {
577
633
  applyRelayEventSubscriptions(subscriptions);
578
634
  });
579
- socket.on('background_tasks', ({ conversationId, tasks }) => {
580
- setConversationBackgroundTasks(conversationId, tasks);
635
+ socket.on('background_tasks', ({ conversationId, tasks, endedTasks }) => {
636
+ setConversationBackgroundTasks(conversationId, tasks, { endedTasks });
637
+ // The sidebar's row (and its group's head) count background work as
638
+ // running; the set arrives under the conversation or its session id.
639
+ const id = String(conversationId || '').trim();
640
+ const record = conversations[id]
641
+ || Object.values(conversations).find((conversation) => String(conversation?.sdkSessionId || '') === id)
642
+ || null;
643
+ if (record && applyBackgroundTasksToConversation(record, tasks)) renderConvList();
644
+ });
645
+ // The relay's health poller: last output time and test-runner counts of
646
+ // shell and Monitor tasks, only when they changed.
647
+ socket.on('background_tasks_health', ({ conversationId, health }) => {
648
+ setConversationTaskHealth(conversationId, health);
649
+ });
650
+ // New bytes of the task the console shows; the relay sends them only while
651
+ // this page's console watches that task.
652
+ socket.on('task_output_chunk', (payload) => {
653
+ handleTaskOutputChunk(payload);
581
654
  });
582
655
  // A message was pinned or unpinned (any client): the conversation's whole
583
656
  // pin list, replacing what this page had.
584
657
  socket.on('conversation_pins_updated', ({ conversationId, pins, revision }) => {
585
658
  setConversationPins(conversationId, pins, revision);
586
659
  });
660
+ // A message was hidden from or shown to shared viewers (any client): the
661
+ // conversation's whole hidden list, replacing what this page had.
662
+ socket.on('conversation_hidden_messages_updated', ({ conversationId, hiddenMessages, revision }) => {
663
+ setConversationHiddenMessages(conversationId, hiddenMessages, revision);
664
+ });
587
665
  // REPLACE semantics for the whole set: the preview registry is relay-owned
588
666
  // and global, so every change ships the full list.
589
667
  socket.on('previews', ({ previews }) => {
590
668
  setPreviews(previews);
591
669
  });
670
+ // Public shares: the events name the conversation only; the list (Settings
671
+ // → Sharing, the ⋯ menu's Stop sharing) is read again.
672
+ for (const event of ['share_created', 'share_renewed', 'share_revoked']) {
673
+ socket.on(event, () => {
674
+ void refreshShares().catch(() => {});
675
+ });
676
+ }
677
+ // A deleted conversation takes its share along.
678
+ socket.on('conversation_deleted', ({ conversationId } = {}) => {
679
+ if (hasActiveShare(conversationId)) void refreshShares().catch(() => {});
680
+ });
681
+ socket.on('share_opened', ({ conversationId } = {}) => {
682
+ handleShareOpened(conversationId);
683
+ });
684
+ socket.on('share_settings_changed', (payload) => {
685
+ applyShareSettings(payload || null);
686
+ });
687
+ socket.on('share_viewer_ips_forgotten', () => {
688
+ handleViewerIpsForgotten();
689
+ });
592
690
  // REPLACE semantics too: the relay ships the whole remote-relay list (no
593
691
  // tokens) whenever a remote is added, removed, renamed or changes status.
594
692
  socket.on(REMOTE_RELAY_SOCKET_EVENT, (payload) => {
@@ -613,16 +711,17 @@ export async function connectSocket(overrideDeps) {
613
711
  const last = items[items.length - 1];
614
712
  const lastText = typeof last === 'string' ? last : String(last?.text || '');
615
713
  const lastSubagentRunId = typeof last === 'object' && last ? (last.subagentRunId || null) : null;
616
- if (lastText !== entry.text || lastSubagentRunId !== entry.subagentRunId) {
617
- relayActivities.set(messageId, capLiveActivityEntries(items.concat(entry)));
618
- }
714
+ // The same line again (a repeated event) adds nothing.
715
+ const added = lastText !== entry.text || lastSubagentRunId !== entry.subagentRunId;
716
+ if (added) relayActivities.set(messageId, capLiveActivityEntries(items.concat(entry)));
619
717
  if (entry.subagentRunId) {
620
718
  upsertSubagentRun({ subagentRunId: entry.subagentRunId, messageId, conversationId });
621
719
  addSubagentActivity(entry.subagentRunId, entry.text);
622
720
  }
623
721
  if (conversationId === currentConvId) {
624
- const autoScroll = isMessagesAtBottom();
722
+ const autoScroll = followsTranscriptEnd();
625
723
  appendThinkingActivity(entry, entry.subagentRunId, autoScroll);
724
+ if (added && !autoScroll) noteTranscriptNews();
626
725
  }
627
726
  });
628
727
  socket.on('relay_stream', ({ conversationId, messageId, text, done, seq, subagentRunId }) => {
@@ -631,8 +730,8 @@ export async function connectSocket(overrideDeps) {
631
730
  upsertSubagentRun({ subagentRunId: String(subagentRunId).trim(), messageId, conversationId });
632
731
  }
633
732
  if (conversationId !== currentConvId) return;
634
- const autoScroll = isMessagesAtBottom();
635
- applyRelayStreamEvent({
733
+ const autoScroll = followsTranscriptEnd();
734
+ const accepted = applyRelayStreamEvent({
636
735
  messageId,
637
736
  text: String(text || ''),
638
737
  done: !!done,
@@ -640,11 +739,16 @@ export async function connectSocket(overrideDeps) {
640
739
  subagentRunId: subagentRunId ? String(subagentRunId).trim() : null,
641
740
  autoScroll,
642
741
  });
742
+ // A frame that was dropped (out of order, a finished turn) is no news.
743
+ if (accepted && !autoScroll) noteTranscriptNews();
643
744
  });
644
745
  socket.on('relay_thought', ({ conversationId, messageId, reasoningId, text, done, subagentRunId }) => {
645
746
  if (!messageId) return;
646
747
  const key = String(reasoningId || 'reasoning');
647
748
  const thoughtMap = relayThoughts.get(messageId) || new Map();
749
+ const known = thoughtMap.get(key);
750
+ // The same thought again (a repeated event) adds nothing.
751
+ const added = !known || known.text !== String(text || '') || known.done !== !!done;
648
752
  thoughtMap.set(key, { reasoningId: key, text: String(text || ''), done: !!done, subagentRunId: subagentRunId || null });
649
753
  relayThoughts.set(messageId, thoughtMap);
650
754
  if (subagentRunId) {
@@ -652,8 +756,9 @@ export async function connectSocket(overrideDeps) {
652
756
  addSubagentThought(subagentRunId, { reasoningId: key, text: String(text || ''), done: !!done });
653
757
  }
654
758
  if (conversationId === currentConvId) {
655
- const autoScroll = isMessagesAtBottom();
759
+ const autoScroll = followsTranscriptEnd();
656
760
  appendThinkingThought(key, String(text || ''), !!done, subagentRunId, autoScroll);
761
+ if (added && !autoScroll) noteTranscriptNews();
657
762
  }
658
763
  });
659
764
  socket.on('subagent_status', (payload = {}) => {
@@ -681,6 +786,8 @@ export async function connectSocket(overrideDeps) {
681
786
  });
682
787
  socket.on('conversation_compacted', async ({ sourceConversationId, targetConversationId }) => {
683
788
  if (!sourceConversationId || !targetConversationId) return;
789
+ conversationPayloadCache.forget(sourceConversationId);
790
+ conversationPayloadCache.forget(targetConversationId);
684
791
  await refreshConversations();
685
792
  if (currentConvId === sourceConversationId) {
686
793
  await openConversation(targetConversationId);
@@ -770,6 +877,9 @@ export async function connectSocket(overrideDeps) {
770
877
  applyConversationTurnStatus({ conversationId, messageId, status });
771
878
  if (conversationId && conversations[conversationId]) {
772
879
  const conversation = conversations[conversationId];
880
+ // The row's turn fields follow the event, open or not: the title colour, a
881
+ // group head's pill and its fold read them between list fetches.
882
+ applyTurnStatusToConversation(conversation, normalizedStatus);
773
883
  if (normalizedStatus === 'processing') {
774
884
  conversation.localTurnStatus = 'processing';
775
885
  conversation.localTurnStatusUpdatedAt = Date.now();
@@ -785,7 +895,7 @@ export async function connectSocket(overrideDeps) {
785
895
  }
786
896
  }
787
897
  if (conversationId === currentConvId && normalizedStatus === 'processing') {
788
- const autoScroll = isMessagesAtBottom();
898
+ const autoScroll = followsTranscriptEnd();
789
899
  showThinking(messageId || null, autoScroll);
790
900
  renderThinkingActivities();
791
901
  renderThinkingThoughts();
@@ -821,11 +931,18 @@ export async function connectSocket(overrideDeps) {
821
931
  }
822
932
  renderConvList();
823
933
  });
934
+ // A session group changed (a member joined or left, a name, a dissolve,
935
+ // here or from another device): every loaded page is fetched again with
936
+ // its groups, so a row far down the list changes too.
937
+ socket.on('conversation_group_changed', (payload) => {
938
+ applyConversationGroupEvent(payload);
939
+ });
824
940
  socket.on('conversation_archived', ({ conversationId, archived }) => {
825
941
  const id = String(conversationId || '').trim();
826
942
  if (!id) return;
827
943
  const conversation = conversations[id];
828
944
  if (conversation) conversation.archived = archived !== false;
945
+ conversationPayloadCache.forget(id);
829
946
  renderConvList();
830
947
  if (currentConvId === id) {
831
948
  syncChatTitleControls();
@@ -834,6 +951,7 @@ export async function connectSocket(overrideDeps) {
834
951
  });
835
952
  socket.on('conversation_deleted', ({ conversationId }) => {
836
953
  delete conversations[conversationId];
954
+ conversationPayloadCache.forget(conversationId);
837
955
  for (const [id, question] of relayQuestions.entries()) {
838
956
  if (question?.conversationId === conversationId) relayQuestions.delete(id);
839
957
  }
@@ -1,10 +1,12 @@
1
1
  import { resolveNearBottomThresholdPx } from './transcript-viewport-anchor.mjs';
2
+ import { normalizeReadingAnchor } from './transcript-reading-anchor.mjs';
2
3
  import { recordCliLifecycleEvent, recordRelayLifecycleEvent } from './status-store.mjs';
3
4
  import { resolveRelayDotState } from './relay-dot-state.mjs';
4
5
  import { readRepoBrowserPreferences } from './repo-browser-preferences.mjs';
5
6
  import { isChatInteractionHeld } from './selection-guard.mjs';
6
7
  import { nextSubagentStatus } from './subagent-fold.mjs';
7
8
  import { useStrictStrikethrough } from './markdown-strikethrough.mjs';
9
+ import { parseGroupFoldChoices, pruneGroupFoldChoices } from './conversation-group-state.mjs';
8
10
 
9
11
  function resolveAppBase() {
10
12
  const configuredBase = typeof window.__COPILOT_APP_CONFIG?.basePath === 'string'
@@ -16,6 +18,8 @@ function resolveAppBase() {
16
18
  const pathname = String(window.location.pathname || '/');
17
19
  const sharedIndex = pathname.indexOf('/shared/');
18
20
  if (sharedIndex >= 0) return pathname.slice(0, sharedIndex).replace(/\/+$/, '');
21
+ const previewIndex = pathname.indexOf('/share-preview/');
22
+ if (previewIndex >= 0) return pathname.slice(0, previewIndex).replace(/\/+$/, '');
19
23
  return pathname.replace(/\/+$/, '');
20
24
  }
21
25
 
@@ -32,8 +36,24 @@ function resolveSharedConversationToken() {
32
36
  if (configured) return configured;
33
37
  return extractSharedTokenFromPathname(window.location.pathname);
34
38
  }
35
- export const SHARED_CONVERSATION_TOKEN = resolveSharedConversationToken();
36
- export const IS_SHARED_VIEW = !!SHARED_CONVERSATION_TOKEN;
39
+ // "Preview as shared": the owner reads a conversation as a share link would
40
+ // serve it (marked redactions), through the owner-only preview routes. It is a
41
+ // shared view in every other respect.
42
+ function resolveSharePreviewConversationId() {
43
+ const configured = String(window.__COPILOT_APP_CONFIG?.sharePreviewConversationId || '').trim();
44
+ if (configured) return configured;
45
+ const match = String(window.location.pathname || '/').match(/\/share-preview\/([^/?#]+)\/?$/i);
46
+ if (!match) return '';
47
+ try {
48
+ return decodeURIComponent(String(match[1] || '')).trim();
49
+ } catch {
50
+ return '';
51
+ }
52
+ }
53
+ export const SHARE_PREVIEW_CONVERSATION_ID = resolveSharePreviewConversationId();
54
+ export const IS_SHARE_PREVIEW = !!SHARE_PREVIEW_CONVERSATION_ID;
55
+ export const SHARED_CONVERSATION_TOKEN = IS_SHARE_PREVIEW ? '' : resolveSharedConversationToken();
56
+ export const IS_SHARED_VIEW = !!SHARED_CONVERSATION_TOKEN || IS_SHARE_PREVIEW;
37
57
  export let TOKEN = '';
38
58
  export let socket = null;
39
59
  export let currentConvId = null;
@@ -82,6 +102,9 @@ export let updateState = null;
82
102
  export let activeRuntimeSessionCount = 0;
83
103
  export let runtimeSessionBindingCount = 0;
84
104
  export let conversations = {};
105
+ // Session groups by id, as the list payload describes them (`groups`):
106
+ // name, display name, head and member counts. Merged with every page.
107
+ export const conversationGroups = {};
85
108
  export let selectedAttachments = [];
86
109
  export let imageEditTarget = null;
87
110
  export const RELAY_QUESTION_POLL_MS = 3000;
@@ -172,6 +195,8 @@ const SIDEBAR_WIDTH_PERCENT_FALLBACK = 24;
172
195
  const SIDEBAR_WIDTH_PORTRAIT_STORAGE_KEY = 'copilot_sidebar_width_pct_portrait';
173
196
  const SIDEBAR_WIDTH_LANDSCAPE_STORAGE_KEY = 'copilot_sidebar_width_pct_landscape';
174
197
  const SIDEBAR_COLLAPSED_DESKTOP_STORAGE_KEY = 'copilot_sidebar_collapsed_desktop';
198
+ // The user's fold choices for session groups in the sidebar: { [groupId]: 'open' | 'folded' }.
199
+ const CONVERSATION_GROUP_FOLDS_STORAGE_KEY = 'copilot_conv_group_folds';
175
200
  let sidebarWidthPercent = SIDEBAR_WIDTH_PERCENT_FALLBACK;
176
201
  const CONVERSATION_SCROLL_STORAGE_PREFIX = 'copilot_message_scroll_';
177
202
 
@@ -197,6 +222,59 @@ export function setCurrentConv(id) {
197
222
  updateCompactButton();
198
223
  }
199
224
 
225
+ /** Merges a list payload's `groups` (by id) into the known session groups. */
226
+ export function mergeConversationGroups(groups) {
227
+ if (!groups || typeof groups !== 'object') return;
228
+ for (const [groupId, group] of Object.entries(groups)) {
229
+ const id = String(groupId || '').trim();
230
+ if (!id || !group || typeof group !== 'object') continue;
231
+ conversationGroups[id] = { ...(conversationGroups[id] || {}), ...group, id };
232
+ }
233
+ }
234
+
235
+ /** Forgets a session group the relay no longer lists. */
236
+ export function forgetConversationGroup(groupId) {
237
+ delete conversationGroups[String(groupId || '').trim()];
238
+ }
239
+
240
+ /**
241
+ * Keeps only the groups that a loaded row still belongs to: after a list
242
+ * refresh a dissolved group has no row left, and the picker must not offer
243
+ * it. Returns the ids dropped.
244
+ */
245
+ export function pruneConversationGroups(keepGroupIds) {
246
+ const keep = new Set((Array.isArray(keepGroupIds) ? keepGroupIds : []).map((id) => String(id || '').trim()).filter(Boolean));
247
+ const dropped = [];
248
+ for (const id of Object.keys(conversationGroups)) {
249
+ if (keep.has(id)) continue;
250
+ delete conversationGroups[id];
251
+ dropped.push(id);
252
+ }
253
+ return dropped;
254
+ }
255
+
256
+ export function readConversationGroupFoldChoices() {
257
+ return parseGroupFoldChoices(localStorage.getItem(CONVERSATION_GROUP_FOLDS_STORAGE_KEY) || '');
258
+ }
259
+
260
+ // The stored fold choices are trimmed to the loaded groups only past this
261
+ // size: a choice for a group on an older page or in the other view must
262
+ // not go with every toggle.
263
+ const CONVERSATION_GROUP_FOLDS_MAX = 300;
264
+
265
+ /** Stores the user's fold choice for a group. */
266
+ export function setConversationGroupFoldChoice(groupId, choice) {
267
+ const id = String(groupId || '').trim();
268
+ if (!id) return;
269
+ let next = readConversationGroupFoldChoices();
270
+ if (Object.keys(next).length > CONVERSATION_GROUP_FOLDS_MAX) {
271
+ next = pruneGroupFoldChoices(next, [...Object.keys(conversationGroups), id]);
272
+ }
273
+ if (choice === 'open' || choice === 'folded') next[id] = choice;
274
+ else delete next[id];
275
+ localStorage.setItem(CONVERSATION_GROUP_FOLDS_STORAGE_KEY, JSON.stringify(next));
276
+ }
277
+
200
278
  export function setConversationWatcherCount(conversationId, count) {
201
279
  const id = String(conversationId || '').trim();
202
280
  if (!id) return;
@@ -236,6 +314,7 @@ function readConversationViewState(conversationId) {
236
314
  loadedMessageCount: Number.isFinite(Number(parsed.loadedMessageCount)) && Number(parsed.loadedMessageCount) >= 0
237
315
  ? Math.trunc(Number(parsed.loadedMessageCount))
238
316
  : null,
317
+ anchor: normalizeReadingAnchor(parsed.anchor),
239
318
  };
240
319
  }
241
320
  } catch {
@@ -246,6 +325,7 @@ function readConversationViewState(conversationId) {
246
325
  return {
247
326
  scrollTop: Math.trunc(legacyScrollTop),
248
327
  loadedMessageCount: null,
328
+ anchor: null,
249
329
  };
250
330
  }
251
331
 
@@ -260,6 +340,7 @@ function writeConversationViewState(conversationId, nextState = {}) {
260
340
  loadedMessageCount: Number.isFinite(Number(nextState.loadedMessageCount)) && Number(nextState.loadedMessageCount) >= 0
261
341
  ? Math.trunc(Number(nextState.loadedMessageCount))
262
342
  : current.loadedMessageCount ?? null,
343
+ anchor: 'anchor' in nextState ? normalizeReadingAnchor(nextState.anchor) : (current.anchor ?? null),
263
344
  };
264
345
  localStorage.setItem(key, JSON.stringify(next));
265
346
  return true;
@@ -296,6 +377,27 @@ export function loadConversationLoadedMessageCount(conversationId) {
296
377
  return readConversationViewState(conversationId)?.loadedMessageCount ?? null;
297
378
  }
298
379
 
380
+ // The message read and its offset, or "at the end" (transcript-reading-anchor.mjs):
381
+ // what a switch back and a reload restore. scrollTop is still written for
382
+ // older pages of this app reading the same storage.
383
+ export function saveConversationReadingAnchor(conversationId, anchor) {
384
+ const value = normalizeReadingAnchor(anchor);
385
+ if (!value) return false;
386
+ try {
387
+ return writeConversationViewState(conversationId, { anchor: value });
388
+ } catch {
389
+ return false;
390
+ }
391
+ }
392
+
393
+ export function loadConversationReadingAnchor(conversationId) {
394
+ try {
395
+ return readConversationViewState(conversationId)?.anchor ?? null;
396
+ } catch {
397
+ return null;
398
+ }
399
+ }
400
+
299
401
  function normalizePendingMessageText(text) {
300
402
  return String(text || '').replace(/\s+/g, ' ').trim().toLowerCase();
301
403
  }
@@ -612,37 +714,18 @@ export function getMessagesDistanceFromBottom() {
612
714
  return Math.max(0, el.scrollHeight - el.clientHeight - el.scrollTop);
613
715
  }
614
716
 
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) {
717
+ /**
718
+ * "At the end" of the transcript, wherever that is decided: within the
719
+ * near-bottom margin (8% of the viewport, between 12 and 48 px). One margin
720
+ * and no parameter: a caller with a margin of its own (there was one with
721
+ * 80 px, one with 1 px, and a default that read as 0 px) breaks the rule that
722
+ * the transcript follows a reader who is at the end and nobody else.
723
+ */
724
+ export function isMessagesNearBottom() {
631
725
  const el = document.getElementById('messages');
632
726
  if (!el) return true;
633
- const epsilon = Math.max(0, Number.isFinite(Number(epsilonPx)) ? Number(epsilonPx) : 1);
634
727
  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);
728
+ return distance <= resolveNearBottomThresholdPx(el.clientHeight);
646
729
  }
647
730
 
648
731
  export function isMobileComposerViewport() {
@@ -944,8 +1027,16 @@ function buildSessionWorkerStateHash(map) {
944
1027
 
945
1028
  let sessionWorkerStateHash = '';
946
1029
 
1030
+ // Whether a status poll has answered yet: before that, a session's reported
1031
+ // background work is taken as it is; after it, only a live worker carries it.
1032
+ let sessionWorkerStatusKnown = false;
1033
+ export function hasSessionWorkerStatus() {
1034
+ return sessionWorkerStatusKnown;
1035
+ }
1036
+
947
1037
  export function setSessionWorkerStatesFromStatusPayload(payload) {
948
1038
  const workers = Array.isArray(payload?.workers) ? payload.workers : [];
1039
+ sessionWorkerStatusKnown = true;
949
1040
  const next = new Map();
950
1041
  for (const worker of workers) {
951
1042
  const normalized = normalizeWorkerStateEntry(worker);
@@ -60,9 +60,9 @@ export function isRunningSubagentStatus(status) {
60
60
 
61
61
  /**
62
62
  * The row for one run. `clickable` makes the title the button that opens the
63
- * viewer (`data-subagent-open`); a shared view, which cannot read the record,
64
- * passes false. `context` lands in a class so the bubble and the viewer can
65
- * size the row differently.
63
+ * viewer (`data-subagent-open`); a shared view opens it too, read-only,
64
+ * through the share's sub-agent route. `context` lands in a class so the
65
+ * bubble and the viewer can size the row differently.
66
66
  */
67
67
  export function subagentRowHtml(run, { context = 'bubble', clickable = true, now = Date.now() } = {}) {
68
68
  const runId = String(run?.subagentRunId || '').trim();