@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
@@ -103,38 +103,122 @@ export function openContextMenu(x, y, items, { documentRef = document, windowRef
103
103
  return menu;
104
104
  }
105
105
 
106
+ // After a long press opened its menu on release, the click the release makes
107
+ // must not reach the element underneath (it would open the row). Only that
108
+ // click: the next press ends the guard.
109
+ const RELEASE_CLICK_GUARD_MS = 400;
110
+
106
111
  /**
107
112
  * Long-press as the touch equivalent of right-click, on every descendant of
108
113
  * `root` that matches `selector`. Android fires no `contextmenu` for a long
109
114
  * press on a non-selectable element. Cancelled by movement, so it never fires
110
115
  * mid-scroll; mouse pointers are left to the real `contextmenu` event.
111
116
  * `handler(target, x, y)`.
117
+ *
118
+ * With `lift`, the press lifts the element at the delay instead
119
+ * (`lift.onLift(target, x, y)`), and what the finger does next decides:
120
+ * released without moving, the handler runs (the menu opens on release); moved
121
+ * past the slop, `lift.onMove(target, x, y, event)` is offered the gesture (a
122
+ * drag) and the press ends here when it returns true. `lift.onEnd(target)`
123
+ * follows every lift that did not turn into a move. `lift.canLift(target)`
124
+ * false keeps the plain long press for that press (the handler at the delay,
125
+ * a move cancels). A browser `contextmenu` during a touch press is swallowed,
126
+ * so the menu opens once. A page sent to the background ends the press
127
+ * without a menu.
112
128
  */
113
- export function bindLongPress(root, selector, handler, { setTimeoutImpl = setTimeout, clearTimeoutImpl = clearTimeout } = {}) {
129
+ export function bindLongPress(root, selector, handler, {
130
+ setTimeoutImpl = setTimeout,
131
+ clearTimeoutImpl = clearTimeout,
132
+ now = () => Date.now(),
133
+ lift = null,
134
+ } = {}) {
114
135
  let timer = null;
115
136
  let startX = 0;
116
137
  let startY = 0;
138
+ let pressed = null;
139
+ let lifted = false;
140
+ let swallowClicksUntil = 0;
117
141
  const cancel = () => {
118
142
  if (timer !== null) clearTimeoutImpl(timer);
119
143
  timer = null;
144
+ const target = pressed;
145
+ const wasLifted = lifted;
146
+ pressed = null;
147
+ lifted = false;
148
+ if (wasLifted) lift?.onEnd?.(target);
149
+ };
150
+ const release = () => {
151
+ if (!lifted) {
152
+ cancel();
153
+ return;
154
+ }
155
+ const target = pressed;
156
+ cancel();
157
+ swallowClicksUntil = now() + RELEASE_CLICK_GUARD_MS;
158
+ handler(target, startX, startY);
120
159
  };
121
160
  root.addEventListener('pointerdown', (event) => {
161
+ swallowClicksUntil = 0;
122
162
  if (event.pointerType === 'mouse') return;
123
163
  const target = event.target?.closest?.(selector);
124
164
  if (!target || !root.contains(target)) return;
165
+ cancel();
125
166
  startX = event.clientX;
126
167
  startY = event.clientY;
127
- cancel();
168
+ pressed = target;
128
169
  timer = setTimeoutImpl(() => {
129
170
  timer = null;
130
- handler(target, startX, startY);
171
+ if (!lift || lift.canLift?.(pressed) === false) {
172
+ const firing = pressed;
173
+ pressed = null;
174
+ handler(firing, startX, startY);
175
+ return;
176
+ }
177
+ lifted = true;
178
+ lift.onLift?.(pressed, startX, startY);
131
179
  }, LONG_PRESS_MS);
132
180
  }, { passive: true });
133
181
  root.addEventListener('pointermove', (event) => {
134
- if (Math.abs(event.clientX - startX) > LONG_PRESS_SLOP_PX || Math.abs(event.clientY - startY) > LONG_PRESS_SLOP_PX) cancel();
182
+ if (!pressed) return;
183
+ if (Math.abs(event.clientX - startX) <= LONG_PRESS_SLOP_PX && Math.abs(event.clientY - startY) <= LONG_PRESS_SLOP_PX) return;
184
+ if (!lifted) {
185
+ cancel();
186
+ return;
187
+ }
188
+ // A lifted element moved: a drag, when the owner takes it.
189
+ const target = pressed;
190
+ if (lift.onMove?.(target, startX, startY, event) === true) {
191
+ pressed = null;
192
+ lifted = false;
193
+ }
135
194
  }, { passive: true });
136
- for (const name of ['pointerup', 'pointercancel', 'pointerleave']) {
137
- root.addEventListener(name, cancel, { passive: true });
195
+ if (!lift) {
196
+ for (const name of ['pointerup', 'pointercancel', 'pointerleave']) {
197
+ root.addEventListener(name, cancel, { passive: true });
198
+ }
199
+ return cancel;
138
200
  }
201
+ root.addEventListener('pointerup', release, { passive: true });
202
+ // A cancel while lifted (the browser claimed a long press of its own) still
203
+ // opens the menu: the finger was held without moving.
204
+ root.addEventListener('pointercancel', release, { passive: true });
205
+ root.addEventListener('pointerleave', () => {
206
+ if (!lifted) cancel();
207
+ }, { passive: true });
208
+ root.ownerDocument?.addEventListener('visibilitychange', () => {
209
+ if (root.ownerDocument.hidden) cancel();
210
+ });
211
+ root.addEventListener('contextmenu', (event) => {
212
+ if (!pressed) return;
213
+ event.preventDefault();
214
+ event.stopPropagation();
215
+ }, true);
216
+ root.addEventListener('click', (event) => {
217
+ // A click from the keyboard (Enter, Space) is never the release's.
218
+ if (event.detail === 0 || now() >= swallowClicksUntil) return;
219
+ swallowClicksUntil = 0;
220
+ event.preventDefault();
221
+ event.stopPropagation();
222
+ }, true);
139
223
  return cancel;
140
224
  }
@@ -0,0 +1,191 @@
1
+ // Where a conversation row dragged in the sidebar lands, from what the
2
+ // pointer is over. DOM-free: the sidebar measures, this module decides.
3
+ //
4
+ // The list is the arranged one (conversation-group-order.mjs): loose rows and
5
+ // groups ordered by activity, each group a head (a real row, or a synthetic
6
+ // line for a group without its head in view) followed by its members while
7
+ // open. Only the order inside a group is the user's to set, so a drop either
8
+ // places a row inside a group, takes it out of one, or makes a group of two
9
+ // loose rows. Nothing reorders the top level.
10
+ //
11
+ // The pointer is over an item at a relative height `y` (0 top, 1 bottom), or
12
+ // below the last item. The rules, by what is under the pointer:
13
+ //
14
+ // member of group G (upper half: before it; lower half: after it)
15
+ // dragged row in G -> reorder in G (a head of G: none, it stays first)
16
+ // dragged row elsewhere -> join G at that place
17
+ // head of group G (real or synthetic)
18
+ // lower half of an open head -> before G's first member (the head stays first)
19
+ // upper half, or folded -> join G at the end (outline only)
20
+ // a member of G itself -> lower half of the open head: to the top of G; else none
21
+ // loose row
22
+ // dragged row loose -> middle half (25 % to 75 %): a new group of the two; edges: none
23
+ // dragged row in a group -> leave the group (it then sorts by its own activity)
24
+ // below the last item -> a grouped row leaves its group; a loose row: none
25
+ //
26
+ // A drop onto the dragged row itself, or one that would leave a member where
27
+ // it is, is none. A head dragged within its own group is none.
28
+
29
+ const EDGE = 0.25;
30
+
31
+ /** A drop that does nothing. */
32
+ export const NO_DROP = Object.freeze({ action: 'none', groupId: null, beforeConversationId: null, indicator: null });
33
+
34
+ function toText(value) {
35
+ return String(value ?? '').trim();
36
+ }
37
+
38
+ /** The key of a sidebar item: its conversation id, or `group:<id>` for a synthetic head. */
39
+ export function dragItemKey({ conversationId = null, groupId = null } = {}) {
40
+ const id = toText(conversationId);
41
+ return id || `group:${toText(groupId)}`;
42
+ }
43
+
44
+ /**
45
+ * Plain items from arrangeConversationList(): `{ key, kind, conversationId,
46
+ * groupId }`, and on heads `open` and `memberIds` (the group's rows after
47
+ * the head, in list order).
48
+ */
49
+ export function describeDragItems(arranged) {
50
+ const items = [];
51
+ for (const item of Array.isArray(arranged) ? arranged : []) {
52
+ if (!item || !['row', 'head', 'member'].includes(item.kind)) continue;
53
+ const conversationId = toText(item.conversation?.id) || null;
54
+ const groupId = item.kind === 'row' ? null : (toText(item.group?.id) || null);
55
+ if (!conversationId && !groupId) continue;
56
+ const described = { key: dragItemKey({ conversationId, groupId }), kind: item.kind, conversationId, groupId };
57
+ if (item.kind === 'head') {
58
+ described.open = item.group?.open !== false;
59
+ described.memberIds = (Array.isArray(item.group?.members) ? item.group.members : [])
60
+ .map((row) => toText(row?.id))
61
+ .filter(Boolean);
62
+ }
63
+ items.push(described);
64
+ }
65
+ return items;
66
+ }
67
+
68
+ function drop(action, { groupId = null, beforeConversationId = null, indicator = null, targetConversationId = null } = {}) {
69
+ return {
70
+ action,
71
+ groupId,
72
+ beforeConversationId,
73
+ indicator,
74
+ ...(targetConversationId ? { targetConversationId } : {}),
75
+ };
76
+ }
77
+
78
+ /**
79
+ * The drop for `draggedId` with the pointer at `hover`: `{ key, y }` over an
80
+ * item, `{ below: true }` under the last one, or null (nowhere). Returns
81
+ * `{ action: 'none' | 'reorder' | 'join' | 'leave' | 'new-group', groupId,
82
+ * beforeConversationId, indicator: { itemId, edge: 'before' | 'after' |
83
+ * 'inside' } | null }`, with `targetConversationId` on a new group.
84
+ */
85
+ export function resolveDropTarget({ items = [], draggedId = '', hover = null } = {}) {
86
+ const list = Array.isArray(items) ? items : [];
87
+ const id = toText(draggedId);
88
+ const dragged = id ? list.find((item) => item.conversationId === id) : null;
89
+ if (!dragged || !hover) return NO_DROP;
90
+ const draggedGroupId = dragged.kind === 'row' ? null : dragged.groupId;
91
+ const draggedIsHead = dragged.kind === 'head';
92
+ const headOf = (groupId) => list.find((item) => item.kind === 'head' && item.groupId === groupId) || null;
93
+ const membersOf = (groupId) => headOf(groupId)?.memberIds || [];
94
+
95
+ if (hover.below) {
96
+ const last = list[list.length - 1];
97
+ if (!draggedGroupId || !last) return NO_DROP;
98
+ return drop('leave', { indicator: { itemId: last.key, edge: 'after' } });
99
+ }
100
+
101
+ const target = list.find((item) => item.key === toText(hover.key));
102
+ if (!target) return NO_DROP;
103
+ if (target.conversationId && target.conversationId === id) return NO_DROP;
104
+ const y = Math.min(1, Math.max(0, Number(hover.y) || 0));
105
+ const half = y < 0.5 ? 'before' : 'after';
106
+
107
+ if (target.kind === 'member') {
108
+ const groupId = target.groupId;
109
+ if (draggedIsHead && draggedGroupId === groupId) return NO_DROP;
110
+ const members = membersOf(groupId);
111
+ const index = members.indexOf(target.conversationId);
112
+ const before = half === 'before' ? target.conversationId : (members[index + 1] ?? null);
113
+ if (draggedGroupId === groupId) {
114
+ // Before itself, or before the row that already follows it: no move.
115
+ const currentNext = members[members.indexOf(id) + 1] ?? null;
116
+ if (before === id || before === currentNext) return NO_DROP;
117
+ return drop('reorder', { groupId, beforeConversationId: before, indicator: { itemId: target.key, edge: half } });
118
+ }
119
+ return drop('join', { groupId, beforeConversationId: before, indicator: { itemId: target.key, edge: half } });
120
+ }
121
+
122
+ if (target.kind === 'head') {
123
+ const groupId = target.groupId;
124
+ const members = membersOf(groupId);
125
+ const toTop = half === 'after' && target.open === true && members.length > 0;
126
+ if (draggedGroupId === groupId) {
127
+ if (draggedIsHead || !toTop || members[0] === id) return NO_DROP;
128
+ return drop('reorder', { groupId, beforeConversationId: members[0], indicator: { itemId: target.key, edge: 'after' } });
129
+ }
130
+ if (toTop) return drop('join', { groupId, beforeConversationId: members[0], indicator: { itemId: target.key, edge: 'after' } });
131
+ return drop('join', { groupId, beforeConversationId: null, indicator: { itemId: target.key, edge: 'inside' } });
132
+ }
133
+
134
+ // A loose row.
135
+ if (!draggedGroupId) {
136
+ if (y < EDGE || y > 1 - EDGE) return NO_DROP;
137
+ return drop('new-group', {
138
+ indicator: { itemId: target.key, edge: 'inside' },
139
+ targetConversationId: target.conversationId,
140
+ });
141
+ }
142
+ return drop('leave', { indicator: { itemId: target.key, edge: half } });
143
+ }
144
+
145
+ /**
146
+ * "Move up" / "Move down" in a group's hand order: the `beforeConversationId`
147
+ * for the move, or null at the end it cannot pass. `memberIds` are the rows
148
+ * after the head, in list order.
149
+ */
150
+ export function groupMoveStep(memberIds, conversationId, direction) {
151
+ const members = Array.isArray(memberIds) ? memberIds : [];
152
+ const index = members.indexOf(toText(conversationId));
153
+ if (index < 0) return null;
154
+ if (direction === 'up') return index > 0 ? { beforeConversationId: members[index - 1] } : null;
155
+ if (direction === 'down') return index < members.length - 1 ? { beforeConversationId: members[index + 2] ?? null } : null;
156
+ return null;
157
+ }
158
+
159
+ /**
160
+ * Pixels to scroll the list by in one frame while dragging: negative near
161
+ * the top, positive near the bottom, faster toward (and past) the edge, 0
162
+ * outside the zones.
163
+ */
164
+ export function autoScrollStep(pointerY, top, bottom, { zone = 48, maxStep = 18 } = {}) {
165
+ const y = Number(pointerY);
166
+ if (!Number.isFinite(y) || !(bottom > top)) return 0;
167
+ const reach = Math.min(zone, (bottom - top) / 2);
168
+ if (!(reach > 0)) return 0;
169
+ const fromTop = y - top;
170
+ const fromBottom = bottom - y;
171
+ const speed = (distance) => {
172
+ const t = Math.min(1, Math.max(0, (reach - distance) / reach));
173
+ return Math.max(1, Math.ceil(maxStep * t * t));
174
+ };
175
+ if (fromTop < reach && fromTop <= fromBottom) return -speed(fromTop);
176
+ if (fromBottom < reach) return speed(fromBottom);
177
+ return 0;
178
+ }
179
+
180
+ function quoted(text) {
181
+ return `“${toText(text) || 'Untitled'}”`;
182
+ }
183
+
184
+ /** What a screen reader hears after a drop. */
185
+ export function dropAnnouncement(action, { title = '', groupName = '', otherTitle = '' } = {}) {
186
+ if (action === 'reorder') return `Moved ${quoted(title)} within ${quoted(groupName)}`;
187
+ if (action === 'join') return `Moved ${quoted(title)} to ${quoted(groupName)}`;
188
+ if (action === 'leave') return `Removed ${quoted(title)} from its group`;
189
+ if (action === 'new-group') return `New group with ${quoted(otherTitle)} and ${quoted(title)}`;
190
+ return '';
191
+ }
@@ -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
+ }