@oar-sh/oar 0.9.9 → 0.9.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/CHANGELOG.md +561 -1
  2. package/README.md +162 -909
  3. package/bin/bat/oar.bat +46 -46
  4. package/package.json +14 -14
  5. package/server/README.md +369 -13
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  7. package/server/claude-worker/claude-session-process.mjs +121 -9
  8. package/server/claude-worker/claude-session-worker.mjs +9 -1
  9. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  10. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  11. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  12. package/server/copilot-worker/copilot-sdk-session-process.mjs +79 -12
  13. package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
  14. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  15. package/server/db-schema.mjs +95 -2
  16. package/server/grok-worker/acp-client.mjs +2 -1
  17. package/server/grok-worker/acp-host-services.mjs +4 -3
  18. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  19. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  20. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  21. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  22. package/server/migrations/0013-previews.mjs +130 -0
  23. package/server/migrations/0014-conversation-groups.mjs +89 -0
  24. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  25. package/server/migrations/0016-share-access-log.mjs +110 -0
  26. package/server/public/app/about-ui.js +73 -0
  27. package/server/public/app/about-view.mjs +234 -0
  28. package/server/public/app/action-confirmations.js +31 -14
  29. package/server/public/app/agent-exchange-fold-view.mjs +187 -0
  30. package/server/public/app/api-client.js +312 -16
  31. package/server/public/app/archive-confirm.mjs +28 -0
  32. package/server/public/app/ask-user-view.js +17 -12
  33. package/server/public/app/attachments-view.js +516 -6
  34. package/server/public/app/background-tasks-view.mjs +18 -26
  35. package/server/public/app/bootstrap.js +336 -59
  36. package/server/public/app/chat-actions-menu-state.mjs +126 -0
  37. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  38. package/server/public/app/composer-control-state.mjs +16 -0
  39. package/server/public/app/composer-keyboard.mjs +54 -0
  40. package/server/public/app/context-menu.mjs +90 -6
  41. package/server/public/app/conversation-group-drag.mjs +191 -0
  42. package/server/public/app/conversation-group-order.mjs +265 -0
  43. package/server/public/app/conversation-group-state.mjs +100 -0
  44. package/server/public/app/conversation-list-drag.mjs +449 -0
  45. package/server/public/app/conversation-turn-state.mjs +81 -0
  46. package/server/public/app/conversation-view.js +436 -117
  47. package/server/public/app/cwd-picker.js +1 -1
  48. package/server/public/app/explorer-edit-state.mjs +68 -0
  49. package/server/public/app/hidden-messages-view.mjs +312 -0
  50. package/server/public/app/host-suspend-ui.js +13 -4
  51. package/server/public/app/journal-view.js +930 -45
  52. package/server/public/app/no-conversation-state.mjs +58 -0
  53. package/server/public/app/preview-cards.mjs +117 -31
  54. package/server/public/app/preview-command.mjs +10 -1
  55. package/server/public/app/provider-readiness.mjs +120 -0
  56. package/server/public/app/relay-board-view.js +19 -3
  57. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  58. package/server/public/app/relay-event-view.mjs +234 -0
  59. package/server/public/app/relay-restart-error.mjs +16 -0
  60. package/server/public/app/settings-modal.js +93 -9
  61. package/server/public/app/settings-tabs.js +1 -1
  62. package/server/public/app/share-format.mjs +49 -0
  63. package/server/public/app/share-preview-marks.mjs +167 -0
  64. package/server/public/app/share-state.js +109 -0
  65. package/server/public/app/shared-conversations-settings.js +480 -0
  66. package/server/public/app/socket-handlers.js +137 -40
  67. package/server/public/app/store.js +115 -32
  68. package/server/public/app/subagent-row.mjs +138 -0
  69. package/server/public/app/subagent-timeline-view.mjs +186 -0
  70. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  71. package/server/public/app/subagent-viewer.mjs +558 -0
  72. package/server/public/app/task-row-format.mjs +29 -0
  73. package/server/public/app/transcript-jump-bottom-view.mjs +464 -0
  74. package/server/public/app/transcript-jump-bottom.mjs +210 -0
  75. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  76. package/server/public/app/transcript-viewport-anchor.mjs +140 -0
  77. package/server/public/app/update-ui.js +1 -1
  78. package/server/public/app/viewer-history.mjs +5 -3
  79. package/server/public/index.html +774 -12
  80. package/server/relay-tools.md +2 -1
  81. package/server/relay.mjs +2 -1
  82. package/server/repositories/conversation-group-repository.mjs +444 -0
  83. package/server/repositories/message-repository.mjs +30 -3
  84. package/server/repositories/question-repository.mjs +9 -3
  85. package/server/repositories/remote-relay-repository.mjs +6 -2
  86. package/server/repositories/session-repository.mjs +12 -37
  87. package/server/repositories/share-repository.mjs +322 -0
  88. package/server/repositories/share-visibility-statements.mjs +10 -0
  89. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  90. package/server/routes/about-routes.mjs +15 -0
  91. package/server/routes/ask-user-routes.mjs +3 -0
  92. package/server/routes/cache-routes.mjs +4 -0
  93. package/server/routes/conversation-group-routes.mjs +171 -0
  94. package/server/routes/messages-routes.mjs +698 -114
  95. package/server/routes/preview-routes.mjs +9 -1
  96. package/server/routes/relay-event-routes.mjs +157 -0
  97. package/server/routes/remote-relay-routes.mjs +15 -0
  98. package/server/routes/sessions-routes.mjs +843 -193
  99. package/server/server-runtime.mjs +559 -160
  100. package/server/services/about-service.mjs +461 -0
  101. package/server/services/background-task-store.mjs +128 -0
  102. package/server/services/background-work-view.mjs +228 -0
  103. package/server/services/cache-rebuild-service.mjs +30 -0
  104. package/server/services/delete-archive-service.mjs +4 -1
  105. package/server/services/detached-worker-watch-service.mjs +150 -0
  106. package/server/services/explorer-entry-name.mjs +26 -0
  107. package/server/services/explorer-mutations.mjs +122 -0
  108. package/server/services/hidden-message-list.mjs +21 -0
  109. package/server/services/host-suspend-command.mjs +2 -1
  110. package/server/services/message-pin-list.mjs +1 -1
  111. package/server/services/preview-health-probe.mjs +8 -1
  112. package/server/services/preview-proxy-server.mjs +49 -27
  113. package/server/services/preview-registry-service.mjs +676 -26
  114. package/server/services/preview-static-handler.mjs +40 -7
  115. package/server/services/preview-store.mjs +274 -0
  116. package/server/services/push-dispatch-service.mjs +18 -0
  117. package/server/services/rate-limit-service.mjs +12 -1
  118. package/server/services/relay-auth.mjs +183 -0
  119. package/server/services/relay-cli-launcher-service.mjs +3 -1
  120. package/server/services/relay-event-link-service.mjs +289 -0
  121. package/server/services/relay-event-service.mjs +1952 -0
  122. package/server/services/relay-shutdown-gate.mjs +31 -0
  123. package/server/services/remote-relay-caller-context.mjs +52 -7
  124. package/server/services/remote-relay-dispatcher.mjs +551 -7
  125. package/server/services/remote-relay-registry-service.mjs +11 -1
  126. package/server/services/request-boundary.mjs +183 -0
  127. package/server/services/session-activity-tracker.mjs +695 -0
  128. package/server/services/session-history-refresh-service.mjs +7 -0
  129. package/server/services/session-worker-launch-service.mjs +190 -6
  130. package/server/services/session-worker-process-service.mjs +10 -6
  131. package/server/services/share-access-log-service.mjs +123 -0
  132. package/server/services/share-path-redaction.mjs +424 -0
  133. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  134. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  135. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  136. package/server/services/status-event-service.mjs +7 -3
  137. package/server/services/subagent-record-retention.mjs +164 -0
  138. package/server/services/subagent-timeline-service.mjs +328 -0
  139. package/server/services/subagent-viewer-rooms.mjs +119 -0
  140. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  141. package/server/services/upgrade-socket-guard.mjs +16 -0
  142. package/server/services/windows-autostart-service.mjs +6 -1
  143. package/server/services/windows-boot-autostart-service.mjs +207 -23
  144. package/shared/preview-tool-core.mjs +15 -5
  145. package/shared/relay-events.mjs +371 -0
  146. package/shared/remote-relay-contract.mjs +160 -6
  147. package/shared/share-path-privacy.mjs +30 -0
  148. package/shared/subagent-record-retention.mjs +31 -0
  149. package/shared/subagent-timeline.mjs +79 -0
  150. package/shared/windows-system-tools.mjs +106 -0
  151. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  152. package/shared/worker-runtime/heartbeat.mjs +24 -0
  153. package/shared/worker-runtime/kept-posts.mjs +83 -0
  154. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -0,0 +1,265 @@
