@oar-sh/oar 0.9.10 → 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 (104) hide show
  1. package/CHANGELOG.md +314 -1
  2. package/README.md +156 -972
  3. package/package.json +13 -13
  4. package/server/README.md +95 -22
  5. package/server/claude-worker/claude-session-process.mjs +89 -11
  6. package/server/claude-worker/claude-session-worker.mjs +9 -1
  7. package/server/copilot-worker/copilot-sdk-session-process.mjs +76 -11
  8. package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
  9. package/server/db-schema.mjs +59 -2
  10. package/server/grok-worker/acp-client.mjs +2 -1
  11. package/server/grok-worker/acp-host-services.mjs +4 -3
  12. package/server/migrations/0013-previews.mjs +130 -0
  13. package/server/migrations/0014-conversation-groups.mjs +89 -0
  14. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  15. package/server/migrations/0016-share-access-log.mjs +110 -0
  16. package/server/public/app/about-ui.js +73 -0
  17. package/server/public/app/about-view.mjs +234 -0
  18. package/server/public/app/action-confirmations.js +31 -14
  19. package/server/public/app/agent-exchange-fold-view.mjs +40 -101
  20. package/server/public/app/api-client.js +251 -16
  21. package/server/public/app/ask-user-view.js +17 -15
  22. package/server/public/app/bootstrap.js +158 -32
  23. package/server/public/app/chat-actions-menu-state.mjs +20 -10
  24. package/server/public/app/context-menu.mjs +90 -6
  25. package/server/public/app/conversation-group-drag.mjs +191 -0
  26. package/server/public/app/conversation-group-order.mjs +265 -0
  27. package/server/public/app/conversation-group-state.mjs +100 -0
  28. package/server/public/app/conversation-list-drag.mjs +449 -0
  29. package/server/public/app/conversation-turn-state.mjs +81 -0
  30. package/server/public/app/conversation-view.js +136 -93
  31. package/server/public/app/cwd-picker.js +1 -1
  32. package/server/public/app/hidden-messages-view.mjs +312 -0
  33. package/server/public/app/host-suspend-ui.js +7 -1
  34. package/server/public/app/journal-view.js +787 -22
  35. package/server/public/app/preview-cards.mjs +117 -31
  36. package/server/public/app/preview-command.mjs +10 -1
  37. package/server/public/app/relay-board-view.js +19 -6
  38. package/server/public/app/relay-restart-error.mjs +16 -0
  39. package/server/public/app/settings-modal.js +40 -8
  40. package/server/public/app/settings-tabs.js +1 -1
  41. package/server/public/app/share-format.mjs +49 -0
  42. package/server/public/app/share-preview-marks.mjs +167 -0
  43. package/server/public/app/share-state.js +109 -0
  44. package/server/public/app/shared-conversations-settings.js +480 -0
  45. package/server/public/app/socket-handlers.js +80 -14
  46. package/server/public/app/store.js +96 -30
  47. package/server/public/app/subagent-row.mjs +3 -3
  48. package/server/public/app/subagent-viewer.mjs +30 -6
  49. package/server/public/app/thinking-follow.js +0 -4
  50. package/server/public/app/transcript-jump-bottom-view.mjs +194 -20
  51. package/server/public/app/transcript-jump-bottom.mjs +132 -21
  52. package/server/public/app/transcript-viewport-anchor.mjs +140 -0
  53. package/server/public/index.html +386 -15
  54. package/server/relay-tools.md +2 -1
  55. package/server/relay.mjs +2 -1
  56. package/server/repositories/conversation-group-repository.mjs +444 -0
  57. package/server/repositories/message-repository.mjs +1 -0
  58. package/server/repositories/session-repository.mjs +12 -37
  59. package/server/repositories/share-repository.mjs +322 -0
  60. package/server/repositories/share-visibility-statements.mjs +10 -0
  61. package/server/routes/about-routes.mjs +15 -0
  62. package/server/routes/cache-routes.mjs +4 -0
  63. package/server/routes/conversation-group-routes.mjs +171 -0
  64. package/server/routes/messages-routes.mjs +184 -65
  65. package/server/routes/preview-routes.mjs +9 -1
  66. package/server/routes/sessions-routes.mjs +771 -193
  67. package/server/server-runtime.mjs +316 -100
  68. package/server/services/about-service.mjs +461 -0
  69. package/server/services/background-task-store.mjs +128 -0
  70. package/server/services/background-work-view.mjs +228 -0
  71. package/server/services/cache-rebuild-service.mjs +30 -0
  72. package/server/services/delete-archive-service.mjs +4 -1
  73. package/server/services/detached-worker-watch-service.mjs +150 -0
  74. package/server/services/hidden-message-list.mjs +21 -0
  75. package/server/services/host-suspend-command.mjs +2 -1
  76. package/server/services/message-pin-list.mjs +1 -1
  77. package/server/services/preview-health-probe.mjs +8 -1
  78. package/server/services/preview-proxy-server.mjs +8 -4
  79. package/server/services/preview-registry-service.mjs +676 -26
  80. package/server/services/preview-static-handler.mjs +40 -7
  81. package/server/services/preview-store.mjs +274 -0
  82. package/server/services/rate-limit-service.mjs +12 -1
  83. package/server/services/relay-auth.mjs +183 -0
  84. package/server/services/relay-cli-launcher-service.mjs +3 -1
  85. package/server/services/relay-event-service.mjs +17 -0
  86. package/server/services/remote-relay-dispatcher.mjs +57 -5
  87. package/server/services/request-boundary.mjs +183 -0
  88. package/server/services/session-activity-tracker.mjs +227 -19
  89. package/server/services/session-worker-launch-service.mjs +190 -6
  90. package/server/services/session-worker-process-service.mjs +10 -6
  91. package/server/services/share-access-log-service.mjs +123 -0
  92. package/server/services/share-path-redaction.mjs +424 -0
  93. package/server/services/status-event-service.mjs +7 -3
  94. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  95. package/server/services/windows-autostart-service.mjs +6 -1
  96. package/server/services/windows-boot-autostart-service.mjs +207 -23
  97. package/shared/preview-tool-core.mjs +15 -5
  98. package/shared/remote-relay-contract.mjs +3 -1
  99. package/shared/share-path-privacy.mjs +30 -0
  100. package/shared/windows-system-tools.mjs +106 -0
  101. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  102. package/shared/worker-runtime/heartbeat.mjs +24 -0
  103. package/shared/worker-runtime/kept-posts.mjs +83 -0
  104. 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,
