@oar-sh/oar 0.9.21 → 0.9.22

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 (69) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/README.md +9 -9
  3. package/package.json +1 -1
  4. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +9 -1
  5. package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
  6. package/server/claude-worker/claude-session-process.mjs +9 -4
  7. package/server/claude-worker/claude-session-worker-main.mjs +10 -1
  8. package/server/copilot-worker/copilot-sdk-session-process.mjs +1 -0
  9. package/server/copilot-worker/copilot-sdk-session-worker.mjs +10 -1
  10. package/server/cursor-worker/cursor-session-worker.mjs +9 -1
  11. package/server/db-schema.mjs +19 -0
  12. package/server/grok-worker/grok-background-tasks.mjs +1 -0
  13. package/server/grok-worker/grok-session-worker.mjs +10 -1
  14. package/server/grok-worker/grok-turn-runner.mjs +1 -0
  15. package/server/migrations/0037-relay-asks-queue-id.mjs +66 -0
  16. package/server/migrations/0038-turn-seq.mjs +92 -0
  17. package/server/public/app/activity-replay-state.mjs +160 -42
  18. package/server/public/app/api-client.js +7 -0
  19. package/server/public/app/ask-user-view.js +4 -3
  20. package/server/public/app/bootstrap.js +62 -4
  21. package/server/public/app/chat-actions-menu-state.mjs +2 -0
  22. package/server/public/app/context-usage-view.mjs +68 -0
  23. package/server/public/app/conversation-view.js +306 -49
  24. package/server/public/app/header-priority-actions.mjs +5 -1
  25. package/server/public/app/held-details.mjs +202 -0
  26. package/server/public/app/inbox-ui.js +70 -79
  27. package/server/public/app/inbox-view.mjs +35 -19
  28. package/server/public/app/journal-view.js +17 -0
  29. package/server/public/app/live-activity-cap.mjs +103 -0
  30. package/server/public/app/questions-panel-ui.js +634 -0
  31. package/server/public/app/questions-stub-view.mjs +40 -0
  32. package/server/public/app/questions-view.mjs +89 -0
  33. package/server/public/app/relay-board-view.js +12 -2
  34. package/server/public/app/relay-thoughts.mjs +15 -1
  35. package/server/public/app/session-history-ui.js +1 -0
  36. package/server/public/app/settings-modal.js +6 -0
  37. package/server/public/app/socket-handlers.js +42 -11
  38. package/server/public/app/stream-view-preference.mjs +60 -0
  39. package/server/public/app/subagent-fold.mjs +6 -3
  40. package/server/public/app/subagent-timeline-view.mjs +12 -5
  41. package/server/public/app/subagent-viewer.mjs +8 -6
  42. package/server/public/app/thoughts-preference.mjs +79 -0
  43. package/server/public/app/turn-stream-view.mjs +124 -0
  44. package/server/public/index.html +391 -214
  45. package/server/repositories/message-repository.mjs +10 -0
  46. package/server/repositories/question-repository.mjs +51 -7
  47. package/server/repositories/relay-turn-rows.mjs +56 -0
  48. package/server/routes/ask-user-routes.mjs +34 -5
  49. package/server/routes/messages-routes.mjs +35 -14
  50. package/server/routes/relay-ask-routes.mjs +15 -5
  51. package/server/routes/sessions-routes.mjs +76 -5
  52. package/server/server-runtime.mjs +178 -131
  53. package/server/services/cloudflared-tunnel-service.mjs +7 -0
  54. package/server/services/idle-timers.mjs +184 -0
  55. package/server/services/queue-wake-hook.mjs +145 -0
  56. package/server/services/queue-waker.mjs +127 -0
  57. package/server/services/relay-ask-service.mjs +64 -5
  58. package/server/services/relay-event-service.mjs +4 -1
  59. package/server/services/remote-relay-dispatcher.mjs +23 -4
  60. package/server/services/session-history-refresh-service.mjs +4 -0
  61. package/server/services/session-worker-supervisor-service.mjs +36 -0
  62. package/server/services/session-worker-websocket-service.mjs +143 -15
  63. package/server/services/ssh-tunnel-manager-service.mjs +7 -0
  64. package/shared/ask-user-bridge.mjs +9 -6
  65. package/shared/relay-asks.mjs +10 -0
  66. package/shared/relay-events.mjs +1 -0
  67. package/shared/worker-runtime/heartbeat.mjs +26 -0
  68. package/shared/worker-runtime/worker-keepalive.mjs +108 -0
  69. package/shared/worker-runtime/worker-websocket-link.mjs +21 -10