1
+ // The order of the conversation list once sessions can form groups, shared by
2
+ // the list route (paging) and the sidebar (rendering) so both agree row for
3
+ // row. DOM-free.
4
+ //
5
+ // A grouped row sorts by its group's activity time: the newest `updatedAt`
6
+ // among the group's rows in the list at hand (the live list, the Archived
7
+ // view, or both). So an active member lifts its whole group. Inside a group
8
+ // the head comes first, then the members the owner placed by hand (their
9
+ // `groupPosition`, ascending), then the others newest first. An ungrouped row
10
+ // sorts by its own time. Ties fall back to ids, descending, as the list
11
+ // always has.
12
+ //
13
+ // A page never ends inside a group: the server extends it to the group's last
14
+ // row, so a client always holds whole groups and can count and fold them.
15
+
16
+ function toText(value) {
17
+ return String(value ?? '').trim();
18
+ }
19
+
20
+ /** `updatedAt` as milliseconds: ISO, SQLite's "YYYY-MM-DD HH:MM:SS" (UTC), or a number. */
21
+ export function parseConversationTimeMs(value) {
22
+ if (typeof value === 'number') return Number.isFinite(value) ? value : 0;
23
+ const text = toText(value);
24
+ if (!text) return 0;
25
+ const sqliteUtcLike = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?)$/;
26
+ const hasExplicitTimezone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(text);
27
+ let normalized = text;
28
+ if (!hasExplicitTimezone && sqliteUtcLike.test(text)) {
29
+ const [, day, time] = text.match(sqliteUtcLike) || [];
30
+ normalized = `${day}T${time}Z`;
31
+ }
32
+ const parsed = Date.parse(normalized);
33
+ return Number.isFinite(parsed) ? parsed : 0;
34
+ }
35
+
36
+ function rowId(row) {
37
+ return toText(row?.id);
38
+ }
39
+
40
+ function rowGroupId(row) {
41
+ return toText(row?.groupId ?? row?.group_id);
42
+ }
43
+
44
+ function rowOwnTimeMs(row) {
45
+ return parseConversationTimeMs(row?.updatedAt ?? row?.updated_at);
46
+ }
47
+
48
+ /** A row's hand-set place in its group (`groupPosition` / `group_position`), or null. */
49
+ export function conversationGroupPosition(row) {
50
+ const raw = row?.groupPosition ?? row?.group_position;
51
+ if (raw === null || raw === undefined || raw === '') return null;
52
+ const value = Number(raw);
53
+ return Number.isFinite(value) ? value : null;
54
+ }
55
+
56
+ /**
57
+ * The order of two rows of the same group after the head: a placed row
58
+ * before an unplaced one, placed rows by position, unplaced ones newest
59
+ * first; ties by id, descending. The sidebar and the relay sort members
60
+ * with it.
61
+ */
62
+ export function compareGroupMembers(a, b) {
63
+ const pa = conversationGroupPosition(a);
64
+ const pb = conversationGroupPosition(b);
65
+ if (pa !== null && pb === null) return -1;
66
+ if (pa === null && pb !== null) return 1;
67
+ if (pa !== null && pb !== null && pa !== pb) return pa - pb;
68
+ const ua = rowOwnTimeMs(a);
69
+ const ub = rowOwnTimeMs(b);
70
+ if (ua !== ub) return ub - ua;
71
+ return rowId(b).localeCompare(rowId(a));
72
+ }
73
+
74
+ function groupOf(groups, groupId) {
75
+ if (!groups || !groupId) return null;
76
+ if (typeof groups.get === 'function') return groups.get(groupId) || null;
77
+ return groups[groupId] || null;
78
+ }
79
+
80
+ /** 'head' | 'member' | null for a row, from the groups' `parentConversationId`. */
81
+ export function conversationGroupRole(row, groups) {
82
+ const groupId = rowGroupId(row);
83
+ if (!groupId) return null;
84
+ const group = groupOf(groups, groupId);
85
+ const head = toText(group?.parentConversationId ?? group?.parent_conversation_id);
86
+ return head && head === rowId(row) ? 'head' : 'member';
87
+ }
88
+
89
+ /** `Map<groupId, ms>`: the newest row time of each group among `rows`. */
90
+ export function groupActivityTimes(rows) {
91
+ const times = new Map();
92
+ for (const row of Array.isArray(rows) ? rows : []) {
93
+ const groupId = rowGroupId(row);
94
+ if (!groupId) continue;
95
+ const ms = rowOwnTimeMs(row);
96
+ if (ms > (times.get(groupId) || 0)) times.set(groupId, ms);
97
+ }
98
+ return times;
99
+ }
100
+
101
+ function makeContext(rows, groups) {
102
+ const activity = groupActivityTimes(rows);
103
+ return {
104
+ timeOf(row) {
105
+ const groupId = rowGroupId(row);
106
+ return groupId ? Math.max(activity.get(groupId) || 0, rowOwnTimeMs(row)) : rowOwnTimeMs(row);
107
+ },
108
+ keyOf(row) {
109
+ return rowGroupId(row) || rowId(row);
110
+ },
111
+ roleRank(row) {
112
+ return conversationGroupRole(row, groups) === 'head' ? 0 : 1;
113
+ },
114
+ };
115
+ }
116
+
117
+ function compareWith(ctx) {
118
+ return (a, b) => {
119
+ const ta = ctx.timeOf(a);
120
+ const tb = ctx.timeOf(b);
121
+ if (ta !== tb) return tb - ta;
122
+ const ka = ctx.keyOf(a);
123
+ const kb = ctx.keyOf(b);
124
+ if (ka !== kb) return kb.localeCompare(ka);
125
+ const ra = ctx.roleRank(a);
126
+ const rb = ctx.roleRank(b);
127
+ if (ra !== rb) return ra - rb;
128
+ // Same group (or the same ungrouped row): the hand order, then time.
129
+ if (rowGroupId(a)) return compareGroupMembers(a, b);
130
+ const ua = rowOwnTimeMs(a);
131
+ const ub = rowOwnTimeMs(b);
132
+ if (ua !== ub) return ub - ua;
133
+ return rowId(b).localeCompare(rowId(a));
134
+ };
135
+ }
136
+
137
+ /** The rows in list order (a sorted copy). `groups`: Map or object by id. */
138
+ export function orderConversationRows(rows, groups = null) {
139
+ const list = (Array.isArray(rows) ? rows : []).filter(Boolean);
140
+ return list.slice().sort(compareWith(makeContext(list, groups)));
141
+ }
142
+
143
+ /**
144
+ * One page of the ordered list. The cursor names the last row of the page
145
+ * before (`beforeConversationId`); when that row is gone, `beforeUpdatedAt`
146
+ * (its sort time) and the id stand in. The page holds `limit` rows, extended
147
+ * to the end of the group its last row is in.
148
+ */
149
+ export function pageConversationRows(rows, groups = null, { limit = 40, beforeConversationId = '', beforeUpdatedAt = '', beforeGroupId = '' } = {}) {
150
+ const list = (Array.isArray(rows) ? rows : []).filter(Boolean);
151
+ const ctx = makeContext(list, groups);
152
+ const ordered = list.slice().sort(compareWith(ctx));
153
+ const pageLimit = Math.max(1, Math.trunc(Number(limit) || 0) || 40);
154
+ const cursorId = toText(beforeConversationId);
155
+ const cursorGroupId = toText(beforeGroupId);
156
+ let older = ordered;
157
+ if (cursorId || toText(beforeUpdatedAt)) {
158
+ const index = cursorId ? ordered.findIndex((row) => rowId(row) === cursorId) : -1;
159
+ if (index >= 0) {
160
+ // A cursor inside a group (a member moved up in the hand order since
161
+ // the page was served): the page held the whole group, so the next
162
+ // one starts after the group's last row.
163
+ let end = index;
164
+ const cursorRowGroup = rowGroupId(ordered[index]);
165
+ while (cursorRowGroup && end + 1 < ordered.length && rowGroupId(ordered[end + 1]) === cursorRowGroup) end += 1;
166
+ older = ordered.slice(end + 1);
167
+ } else {
168
+ // The cursor row is gone (archived or deleted since). Its sort time
169
+ // and key place the next page; a row inside a group stood for that
170
+ // group, which is served again whole rather than cut short.
171
+ const cursorMs = parseConversationTimeMs(beforeUpdatedAt);
172
+ const cursorKey = cursorGroupId || cursorId;
173
+ older = ordered.filter((row) => {
174
+ const ms = ctx.timeOf(row);
175
+ if (ms < cursorMs) return true;
176
+ if (ms > cursorMs || !cursorKey) return false;
177
+ const key = ctx.keyOf(row);
178
+ if (cursorGroupId && key === cursorGroupId) return true;
179
+ return key.localeCompare(cursorKey) < 0;
180
+ });
181
+ }
182
+ }
183
+ let end = Math.min(pageLimit, older.length);
184
+ const lastGroup = end > 0 ? rowGroupId(older[end - 1]) : '';
185
+ while (lastGroup && end < older.length && rowGroupId(older[end]) === lastGroup) end += 1;
186
+ const pageRows = older.slice(0, end);
187
+ const last = pageRows[pageRows.length - 1] || null;
188
+ const lastMs = last ? ctx.timeOf(last) : 0;
189
+ return {
190
+ rows: pageRows,
191
+ pageInfo: {
192
+ hasMore: older.length > pageRows.length,
193
+ nextCursor: last ? {
194
+ beforeConversationId: rowId(last),
195
+ beforeUpdatedAt: lastMs ? new Date(lastMs).toISOString() : (toText(last.updatedAt ?? last.updated_at) || null),
196
+ ...(rowGroupId(last) ? { beforeGroupId: rowGroupId(last) } : {}),
197
+ } : null,
198
+ },
199
+ };
200
+ }
201
+
202
+ /**
203
+ * The sidebar's items, in order: `{ kind: 'row' }` for an ungrouped row,
204
+ * `{ kind: 'head' }` for a group's first line (with `conversation` when the
205
+ * head row is in the list, else a synthetic head), then `{ kind: 'member' }`
206
+ * for each member while the group is open. Every head item carries
207
+ * `group: { id, info, members, activityMs, open }`; `members` holds the
208
+ * group's rows after the head, in the group's order (hand-placed first).
209
+ *
210
+ * `isOpen(groupId, group)` decides the fold; `matches(row)` keeps rows that
211
+ * match a filter (a group shows when any row matches: its head as context
212
+ * with the matching members, or whole when the head or its name matches).
213
+ */
214
+ export function arrangeConversationList(rows, groups = null, {
215
+ isOpen = () => true,
216
+ matches = null,
217
+ groupNameMatches = null,
218
+ } = {}) {
219
+ const ordered = orderConversationRows(rows, groups);
220
+ const activity = groupActivityTimes(ordered);
221
+ const items = [];
222
+ let index = 0;
223
+ while (index < ordered.length) {
224
+ const row = ordered[index];
225
+ const groupId = rowGroupId(row);
226
+ if (!groupId) {
227
+ if (!matches || matches(row)) items.push({ kind: 'row', conversation: row });
228
+ index += 1;
229
+ continue;
230
+ }
231
+ let end = index;
232
+ while (end < ordered.length && rowGroupId(ordered[end]) === groupId) end += 1;
233
+ const groupRows = ordered.slice(index, end);
234
+ index = end;
235
+ const info = groupOf(groups, groupId);
236
+ const headId = toText(info?.parentConversationId ?? info?.parent_conversation_id);
237
+ const headRow = groupRows.find((candidate) => rowId(candidate) === headId) || null;
238
+ const members = groupRows.filter((candidate) => candidate !== headRow);
239
+ let shownMembers = members;
240
+ let headMatches = true;
241
+ let contextOnly = false;
242
+ if (matches) {
243
+ headMatches = (headRow ? matches(headRow) : false) || (typeof groupNameMatches === 'function' && groupNameMatches(info, headRow));
244
+ if (!headMatches) {
245
+ shownMembers = members.filter((candidate) => matches(candidate));
246
+ if (!shownMembers.length) continue;
247
+ contextOnly = true;
248
+ }
249
+ }
250
+ const group = {
251
+ id: groupId,
252
+ info: info || null,
253
+ head: headRow,
254
+ members,
255
+ activityMs: activity.get(groupId) || 0,
256
+ open: contextOnly ? true : isOpen(groupId, { info, head: headRow, members }) !== false,
257
+ contextOnly,
258
+ };
259
+ items.push({ kind: 'head', conversation: headRow, group });
260
+ if (group.open) {
261
+ for (const member of shownMembers) items.push({ kind: 'member', conversation: member, group });
262
+ }
263
+ }
264
+ return items;
265
+ }
@@ -0,0 +1,100 @@
1
+ // The combined state of a session group, from what each member row already
2
+ // knows, and the fold rule of a group in the sidebar. DOM-free.
3
+
4
+ export const MEMBER_STATE_ORDER = Object.freeze(['waiting', 'running', 'error', 'idle']);
5
+
6
+ /**
7
+ * A member's class: 'waiting' (a question or approval card sits in it),
8
+ * 'running' (a turn or background work), 'error', or 'idle'.
9
+ */
10
+ export function memberStateClass({
11
+ pendingCount = 0,
12
+ visualState = '',
13
+ processing = false,
14
+ activeTurn = false,
15
+ sessionState = '',
16
+ } = {}) {
17
+ const visual = String(visualState || '').trim().toLowerCase();
18
+ if (Number(pendingCount) > 0 || visual === 'question') return 'waiting';
19
+ const session = String(sessionState || '').trim().toLowerCase();
20
+ if (processing === true || activeTurn === true || session === 'running_turn' || session === 'background_only') return 'running';
21
+ if (visual === 'error') return 'error';
22
+ return 'idle';
23
+ }
24
+
25
+ /** Counts per class and the strongest class among the members. */
26
+ export function summarizeGroupMembers(states = []) {
27
+ const summary = { count: 0, waiting: 0, running: 0, error: 0, idle: 0, strongest: 'idle' };
28
+ for (const state of states) {
29
+ const key = MEMBER_STATE_ORDER.includes(state) ? state : 'idle';
30
+ summary[key] += 1;
31
+ summary.count += 1;
32
+ }
33
+ summary.strongest = MEMBER_STATE_ORDER.find((key) => summary[key] > 0) || 'idle';
34
+ return summary;
35
+ }
36
+
37
+ /**
38
+ * "3 · 1 waiting for you, 2 running" or "3 sessions"; "" for an empty group.
39
+ * The waiting part comes first: it is the reason to look, and a narrow
40
+ * sidebar cuts the end.
41
+ */
42
+ export function groupSummaryText(summary) {
43
+ const count = Number(summary?.count || 0);
44
+ if (!count) return '';
45
+ const parts = [];
46
+ if (summary.waiting > 0) parts.push(`${summary.waiting} waiting for you`);
47
+ if (summary.running > 0) parts.push(`${summary.running} running`);
48
+ if (summary.error > 0) parts.push(`${summary.error} failed`);
49
+ if (!parts.length) return `${count} session${count === 1 ? '' : 's'}`;
50
+ return `${count} · ${parts.join(', ')}`;
51
+ }
52
+
53
+ /** Whether a group has something going on: a member runs or waits. */
54
+ export function groupIsActive(summary) {
55
+ return Number(summary?.running || 0) > 0 || Number(summary?.waiting || 0) > 0;
56
+ }
57
+
58
+ /**
59
+ * Open or folded. The user's choice ('open' | 'folded') wins; otherwise a
60
+ * group is open while active and folded once everything is finished. The
61
+ * group that holds the open conversation, and every group under a title
62
+ * filter, is always open.
63
+ */
64
+ export function resolveGroupFold({ choice = null, active = false, holdsOpenConversation = false, filterActive = false } = {}) {
65
+ if (holdsOpenConversation || filterActive) return true;
66
+ if (choice === 'open') return true;
67
+ if (choice === 'folded') return false;
68
+ return active === true;
69
+ }
70
+
71
+ /** The choice to store after a toggle from the current state. */
72
+ export function toggledGroupFoldChoice(currentlyOpen) {
73
+ return currentlyOpen ? 'folded' : 'open';
74
+ }
75
+
76
+ /** `{ [groupId]: 'open' | 'folded' }` from storage text; anything else is dropped. */
77
+ export function parseGroupFoldChoices(text) {
78
+ let parsed = null;
79
+ try {
80
+ parsed = JSON.parse(String(text || ''));
81
+ } catch {
82
+ return {};
83
+ }
84
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
85
+ const choices = {};
86
+ for (const [groupId, choice] of Object.entries(parsed)) {
87
+ if (choice === 'open' || choice === 'folded') choices[String(groupId)] = choice;
88
+ }
89
+ return choices;
90
+ }
91
+
92
+ /** The choices to keep: only those of groups still listed. */
93
+ export function pruneGroupFoldChoices(choices, knownGroupIds) {
94
+ const known = new Set((Array.isArray(knownGroupIds) ? knownGroupIds : []).map((id) => String(id)));
95
+ const kept = {};
96
+ for (const [groupId, choice] of Object.entries(choices || {})) {
97
+ if (known.has(groupId)) kept[groupId] = choice;
98
+ }
99
+ return kept;
100
+ }