@@ -35,7 +34,8 @@ import {
35
34
  import { scheduleContextUsageRefresh } from './api-client.js';
36
35
  import { capLiveActivityEntries } from './activity-replay-state.mjs';
37
36
  import { publishStatusEvent, recordStatusEvent } from './status-store.mjs';
38
- import { clearOpenConversation, 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
39
  import { applyRelayEventSubscriptions, loadRelayEventSubscriptions } from './relay-event-subscriptions-ui.js';
40
40
  import {
41
41
  upsertRelayQuestion,
@@ -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';
@@ -74,6 +77,8 @@ import {
74
77
  applyConversationTurnStatus,
75
78
  renderMessages,
76
79
  appendMessage,
80
+ captureLiveReplyReadingPosition,
81
+ restoreLiveReplyReadingPosition,
77
82
  replaceRenderedMessage,
78
83
  noteTranscriptLiveRow,
79
84
  applyIncomingConversationDraftUpdate,
@@ -85,6 +90,7 @@ import {
85
90
  syncComposerButtonState,
86
91
  syncCancellableSteerButtons,
87
92
  } from './conversation-view.js';
93
+ import { followsTranscriptEnd, noteTranscriptNews } from './transcript-jump-bottom-view.mjs';
88
94
  import { loadRepoBrowserTree, refreshRepoBrowserIfWorkspaceOpen } from './attachments-view.js';
89
95
  import { clearMessageSearchRuntimeState } from './message-search-view.js';
90
96
  import { stripRelayPromptContext } from './relay-prompt-sanitizer.mjs';
@@ -333,12 +339,17 @@ export async function connectSocket(overrideDeps) {
333
339
  setCliOnline(true);
334
340
  renderConvList();
335
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();
336
345
  refreshSessionWorkerStatus().catch(() => {});
337
346
  refreshModelCatalog().catch(() => {});
338
347
  // An open sub-agent viewer fetches whatever it missed while away.
339
348
  handleSubagentViewerReconnect();
340
349
  // Subscriptions that changed while away.
341
350
  void loadRelayEventSubscriptions();
351
+ // Shares made or stopped while away.
352
+ void refreshShares().catch(() => {});
342
353
  });
343
354
  socket.on('connect_error', (e) => {
344
355
  setRelayOnline(false);
@@ -511,13 +522,16 @@ export async function connectSocket(overrideDeps) {
511
522
  // Another client's message (an agent's prompt included) follows the
512
523
  // end like any live row: a reader scrolled up stays where they are,
513
524
  // and the jump-to-bottom badge counts it.
514
- appendMessage(normalizedMessage, isMessagesAtBottom(), messageId);
525
+ appendMessage(normalizedMessage, followsTranscriptEnd(), messageId);
515
526
  noteTranscriptLiveRow();
516
527
  }
517
528
  });
518
529
  socket.on('assistant_message', ({ conversationId, message, messageId, sourceMessageId }) => {
519
530
  const isCurrentConversation = conversationId === currentConvId;
520
- 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;
521
535
  // A background conversation finishing its turn must not wipe the viewed
522
536
  // conversation's live bubble.
523
537
  if (isCurrentConversation) {
@@ -536,9 +550,13 @@ export async function connectSocket(overrideDeps) {
536
550
  message.thoughts = mergedThoughts;
537
551
  }
538
552
  }
539
- if (messageId && seenMessageIds?.has(messageId)) return;
553
+ if (messageId && seenMessageIds?.has(messageId)) {
554
+ restoreLiveReplyReadingPosition(reading);
555
+ return;
556
+ }
540
557
  if (isCurrentConversation) {
541
558
  appendMessage(message, autoScroll, messageId || null, false, sourceMessageId || null);
559
+ restoreLiveReplyReadingPosition(reading);
542
560
  noteTranscriptLiveRow();
543
561
  scheduleContextUsageRefresh(conversationId, 120);
544
562
  }
@@ -558,7 +576,7 @@ export async function connectSocket(overrideDeps) {
558
576
  socket.on('message_updated', ({ conversationId, messageId, message }) => {
559
577
  if (!messageId || conversationId !== currentConvId || !message) return;
560
578
  if (!replaceRenderedMessage(message, messageId)) {
561
- appendMessage(message, isMessagesAtBottom(), messageId, false);
579
+ appendMessage(message, followsTranscriptEnd(), messageId, false);
562
580
  }
563
581
  noteTranscriptLiveRow();
564
582
  });
@@ -578,17 +596,49 @@ export async function connectSocket(overrideDeps) {
578
596
  });
579
597
  socket.on('background_tasks', ({ conversationId, tasks }) => {
580
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();
581
606
  });
582
607
  // A message was pinned or unpinned (any client): the conversation's whole
583
608
  // pin list, replacing what this page had.
584
609
  socket.on('conversation_pins_updated', ({ conversationId, pins, revision }) => {
585
610
  setConversationPins(conversationId, pins, revision);
586
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
+ });
587
617
  // REPLACE semantics for the whole set: the preview registry is relay-owned
588
618
  // and global, so every change ships the full list.
589
619
  socket.on('previews', ({ previews }) => {
590
620
  setPreviews(previews);
591
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
+ });
592
642
  // REPLACE semantics too: the relay ships the whole remote-relay list (no
593
643
  // tokens) whenever a remote is added, removed, renamed or changes status.
594
644
  socket.on(REMOTE_RELAY_SOCKET_EVENT, (payload) => {
@@ -613,16 +663,17 @@ export async function connectSocket(overrideDeps) {
613
663
  const last = items[items.length - 1];
614
664
  const lastText = typeof last === 'string' ? last : String(last?.text || '');
615
665
  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
- }
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)));
619
669
  if (entry.subagentRunId) {
620
670
  upsertSubagentRun({ subagentRunId: entry.subagentRunId, messageId, conversationId });
621
671
  addSubagentActivity(entry.subagentRunId, entry.text);
622
672
  }
623
673
  if (conversationId === currentConvId) {
624
- const autoScroll = isMessagesAtBottom();
674
+ const autoScroll = followsTranscriptEnd();
625
675
  appendThinkingActivity(entry, entry.subagentRunId, autoScroll);
676
+ if (added && !autoScroll) noteTranscriptNews();
626
677
  }
627
678
  });
628
679
  socket.on('relay_stream', ({ conversationId, messageId, text, done, seq, subagentRunId }) => {
@@ -631,8 +682,8 @@ export async function connectSocket(overrideDeps) {
631
682
  upsertSubagentRun({ subagentRunId: String(subagentRunId).trim(), messageId, conversationId });
632
683
  }
633
684
  if (conversationId !== currentConvId) return;
634
- const autoScroll = isMessagesAtBottom();
635
- applyRelayStreamEvent({
685
+ const autoScroll = followsTranscriptEnd();
686
+ const accepted = applyRelayStreamEvent({
636
687
  messageId,
637
688
  text: String(text || ''),
638
689
  done: !!done,
@@ -640,11 +691,16 @@ export async function connectSocket(overrideDeps) {
640
691
  subagentRunId: subagentRunId ? String(subagentRunId).trim() : null,
641
692
  autoScroll,
642
693
  });
694
+ // A frame that was dropped (out of order, a finished turn) is no news.
695
+ if (accepted && !autoScroll) noteTranscriptNews();
643
696
  });
644
697
  socket.on('relay_thought', ({ conversationId, messageId, reasoningId, text, done, subagentRunId }) => {
645
698
  if (!messageId) return;
646
699
  const key = String(reasoningId || 'reasoning');
647
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;
648
704
  thoughtMap.set(key, { reasoningId: key, text: String(text || ''), done: !!done, subagentRunId: subagentRunId || null });
649
705
  relayThoughts.set(messageId, thoughtMap);
650
706
  if (subagentRunId) {
@@ -652,8 +708,9 @@ export async function connectSocket(overrideDeps) {
652
708
  addSubagentThought(subagentRunId, { reasoningId: key, text: String(text || ''), done: !!done });
653
709
  }
654
710
  if (conversationId === currentConvId) {
655
- const autoScroll = isMessagesAtBottom();
711
+ const autoScroll = followsTranscriptEnd();
656
712
  appendThinkingThought(key, String(text || ''), !!done, subagentRunId, autoScroll);
713
+ if (added && !autoScroll) noteTranscriptNews();
657
714
  }
658
715
  });
659
716
  socket.on('subagent_status', (payload = {}) => {
@@ -770,6 +827,9 @@ export async function connectSocket(overrideDeps) {
770
827
  applyConversationTurnStatus({ conversationId, messageId, status });
771
828
  if (conversationId && conversations[conversationId]) {
772
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);
773
833
  if (normalizedStatus === 'processing') {
774
834
  conversation.localTurnStatus = 'processing';
775
835
  conversation.localTurnStatusUpdatedAt = Date.now();
@@ -785,7 +845,7 @@ export async function connectSocket(overrideDeps) {
785
845
  }
786
846
  }
787
847
  if (conversationId === currentConvId && normalizedStatus === 'processing') {
788
- const autoScroll = isMessagesAtBottom();
848
+ const autoScroll = followsTranscriptEnd();
789
849
  showThinking(messageId || null, autoScroll);
790
850
  renderThinkingActivities();
791
851
  renderThinkingThoughts();
@@ -821,6 +881,12 @@ export async function connectSocket(overrideDeps) {
821
881
  }
822
882
  renderConvList();
823
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
+ });
824
890
  socket.on('conversation_archived', ({ conversationId, archived }) => {
825
891
  const id = String(conversationId || '').trim();
826
892
  if (!id) return;
@@ -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);
@@ -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();
@@ -9,8 +9,12 @@
9
9
  // and drops on a backed-up connection; a missed event shows up as a hole in
10
10
  // the seq numbers and is fetched again, and so is the end of a run. Full-screen on
11
11
  // phones; Escape, ×, a click outside and the system back close it.
12
+ //
13
+ // A shared view (and the owner's "Preview as shared") opens it read-only: the
14
+ // record comes filtered from the share's sub-agent route, without a socket, so
15
+ // a running agent's record is read again every few seconds; no Stop.
12
16
 
13
- import { apiFetch, cancelSubagentRun } from './api-client.js';
17
+ import { apiFetch, cancelSubagentRun, loadSharedSubagentRecord } from './api-client.js';
14
18
  import { renderMarkdownPreview } from './router.js';
15
19
  import { currentConvId, getSubagentRun, IS_SHARED_VIEW, upsertSubagentRun } from './store.js';
16
20
  import { createViewerHistory } from './viewer-history.mjs';
@@ -30,6 +34,7 @@ import {
30
34
  const PAGE_LIMIT = 500;
31
35
  const MAX_PAGES = 10;
32
36
  const STOP_PENDING_MS = 15_000;
37
+ const SHARED_POLL_MS = 3_000;
33
38
 
34
39
  const viewer = {
35
40
  open: false,
@@ -44,6 +49,7 @@ const viewer = {
44
49
  stopping: false,
45
50
  stopTimer: null,
46
51
  tickTimer: null,
52
+ sharedPollTimer: null,
47
53
  openFolds: new Set(),
48
54
  };
49
55
 
@@ -146,7 +152,7 @@ function buildEntryNode(entry) {
146
152
  renderMarkdown,
147
153
  children: viewer.children,
148
154
  openDetails: viewer.openFolds.has(entry.seq),
149
- clickable: !IS_SHARED_VIEW,
155
+ clickable: true,
150
156
  });
151
157
  return holder.firstElementChild;
152
158
  }
@@ -215,6 +221,21 @@ function applyRunHeader(header) {
215
221
  }
216
222
  renderHeader();
217
223
  syncTicker();
224
+ syncSharedPoll();
225
+ }
226
+
227
+ // Without a socket a running record is read again while it is on screen.
228
+ function syncSharedPoll() {
229
+ const want = IS_SHARED_VIEW && viewer.open && runIsRunning(viewer.run);
230
+ if (want && !viewer.sharedPollTimer) {
231
+ viewer.sharedPollTimer = setInterval(() => {
232
+ if (!viewer.loading && document.visibilityState !== 'hidden') void fetchRecord();
233
+ }, SHARED_POLL_MS);
234
+ }
235
+ if (!want && viewer.sharedPollTimer) {
236
+ clearInterval(viewer.sharedPollTimer);
237
+ viewer.sharedPollTimer = null;
238
+ }
218
239
  }
219
240
 
220
241
  /**
@@ -231,7 +252,9 @@ async function fetchRecord({ fromStart = false } = {}) {
231
252
  renderStatus();
232
253
  try {
233
254
  for (let page = 0; page < MAX_PAGES; page += 1) {
234
- const data = await apiFetch(`/api/conversation/${encodeURIComponent(conversationId)}/subagent/${encodeURIComponent(runId)}?after=${after}&limit=${PAGE_LIMIT}`);
255
+ const data = IS_SHARED_VIEW
256
+ ? await loadSharedSubagentRecord(runId, { after, limit: PAGE_LIMIT })
257
+ : await apiFetch(`/api/conversation/${encodeURIComponent(conversationId)}/subagent/${encodeURIComponent(runId)}?after=${after}&limit=${PAGE_LIMIT}`);
235
258
  if (token !== viewer.fetchToken || !viewer.open || viewer.state?.runId !== runId) return;
236
259
  if (!data) {
237
260
  viewer.error = viewer.state.entries.size ? '' : 'This agent\'s record could not be loaded.';
@@ -272,7 +295,7 @@ async function loadRun(runId) {
272
295
  renderHeader();
273
296
  renderStatus();
274
297
  // Joined before the read, so nothing sent in between is lost.
275
- await viewerWatch.watch(viewer.conversationId, id);
298
+ if (!IS_SHARED_VIEW) await viewerWatch.watch(viewer.conversationId, id);
276
299
  if (!viewer.open || viewer.state?.runId !== id) return;
277
300
  await fetchRecord({ fromStart: true });
278
301
  // A finished record reads from the top; a running one follows its end.
@@ -317,6 +340,7 @@ export function closeSubagentViewer({ fromHistory = false } = {}) {
317
340
  viewer.children = new Map();
318
341
  clearTimeout(viewer.stopTimer);
319
342
  syncTicker();
343
+ syncSharedPoll();
320
344
  if (!fromHistory) viewerHistory?.closed();
321
345
  }
322
346
 
@@ -338,7 +362,7 @@ async function openParent() {
338
362
 
339
363
  async function stopRun() {
340
364
  const run = viewer.run;
341
- if (!run || viewer.stopping || !runIsRunning(run)) return;
365
+ if (IS_SHARED_VIEW || !run || viewer.stopping || !runIsRunning(run)) return;
342
366
  viewer.stopping = true;
343
367
  renderHeader();
344
368
  const task = liveTaskFor(run);
@@ -475,7 +499,7 @@ export function initSubagentViewer() {
475
499
  // (a child row) opens that child in place.
476
500
  document.addEventListener('click', (event) => {
477
501
  const trigger = event.target?.closest?.('[data-subagent-open]');
478
- if (!trigger || IS_SHARED_VIEW) return;
502
+ if (!trigger) return;
479
503
  event.preventDefault();
480
504
  event.stopPropagation();
481
505
  const runId = trigger.getAttribute('data-subagent-open');
@@ -7,7 +7,6 @@
7
7
  // per conversation for the life of the page.
8
8
 
9
9
  import { isChatInteractionHeld } from './selection-guard.mjs';
10
- import { AGENT_EXCHANGE_CLOSED_CLASS } from './agent-exchange-fold-view.mjs';
11
10
 
12
11
  export const THINKING_FOLLOW_MODES = Object.freeze(['thoughts', 'stream', 'tools']);
13
12
  export const THINKING_FOLLOW_BOTTOM_GAP_PX = 16;
@@ -109,9 +108,6 @@ function resolveTarget(mode) {
109
108
  const el = document.querySelector(selector);
110
109
  if (!el) return null;
111
110
  if (el.hidden || el.closest?.('[hidden]')) return null;
112
- // Nor does a live bubble folded into an agent exchange: it has no box, and
113
- // following it would scroll the pane to the top.
114
- if (el.closest?.(`.${AGENT_EXCHANGE_CLOSED_CLASS}`)) return null;
115
111
  // A collapsed <details> or an empty list has nothing to follow yet.
116
112
  const details = el.closest?.('details');
117
113
  if (details && !details.open) return null;