@@ -0,0 +1,89 @@
1
+ // 📓 Questions of one conversation: the order the panel lists them in, the
2
+ // header icon's count and its labels. Pure: no DOM, no fetch.
3
+ //
4
+ // An ask (relay-ask-service.mjs shape) is pending, or closed: answered,
5
+ // dismissed, withdrawn or cancelled. Pending asks come first, the urgent ones
6
+ // (`urgency: 'now'`) ahead of the rest, each group newest first; then those
7
+ // closed in the last day, the latest close first.
8
+
9
+ export const QUESTIONS_CLOSED_KEEP_MS = 24 * 60 * 60_000;
10
+
11
+ function toText(value) {
12
+ return String(value ?? '').trim();
13
+ }
14
+
15
+ function timeOf(value) {
16
+ const ms = Date.parse(toText(value));
17
+ return Number.isFinite(ms) ? ms : 0;
18
+ }
19
+
20
+ function newestFirst(a, b) {
21
+ return timeOf(b.createdAt) - timeOf(a.createdAt) || toText(b.id).localeCompare(toText(a.id));
22
+ }
23
+
24
+ function closedAt(ask) {
25
+ return timeOf(ask.answeredAt) || timeOf(ask.updatedAt);
26
+ }
27
+
28
+ export function isUrgentAsk(ask) {
29
+ return ask?.urgency === 'now';
30
+ }
31
+
32
+ /**
33
+ * The asks of `conversationId`, split and ordered for the panel:
34
+ * `{ pending, urgent, answered }` where `pending` lists every pending ask
35
+ * (urgent ones first), `urgent` counts them and `answered` holds the asks
36
+ * closed within the last day.
37
+ */
38
+ export function questionsOfConversation(asks, conversationId, now = Date.now()) {
39
+ const id = toText(conversationId);
40
+ const mine = (Array.isArray(asks) ? asks : [])
41
+ .filter((ask) => ask && !ask.relayId && id && toText(ask.conversationId || ask.session?.id) === id);
42
+ const pending = mine.filter((ask) => ask.status === 'pending');
43
+ const urgentAsks = pending.filter(isUrgentAsk).sort(newestFirst);
44
+ const normalAsks = pending.filter((ask) => !isUrgentAsk(ask)).sort(newestFirst);
45
+ const since = now - QUESTIONS_CLOSED_KEEP_MS;
46
+ const answered = mine
47
+ .filter((ask) => ask.status !== 'pending' && closedAt(ask) >= since)
48
+ .sort((a, b) => closedAt(b) - closedAt(a) || toText(b.id).localeCompare(toText(a.id)));
49
+ return { pending: [...urgentAsks, ...normalAsks], urgent: urgentAsks.length, answered };
50
+ }
51
+
52
+ /**
53
+ * A count badge: nothing at zero; with an urgent one waiting the urgent
54
+ * count, marked; else the pending count.
55
+ */
56
+ export function questionsBadge({ pending = 0, urgent = 0 } = {}) {
57
+ const open = Math.max(0, Number(pending) || 0);
58
+ const hot = Math.max(0, Number(urgent) || 0);
59
+ if (hot > 0) return { text: String(hot), urgent: true };
60
+ if (open > 0) return { text: String(open), urgent: false };
61
+ return { text: '', urgent: false };
62
+ }
63
+
64
+ /** The 📓 button's tooltip and label. */
65
+ export function questionsAria({ pending = 0, urgent = 0 } = {}) {
66
+ const open = Math.max(0, Number(pending) || 0);
67
+ const hot = Math.max(0, Number(urgent) || 0);
68
+ if (hot > 0) return `Questions (${hot} urgent of ${open} open)`;
69
+ if (open > 0) return `Questions (${open} open)`;
70
+ return 'Questions';
71
+ }
72
+
73
+ /** The ︙ twin's text: "📓 Questions (2 urgent)" / "(3 open)". */
74
+ export function questionsTwinLabel({ pending = 0, urgent = 0 } = {}) {
75
+ const open = Math.max(0, Number(pending) || 0);
76
+ const hot = Math.max(0, Number(urgent) || 0);
77
+ if (hot > 0) return `📓 Questions (${hot} urgent)`;
78
+ if (open > 0) return `📓 Questions (${open} open)`;
79
+ return '📓 Questions';
80
+ }
81
+
82
+ /** The panel's head line: "📓 Questions · 2 urgent · 5 open". */
83
+ export function questionsPanelTitle({ pending = 0, urgent = 0 } = {}) {
84
+ const open = Math.max(0, Number(pending) || 0);
85
+ const hot = Math.max(0, Number(urgent) || 0);
86
+ return ['📓 Questions', hot > 0 ? `${hot} urgent` : '', open > 0 ? `${open} open` : 'none open']
87
+ .filter(Boolean)
88
+ .join(' · ');
89
+ }
@@ -77,8 +77,18 @@ async function loadBoardHistory(conversationId) {
77
77
 
78
78
  /** Where a board is shown: its turn's reply, the live bubble of its turn, or nowhere yet. */
79
79
  function findBoardHost(el, board) {
80
- const source = String(board?.messageId || board?.queueId || '').trim();
81
- if (!source) return null;
80
+ return findTurnBubble(el, board?.messageId || board?.queueId);
81
+ }
82
+
83
+ /**
84
+ * The bubble of the turn that answers queue row `queueId`: its reply
85
+ * (`{ bubble, live: false }`), else the live bubble while it runs
86
+ * (`live: true`), else null (not loaded, or not there). Plan boards and 📓
87
+ * stubs both live there.
88
+ */
89
+ export function findTurnBubble(el, queueId) {
90
+ const source = String(queueId || '').trim();
91
+ if (!el || !source) return null;
82
92
  const replies = el.querySelectorAll(`.msg.assistant[data-source-message-id="${CSS.escape(source)}"]`);
83
93
  // The turn's own reply is the last row that answers the message; a steer's
84
94
  // marker above it is not a reply.
@@ -11,6 +11,13 @@ function normalizeTimestampMs(value) {
11
11
  return Number.isFinite(parsed) ? parsed : null;
12
12
  }
13
13
 
14
+ // A thought's place in its turn's order (migration 0038), or null.
15
+ function normalizeTurnSeq(value) {
16
+ if (value === null || value === undefined || value === '') return null;
17
+ const num = Number(value);
18
+ return Number.isInteger(num) && num > 0 ? num : null;
19
+ }
20
+
14
21
  function toThoughtEntry(value, order) {
15
22
  const text = String(value?.text || '');
16
23
  if (!text.trim()) return null;
@@ -19,6 +26,7 @@ function toThoughtEntry(value, order) {
19
26
  const subagentRunId = String(value?.subagentRunId || '').trim() || null;
20
27
  return {
21
28
  reasoningId,
29
+ turnSeq: normalizeTurnSeq(value?.turnSeq),
22
30
  seq: normalizeSeq(value?.seq),
23
31
  text,
24
32
  done: !!value?.done,
@@ -75,7 +83,12 @@ export function normalizeRelayThoughtList(thoughts = []) {
75
83
  }
76
84
  const existing = byReasoningId.get(entry.reasoningId);
77
85
  if (!existing || isCandidateNewer(entry, existing)) {
78
- byReasoningId.set(entry.reasoningId, entry);
86
+ // The text goes to the newer copy; the turn order stays the first one
87
+ // known (a thought sits where its first chunk arrived).
88
+ const turnSeq = existing?.turnSeq ?? entry.turnSeq;
89
+ byReasoningId.set(entry.reasoningId, { ...entry, turnSeq });
90
+ } else if (existing.turnSeq === null && entry.turnSeq !== null) {
91
+ existing.turnSeq = entry.turnSeq;
79
92
  }
80
93
  }
81
94
 
@@ -87,6 +100,7 @@ export function normalizeRelayThoughtList(thoughts = []) {
87
100
  done: !!entry.done,
88
101
  timestamp: entry.timestamp || null,
89
102
  subagentRunId: entry.subagentRunId || null,
103
+ ...(entry.turnSeq !== null ? { turnSeq: entry.turnSeq } : {}),
90
104
  }));
91
105
  }
92
106
 
@@ -96,6 +96,7 @@ function overlayDescriptors(closers) {
96
96
  { name: 'tmux-inspector-modal', find: byId('tmux-inspector-modal'), isOpen: isVisible, close: closers.closeTmuxInspector },
97
97
  { name: 'image-annotate-modal', find: byId('image-annotate-modal'), isOpen: isVisible, close: closers.closeAnnotateEditor },
98
98
  { name: 'relay-watch-panel', find: byId('relay-watch-panel'), isOpen: isShown, close: closers.closeRelayWatchPanel },
99
+ { name: 'questions-panel', find: byId('questions-panel'), isOpen: isShown, close: closers.closeQuestionsPanel },
99
100
  { name: 'relay-schedule-panel', find: byId('relay-schedule-panel'), isOpen: isShown, close: closers.closeRelaySchedulePanel },
100
101
  { name: 'emoji-popup', find: byId('emoji-popup'), isOpen: isVisible, close: closers.closeEmojiPicker },
101
102
  // A row's menu (right-click, long press, ⓘ): on <body> while open.
@@ -82,6 +82,9 @@ import { refreshRemoteRelaysSection } from './remote-relays-settings.js';
82
82
  import { refreshAgentSessionsSection } from './agent-sessions-settings-ui.js';
83
83
  import { refreshSharedConversationsSection } from './shared-conversations-settings.js';
84
84
  import { syncScrollOnSendToggle } from './transcript-jump-bottom.mjs';
85
+ import { syncThoughtsExpandedToggle } from './thoughts-preference.mjs';
86
+ import { syncLiveActivityCapSlider } from './live-activity-cap.mjs';
87
+ import { syncStreamViewToggle } from './stream-view-preference.mjs';
85
88
  import { syncWindowRetentionSlider } from './conversation-window-retention.mjs';
86
89
  import {
87
90
  getPreviews,
@@ -1942,6 +1945,9 @@ export function openSettingsModal(tab, providerTab) {
1942
1945
  }
1943
1946
  syncSuspendHostVisibility();
1944
1947
  syncScrollOnSendToggle();
1948
+ syncThoughtsExpandedToggle();
1949
+ syncLiveActivityCapSlider();
1950
+ syncStreamViewToggle();
1945
1951
  syncWindowRetentionSlider();
1946
1952
  syncFontScaleSelect();
1947
1953
  syncPwaAppNameInput();
@@ -33,7 +33,8 @@ import {
33
33
  IS_SHARED_VIEW,
34
34
  } from './store.js';
35
35
  import { scheduleContextUsageRefresh } from './api-client.js';
36
- import { capLiveActivityEntries } from './activity-replay-state.mjs';
36
+ import { capFinishedActivityEntries, capLiveActivityEntries, normalizeTurnSeq } from './activity-replay-state.mjs';
37
+ import { liveActivityCap } from './live-activity-cap.mjs';
37
38
  import { publishStatusEvent, recordStatusEvent } from './status-store.mjs';
38
39
  import { clearOpenConversation, renderConvList, refreshConversations, scheduleLoadedConversationsRefresh, applyConversationGroupEvent, openConversation } from './journal-view.js';
39
40
  import { applyBackgroundTasksToConversation, applyTurnStatusToConversation } from './conversation-turn-state.mjs';
@@ -50,6 +51,7 @@ import { upsertRelayBoard, loadRelayBoards, renderRelayBoards } from './relay-bo
50
51
  import { setConversationBackgroundTasks, setConversationTaskHealth } from './background-tasks-view.mjs';
51
52
  import { handleTaskConsoleReconnect, handleTaskOutputChunk } from './task-console.mjs';
52
53
  import { setConversationPins } from './pinned-messages-view.mjs';
54
+ import { noteQuestionAskUpdated, refreshQuestions } from './questions-panel-ui.js';
53
55
  import { setConversationHiddenMessages } from './hidden-messages-view.mjs';
54
56
  import { setPreviews } from './preview-cards.mjs';
55
57
  import { applyShareSettings, hasActiveShare, refreshShares } from './share-state.js';
@@ -377,8 +379,10 @@ export async function connectSocket(overrideDeps) {
377
379
  void loadRelayEventSubscriptions();
378
380
  // Schedules that changed or fired while away.
379
381
  void loadRelaySchedules();
380
- // Questions asked or answered while away.
382
+ // Questions asked or answered while away: the Inbox and the open
383
+ // conversation's 📓.
381
384
  void refreshInbox();
385
+ void refreshQuestions();
382
386
  // Shares made or stopped while away.
383
387
  void refreshShares().catch(() => {});
384
388
  });
@@ -600,7 +604,8 @@ export async function connectSocket(overrideDeps) {
600
604
  }
601
605
  if ((!message?.activities || !message.activities.length) && sourceMessageId) {
602
606
  const cached = relayActivities.get(sourceMessageId) || [];
603
- if (cached.length) message.activities = cached.slice(0, 48);
607
+ // The finished turn's cap (newest rows per thread), as the relay sends it.
608
+ if (cached.length) message.activities = capFinishedActivityEntries(cached);
604
609
  }
605
610
  if (sourceMessageId) {
606
611
  const persistedThoughts = Array.isArray(message?.thoughts) ? message.thoughts : [];
@@ -669,7 +674,10 @@ export async function connectSocket(overrideDeps) {
669
674
  // A paired relay's pending cards changed (this relay checks them every ~20 s).
670
675
  socket.on(INBOX_REMOTE_CHANGED_EVENT, () => noteInboxQuestionsChanged());
671
676
  // Questions that don't block: one ask, after every change of it.
672
- socket.on(RELAY_ASK_SOCKET_EVENT, (payload) => noteRelayAskUpdated(payload));
677
+ socket.on(RELAY_ASK_SOCKET_EVENT, (payload) => {
678
+ noteRelayAskUpdated(payload);
679
+ noteQuestionAskUpdated(payload?.ask);
680
+ });
673
681
  socket.on('relay_board', ({ board }) => upsertRelayBoard(board));
674
682
  socket.on('relay_board_updated', ({ board }) => upsertRelayBoard(board));
675
683
  socket.on('relay_board_changed', () => {
@@ -752,21 +760,36 @@ export async function connectSocket(overrideDeps) {
752
760
  if (payload && typeof payload === 'object') applyAgentSessionsSettingsState(payload);
753
761
  else void refreshAgentSessionsSection();
754
762
  });
755
- socket.on('relay_activity', ({ conversationId, messageId, text, subagentRunId, metadata }) => {
763
+ socket.on('relay_activity', ({ conversationId, messageId, text, subagentRunId, metadata, turnSeq }) => {
756
764
  if (!messageId || !text) return;
765
+ const numbered = normalizeTurnSeq(turnSeq);
757
766
  const entry = {
758
767
  text: String(text || '').trim(),
759
768
  subagentRunId: subagentRunId ? String(subagentRunId).trim() : null,
760
769
  metadata: (metadata && typeof metadata === 'object' && !Array.isArray(metadata)) ? metadata : null,
770
+ ...(numbered !== null ? { turnSeq: numbered } : {}),
761
771
  };
762
772
  if (!entry.text) return;
763
773
  const items = relayActivities.get(messageId) || [];
764
774
  const last = items[items.length - 1];
765
775
  const lastText = typeof last === 'string' ? last : String(last?.text || '');
766
776
  const lastSubagentRunId = typeof last === 'object' && last ? (last.subagentRunId || null) : null;
767
- // The same line again (a repeated event) adds nothing.
768
- const added = lastText !== entry.text || lastSubagentRunId !== entry.subagentRunId;
769
- if (added) relayActivities.set(messageId, capLiveActivityEntries(items.concat(entry)));
777
+ // The same event again (a replay after a reconnect) adds nothing. A
778
+ // numbered row is known by its number, so the same tool line run twice
779
+ // is two rows; an unnumbered one (an older relay) by being the last line.
780
+ const added = numbered !== null
781
+ ? !items.some((item) => item?.turnSeq === numbered)
782
+ : (lastText !== entry.text || lastSubagentRunId !== entry.subagentRunId);
783
+ if (numbered !== null && !added) return;
784
+ if (added) {
785
+ // A numbered list stays in the turn's order, so a row replayed after
786
+ // the cap trimmed it sorts to the front and is trimmed again.
787
+ let next = items.concat(entry);
788
+ if (numbered !== null && next.every((item) => item?.turnSeq != null)) next = next.sort((a, b) => a.turnSeq - b.turnSeq);
789
+ const kept = capLiveActivityEntries(next, liveActivityCap());
790
+ relayActivities.set(messageId, kept);
791
+ if (numbered !== null && !kept.includes(entry)) return;
792
+ }
770
793
  if (entry.subagentRunId) {
771
794
  upsertSubagentRun({ subagentRunId: entry.subagentRunId, messageId, conversationId });
772
795
  addSubagentActivity(entry.subagentRunId, entry.text);
@@ -795,14 +818,22 @@ export async function connectSocket(overrideDeps) {
795
818
  // A frame that was dropped (out of order, a finished turn) is no news.
796
819
  if (accepted && !autoScroll) noteTranscriptNews();
797
820
  });
798
- socket.on('relay_thought', ({ conversationId, messageId, reasoningId, text, done, subagentRunId }) => {
821
+ socket.on('relay_thought', ({ conversationId, messageId, reasoningId, text, done, subagentRunId, turnSeq }) => {
799
822
  if (!messageId) return;
800
823
  const key = String(reasoningId || 'reasoning');
801
824
  const thoughtMap = relayThoughts.get(messageId) || new Map();
802
825
  const known = thoughtMap.get(key);
803
826
  // The same thought again (a repeated event) adds nothing.
804
827
  const added = !known || known.text !== String(text || '') || known.done !== !!done;
805
- thoughtMap.set(key, { reasoningId: key, text: String(text || ''), done: !!done, subagentRunId: subagentRunId || null });
828
+ // A thought keeps the place of its first chunk.
829
+ const keptTurnSeq = known?.turnSeq ?? normalizeTurnSeq(turnSeq);
830
+ thoughtMap.set(key, {
831
+ reasoningId: key,
832
+ text: String(text || ''),
833
+ done: !!done,
834
+ subagentRunId: subagentRunId || null,
835
+ ...(keptTurnSeq != null ? { turnSeq: keptTurnSeq } : {}),
836
+ });
806
837
  relayThoughts.set(messageId, thoughtMap);
807
838
  if (subagentRunId) {
808
839
  upsertSubagentRun({ subagentRunId, messageId, conversationId });
@@ -810,7 +841,7 @@ export async function connectSocket(overrideDeps) {
810
841
  }
811
842
  if (conversationId === currentConvId) {
812
843
  const autoScroll = followsTranscriptEnd();
813
- appendThinkingThought(key, String(text || ''), !!done, subagentRunId, autoScroll);
844
+ appendThinkingThought(key, String(text || ''), !!done, subagentRunId, autoScroll, messageId);
814
845
  if (added && !autoScroll) noteTranscriptNews();
815
846
  }
816
847
  });
@@ -0,0 +1,60 @@
1
+ // "Stream view" (Settings › General › Appearance, and its mirror in the 🧠
2
+ // modal): an agent turn's thoughts, tool calls and reply as one list in the
3
+ // order they happened, live and in the finished message. A setting of this
4
+ // browser, kept in localStorage like the thoughts one; off unless switched
5
+ // on. Turns recorded before the relay numbered their rows keep the grouped
6
+ // layout either way (turn-stream-view.mjs). The document, the storage and
7
+ // the event target are passed in by the tests.
8
+
9
+ export const STREAM_VIEW_STORAGE_KEY = 'copilot_stream_view';
10
+ export const STREAM_VIEW_TOGGLE_ID = 'stream-view-toggle';
11
+ // The page re-renders its transcript and the live bubble on this.
12
+ export const STREAM_VIEW_CHANGE_EVENT = 'oar:stream-view-change';
13
+ // The 🧠 modal's two buttons: `data-stream-view` is "on" or "off".
14
+ export const STREAM_VIEW_BUTTON_SELECTOR = '.ctx-stream-view-btn[data-stream-view]';
15
+
16
+ /** Only an explicit on is on: a missing or damaged value reads off. */
17
+ export function parseStreamViewSetting(stored) {
18
+ const value = String(stored ?? '').trim().toLowerCase();
19
+ return value === '1' || value === 'true';
20
+ }
21
+
22
+ export function serializeStreamViewSetting(on) {
23
+ return on ? '1' : '0';
24
+ }
25
+
26
+ function storage() {
27
+ try { return globalThis.localStorage || null; } catch { return null; }
28
+ }
29
+
30
+ export function isStreamViewEnabled(store = storage()) {
31
+ try {
32
+ return parseStreamViewSetting(store?.getItem(STREAM_VIEW_STORAGE_KEY));
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ /** The Settings checkbox and the 🧠 buttons, wherever they are rendered. */
39
+ export function syncStreamViewToggle(doc = globalThis.document, store = storage()) {
40
+ const on = isStreamViewEnabled(store);
41
+ const checkbox = doc?.getElementById?.(STREAM_VIEW_TOGGLE_ID);
42
+ if (checkbox) checkbox.checked = on;
43
+ const buttons = doc?.querySelectorAll?.(STREAM_VIEW_BUTTON_SELECTOR) || [];
44
+ for (const btn of buttons) {
45
+ const active = (btn.dataset?.streamView === 'on') === on;
46
+ btn.classList?.toggle('is-active', active);
47
+ btn.setAttribute?.('aria-pressed', active ? 'true' : 'false');
48
+ }
49
+ }
50
+
51
+ export function updateStreamViewSetting(on, { doc = globalThis.document, store = storage(), target = globalThis.window } = {}) {
52
+ const value = !!on;
53
+ const before = isStreamViewEnabled(store);
54
+ try { store?.setItem(STREAM_VIEW_STORAGE_KEY, serializeStreamViewSetting(value)); } catch {}
55
+ syncStreamViewToggle(doc, store);
56
+ if (before === isStreamViewEnabled(store)) return;
57
+ try {
58
+ target?.dispatchEvent?.(new CustomEvent(STREAM_VIEW_CHANGE_EVENT, { detail: { on: value } }));
59
+ } catch {}
60
+ }
@@ -35,13 +35,16 @@ function oneLine(text) {
35
35
  /**
36
36
  * The line a folded header shows: the number of steps (activity lines) and
37
37
  * the latest one, or the end of what the run has written when it has taken
38
- * no step yet. Empty when there is nothing to say.
38
+ * no step yet. Empty when there is nothing to say. `cap` is how many lines
39
+ * the bubble keeps (the "Tool lines shown" setting, 0 = all): a bubble that
40
+ * holds that many has likely dropped older ones, so the count reads "50+".
39
41
  */
40
- export function subagentFoldSummary({ activities = [], streamText = '' } = {}) {
42
+ export function subagentFoldSummary({ activities = [], streamText = '', cap = 0 } = {}) {
41
43
  const steps = (Array.isArray(activities) ? activities : []).map(oneLine).filter(Boolean);
42
44
  if (steps.length) {
43
45
  const latest = steps[steps.length - 1];
44
- const count = steps.length === 1 ? '1 step' : `${steps.length} steps`;
46
+ const atCap = Number(cap) > 0 && steps.length >= Number(cap);
47
+ const count = atCap ? `${steps.length}+ steps` : (steps.length === 1 ? '1 step' : `${steps.length} steps`);
45
48
  return `${count} · ${latest}`.slice(0, SUMMARY_MAX_CHARS);
46
49
  }
47
50
  const lines = String(streamText || '').split(/\r?\n/).map(oneLine).filter(Boolean);
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { prefixToolActivityEmoji } from './tool-activity-emoji.mjs';
6
6
  import { subagentRowHtml } from './subagent-row.mjs';
7
+ import { isThoughtsExpanded, thoughtFoldOpen } from './thoughts-preference.mjs';
7
8
 
8
9
  function escHtml(value) {
9
10
  return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
@@ -100,20 +101,26 @@ function firstLine(text, max = 140) {
100
101
  /**
101
102
  * One entry's markup. `renderMarkdown(text)` renders reply text (and
102
103
  * thoughts); `children` maps a child run id to its header so a child entry
103
- * renders as a clickable row; `openDetails` keeps a fold the reader opened
104
- * open across re-renders.
104
+ * renders as a clickable row; `openDetails` keeps the reader's folds across
105
+ * re-renders: a Map or a function from seq to 'open' | 'folded' | undefined
106
+ * (`true` still means "open"). A thought without a choice, streaming or
107
+ * done, opens as `thoughtsExpanded` (the "Expand thoughts" setting) says.
105
108
  */
106
- export function timelineEntryHtml(entry, { renderMarkdown = (text) => `<p>${escHtml(text)}</p>`, children = new Map(), openDetails = false, clickable = true, now = Date.now() } = {}) {
109
+ export function timelineEntryHtml(entry, { renderMarkdown = (text) => `<p>${escHtml(text)}</p>`, children = new Map(), openDetails = false, thoughtsExpanded = isThoughtsExpanded(), clickable = true, now = Date.now() } = {}) {
107
110
  const kind = String(entry?.kind || '');
108
111
  const seq = Number(entry?.seq) || 0;
109
112
  const open = entry?.done === false;
110
113
  const text = String(entry?.text || '');
111
114
  const wrap = (inner, extraClass = '') => `<div class="sav-entry sav-entry-${escHtml(kind)}${open ? ' is-open' : ''}${extraClass}" data-seq="${seq}">${inner}</div>`;
112
- const openAttr = openDetails ? ' open' : '';
115
+ const choice = typeof openDetails === 'function'
116
+ ? openDetails(seq)
117
+ : (openDetails instanceof Map ? openDetails.get(seq) : (openDetails === true ? 'open' : undefined));
118
+ const openAttr = choice === 'open' ? ' open' : '';
113
119
 
114
120
  if (kind === 'thought') {
115
121
  const label = open && !text.trim() ? 'Thinking…' : (firstLine(text, 110) || 'Thought');
116
- return wrap(`<details class="sav-fold"${open || openDetails ? ' open' : ''}><summary><span class="sav-kind">💭</span> <span class="sav-summary-text">${escHtml(label)}</span></summary><div class="sav-thought-body markdown-body">${renderMarkdown(text)}</div></details>`);
122
+ const thoughtOpen = thoughtFoldOpen({ choice: choice || null, expanded: thoughtsExpanded });
123
+ return wrap(`<details class="sav-fold"${thoughtOpen ? ' open' : ''}><summary><span class="sav-kind">💭</span> <span class="sav-summary-text">${escHtml(label)}</span></summary><div class="sav-thought-body markdown-body">${renderMarkdown(text)}</div></details>`);
117
124
  }
118
125
 
119
126
  if (kind === 'text') {
@@ -50,7 +50,9 @@ const viewer = {
50
50
  stopTimer: null,
51
51
  tickTimer: null,
52
52
  sharedPollTimer: null,
53
- openFolds: new Set(),
53
+ // The reader's folds by seq ('open' | 'folded'); a thought without one
54
+ // follows the "Expand thoughts" setting.
55
+ openFolds: new Map(),
54
56
  };
55
57
 
56
58
  let viewerHistory = null;
@@ -151,7 +153,7 @@ function buildEntryNode(entry) {
151
153
  holder.innerHTML = timelineEntryHtml(entry, {
152
154
  renderMarkdown,
153
155
  children: viewer.children,
154
- openDetails: viewer.openFolds.has(entry.seq),
156
+ openDetails: viewer.openFolds,
155
157
  clickable: true,
156
158
  });
157
159
  return holder.firstElementChild;
@@ -283,7 +285,7 @@ async function loadRun(runId) {
283
285
  if (!id) return;
284
286
  viewer.state = createTimelineState(id);
285
287
  viewer.children = new Map();
286
- viewer.openFolds = new Set();
288
+ viewer.openFolds = new Map();
287
289
  viewer.error = '';
288
290
  viewer.stopping = false;
289
291
  clearTimeout(viewer.stopTimer);
@@ -552,7 +554,8 @@ export function initSubagentViewer() {
552
554
  body?.addEventListener('scroll', () => {
553
555
  if (jump && !jump.hidden && isAtBottom(body)) jump.hidden = true;
554
556
  }, { passive: true });
555
- // A fold the reader opened stays open while the entry keeps changing.
557
+ // A fold the reader opened or closed stays that way while the entry keeps
558
+ // changing.
556
559
  timeline?.addEventListener('click', (event) => {
557
560
  const summary = event.target?.closest?.('summary');
558
561
  const entry = summary?.closest?.('.sav-entry');
@@ -560,8 +563,7 @@ export function initSubagentViewer() {
560
563
  const seq = Number(entry.dataset.seq);
561
564
  setTimeout(() => {
562
565
  const details = summary.parentElement;
563
- if (details?.open) viewer.openFolds.add(seq);
564
- else viewer.openFolds.delete(seq);
566
+ viewer.openFolds.set(seq, details?.open ? 'open' : 'folded');
565
567
  }, 0);
566
568
  });
567
569
  document.addEventListener('keydown', (event) => {
@@ -0,0 +1,79 @@
1
+ // "Expand thoughts while an agent works" (Settings › General, and its mirror
2
+ // in the 🧠 modal). A setting of this browser, kept in localStorage like the
3
+ // scroll-on-send one. On unless switched off: it decides whether a thought
4
+ // starts open in the live turn, in a live sub-agent bubble and in the
5
+ // sub-agent viewer. A fold the reader opened or closed by hand wins over it.
6
+ // A finished message's 💭 fold stays closed either way. The document, the
7
+ // storage and the event target are passed in by the tests.
8
+
9
+ export const THOUGHTS_EXPANDED_STORAGE_KEY = 'copilot_thoughts_expanded';
10
+ export const THOUGHTS_EXPANDED_TOGGLE_ID = 'thoughts-expanded-toggle';
11
+ export const THOUGHTS_EXPANDED_CHANGE_EVENT = 'oar:thoughts-expanded-change';
12
+ // The 🧠 modal's two buttons: `data-thoughts-expanded` is "on" or "off".
13
+ export const THOUGHTS_EXPANDED_BUTTON_SELECTOR = '.ctx-thoughts-btn[data-thoughts-expanded]';
14
+
15
+ /** Anything but an explicit off is on: a missing or damaged value included. */
16
+ export function parseThoughtsExpandedSetting(stored) {
17
+ const value = String(stored ?? '').trim().toLowerCase();
18
+ if (!value) return true;
19
+ return value !== '0' && value !== 'false';
20
+ }
21
+
22
+ export function serializeThoughtsExpandedSetting(expanded) {
23
+ return expanded ? '1' : '0';
24
+ }
25
+
26
+ function storage() {
27
+ try { return globalThis.localStorage || null; } catch { return null; }
28
+ }
29
+
30
+ export function isThoughtsExpanded(store = storage()) {
31
+ try {
32
+ return parseThoughtsExpandedSetting(store?.getItem(THOUGHTS_EXPANDED_STORAGE_KEY));
33
+ } catch {
34
+ return true;
35
+ }
36
+ }
37
+
38
+ /** The Settings checkbox and the 🧠 buttons, wherever they are rendered. */
39
+ export function syncThoughtsExpandedToggle(doc = globalThis.document, store = storage()) {
40
+ const expanded = isThoughtsExpanded(store);
41
+ const checkbox = doc?.getElementById?.(THOUGHTS_EXPANDED_TOGGLE_ID);
42
+ if (checkbox) checkbox.checked = expanded;
43
+ const buttons = doc?.querySelectorAll?.(THOUGHTS_EXPANDED_BUTTON_SELECTOR) || [];
44
+ for (const btn of buttons) {
45
+ const active = (btn.dataset?.thoughtsExpanded === 'on') === expanded;
46
+ btn.classList?.toggle('is-active', active);
47
+ btn.setAttribute?.('aria-pressed', active ? 'true' : 'false');
48
+ }
49
+ }
50
+
51
+ export function updateThoughtsExpandedSetting(expanded, { doc = globalThis.document, store = storage(), target = globalThis.window } = {}) {
52
+ const value = !!expanded;
53
+ try { store?.setItem(THOUGHTS_EXPANDED_STORAGE_KEY, serializeThoughtsExpandedSetting(value)); } catch {}
54
+ syncThoughtsExpandedToggle(doc, store);
55
+ try {
56
+ target?.dispatchEvent?.(new CustomEvent(THOUGHTS_EXPANDED_CHANGE_EVENT, { detail: { expanded: value } }));
57
+ } catch {}
58
+ }
59
+
60
+ /**
61
+ * Whether a thought fold shows open. `choice` is what the reader did by hand
62
+ * ('open' | 'folded'), and it wins; everything else, a thought still
63
+ * streaming included, follows the setting (`expanded`).
64
+ */
65
+ export function thoughtFoldOpen({ choice = null, expanded = true } = {}) {
66
+ if (choice === 'open') return true;
67
+ if (choice === 'folded') return false;
68
+ return !!expanded;
69
+ }
70
+
71
+ export const MAX_THOUGHT_FOLD_CHOICES = 500;
72
+
73
+ /** Record a hand fold; the oldest choice goes once the map is full. */
74
+ export function rememberThoughtFoldChoice(choices, key, open, max = MAX_THOUGHT_FOLD_CHOICES) {
75
+ if (!choices || key === undefined || key === null || key === '') return;
76
+ choices.delete(key);
77
+ while (choices.size >= max) choices.delete(choices.keys().next().value);
78
+ choices.set(key, open ? 'open' : 'folded');
79
+ }