@oar-sh/oar 0.9.14 → 0.9.16
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.
- package/CHANGELOG.md +179 -0
- package/README.md +9 -9
- package/bin/oar.js +525 -12
- package/package.json +3 -1
- package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
- package/server/claude-worker/claude-session-process.mjs +4 -4
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2 -2
- package/server/db-schema.mjs +52 -0
- package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
- package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
- package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
- package/server/migrations/0022-relay-schedules.mjs +142 -0
- package/server/migrations/0023-relay-asks.mjs +141 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/bootstrap.js +61 -3
- package/server/public/app/context-usage-view.mjs +17 -0
- package/server/public/app/conversation-group-order.mjs +35 -0
- package/server/public/app/inbox-ui.js +356 -0
- package/server/public/app/inbox-view.mjs +370 -0
- package/server/public/app/journal-view.js +23 -2
- package/server/public/app/relay-event-subscriptions-ui.js +4 -2
- package/server/public/app/relay-event-view.mjs +33 -7
- package/server/public/app/relay-schedule-view.mjs +119 -0
- package/server/public/app/relay-schedules-ui.js +172 -0
- package/server/public/app/settings-modal.js +17 -3
- package/server/public/app/socket-handlers.js +27 -3
- package/server/public/app/store.js +18 -5
- package/server/public/index.html +159 -2
- package/server/relay-tools.md +18 -3
- package/server/repositories/conversation-group-repository.mjs +97 -11
- package/server/repositories/remote-relay-repository.mjs +20 -2
- package/server/repositories/session-repository.mjs +7 -1
- package/server/routes/ask-user-routes.mjs +57 -3
- package/server/routes/cache-routes.mjs +6 -0
- package/server/routes/conversation-group-routes.mjs +17 -2
- package/server/routes/messages-routes.mjs +3 -3
- package/server/routes/relay-ask-routes.mjs +387 -0
- package/server/routes/relay-event-routes.mjs +3 -1
- package/server/routes/relay-schedule-routes.mjs +57 -0
- package/server/routes/sessions-routes.mjs +613 -56
- package/server/server-runtime.mjs +80 -1
- package/server/services/cache-rebuild-service.mjs +19 -2
- package/server/services/conversation-preferences-service.mjs +17 -0
- package/server/services/oar-cli-helpers.mjs +12 -4
- package/server/services/relay-ask-link-service.mjs +174 -0
- package/server/services/relay-ask-service.mjs +604 -0
- package/server/services/relay-event-service.mjs +589 -73
- package/server/services/relay-schedule-service.mjs +596 -0
- package/server/services/remote-relay-caller-context.mjs +89 -20
- package/server/services/remote-relay-dispatcher.mjs +956 -30
- package/server/services/windows-autostart-service.mjs +88 -16
- package/server/services/windows-boot-autostart-service.mjs +775 -126
- package/shared/relay-asks.mjs +145 -0
- package/shared/relay-events.mjs +268 -18
- package/shared/relay-schedules.mjs +128 -0
- package/shared/remote-relay-contract.mjs +553 -25
- package/shared/remote-relay-tool-core.mjs +16 -5
- package/.github/extensions/web-relay/README.md +0 -190
- package/server/README.md +0 -2334
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Migration: questions that don't block (services/relay-ask-service.mjs).
|
|
5
|
+
*
|
|
6
|
+
* `relay_asks`: one row per question an agent asked the owner with
|
|
7
|
+
* remote_relay's `ask`. Not a `relay_questions` row: those belong to a turn
|
|
8
|
+
* (queue_id) that waits on them; an ask belongs to a session and blocks
|
|
9
|
+
* nothing.
|
|
10
|
+
*
|
|
11
|
+
* - `relay_id`: 'self' for an ask kept here (its session lives on this
|
|
12
|
+
* relay); a paired relay's registry id for this relay's copy of an ask its
|
|
13
|
+
* agent put on that relay (a mirror: `remote_id` is the ask's id there, and
|
|
14
|
+
* this relay looks for the answer until it has it).
|
|
15
|
+
* - `conversation_id`: the session the ask is in (on that relay for a
|
|
16
|
+
* mirror, `conversation_title` its title there).
|
|
17
|
+
* - `created_by`: the conversation here whose agent asked (NULL when a paired
|
|
18
|
+
* relay's agent did; `creator_json` then names that relay and its
|
|
19
|
+
* conversation, `{ relayId, relayName, conversationId, conversationTitle }`).
|
|
20
|
+
* - `question`, `options_json`, `recommendation`, `detail`, `urgency`
|
|
21
|
+
* ('normal' | 'now'): as the agent wrote them, checked
|
|
22
|
+
* (shared/relay-asks.mjs).
|
|
23
|
+
* - `status`: pending, answered, dismissed, withdrawn (by the agent that
|
|
24
|
+
* asked), cancelled (its session was archived or deleted); `answer`,
|
|
25
|
+
* `choice`, `answered_at`, `answered_via` (the relay whose Inbox it came
|
|
26
|
+
* from, NULL for this relay's own UI).
|
|
27
|
+
* - `notified_at`: when the asking side was told the outcome (for a mirror:
|
|
28
|
+
* when its creator here got question_answered).
|
|
29
|
+
*
|
|
30
|
+
* Additive and idempotent. Safe to run on every boot.
|
|
31
|
+
*
|
|
32
|
+
* Usage: node server/migrations/0023-relay-asks.mjs [path/to/copilot.db]
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { fileURLToPath } from 'url';
|
|
36
|
+
import path from 'path';
|
|
37
|
+
import process from 'process';
|
|
38
|
+
import Database from 'better-sqlite3';
|
|
39
|
+
|
|
40
|
+
export const RELAY_ASK_COLUMNS = Object.freeze([
|
|
41
|
+
'id',
|
|
42
|
+
'relay_id',
|
|
43
|
+
'remote_id',
|
|
44
|
+
'conversation_id',
|
|
45
|
+
'conversation_title',
|
|
46
|
+
'created_by',
|
|
47
|
+
'creator_json',
|
|
48
|
+
'question',
|
|
49
|
+
'options_json',
|
|
50
|
+
'recommendation',
|
|
51
|
+
'detail',
|
|
52
|
+
'urgency',
|
|
53
|
+
'status',
|
|
54
|
+
'answer',
|
|
55
|
+
'choice',
|
|
56
|
+
'answered_at',
|
|
57
|
+
'answered_via',
|
|
58
|
+
'notified_at',
|
|
59
|
+
'created_at',
|
|
60
|
+
'updated_at',
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
function tableExists(db, name) {
|
|
64
|
+
return !!db
|
|
65
|
+
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
|
|
66
|
+
.get(name);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** What keeps the service from using the table: '' when nothing does. */
|
|
70
|
+
export function relayAsksTableProblem(db) {
|
|
71
|
+
if (!tableExists(db, 'relay_asks')) return 'no relay_asks table';
|
|
72
|
+
const present = new Set(db.prepare(`PRAGMA table_info(relay_asks)`).all().map((column) => column.name));
|
|
73
|
+
const missing = RELAY_ASK_COLUMNS.filter((name) => !present.has(name));
|
|
74
|
+
return missing.length ? `relay_asks lacks ${missing.join(', ')}` : '';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function migrateRelayAsks(db) {
|
|
78
|
+
let tablesAdded = 0;
|
|
79
|
+
if (!tableExists(db, 'relay_asks')) {
|
|
80
|
+
db.exec(`
|
|
81
|
+
CREATE TABLE relay_asks (
|
|
82
|
+
id TEXT PRIMARY KEY,
|
|
83
|
+
relay_id TEXT NOT NULL DEFAULT 'self',
|
|
84
|
+
remote_id TEXT,
|
|
85
|
+
conversation_id TEXT NOT NULL,
|
|
86
|
+
conversation_title TEXT,
|
|
87
|
+
created_by TEXT,
|
|
88
|
+
creator_json TEXT,
|
|
89
|
+
question TEXT NOT NULL,
|
|
90
|
+
options_json TEXT,
|
|
91
|
+
recommendation TEXT,
|
|
92
|
+
detail TEXT,
|
|
93
|
+
urgency TEXT NOT NULL DEFAULT 'normal' CHECK (urgency IN ('normal', 'now')),
|
|
94
|
+
status TEXT NOT NULL DEFAULT 'pending'
|
|
95
|
+
CHECK (status IN ('pending', 'answered', 'dismissed', 'withdrawn', 'cancelled')),
|
|
96
|
+
answer TEXT,
|
|
97
|
+
choice TEXT,
|
|
98
|
+
answered_at TEXT,
|
|
99
|
+
answered_via TEXT,
|
|
100
|
+
notified_at TEXT,
|
|
101
|
+
created_at TEXT NOT NULL,
|
|
102
|
+
updated_at TEXT NOT NULL,
|
|
103
|
+
CHECK (relay_id = 'self' OR remote_id IS NOT NULL)
|
|
104
|
+
)
|
|
105
|
+
`);
|
|
106
|
+
tablesAdded += 1;
|
|
107
|
+
}
|
|
108
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_relay_asks_status ON relay_asks(relay_id, status, created_at)`);
|
|
109
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_relay_asks_conversation ON relay_asks(conversation_id, status)`);
|
|
110
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_relay_asks_creator ON relay_asks(created_by, status)`);
|
|
111
|
+
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_relay_asks_remote ON relay_asks(relay_id, remote_id) WHERE remote_id IS NOT NULL`);
|
|
112
|
+
return { applied: tablesAdded > 0, tablesAdded };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function defaultDbPath() {
|
|
116
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
117
|
+
return path.resolve(here, '..', 'data', 'copilot.db');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function migrate(dbPath, options = {}) {
|
|
121
|
+
const db = new Database(dbPath, options);
|
|
122
|
+
try {
|
|
123
|
+
return migrateRelayAsks(db);
|
|
124
|
+
} finally {
|
|
125
|
+
db.close();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
130
|
+
if (isMain) {
|
|
131
|
+
const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
|
|
132
|
+
try {
|
|
133
|
+
const result = migrate(dbPath);
|
|
134
|
+
console.log(`Migration 0023 on ${dbPath}`);
|
|
135
|
+
console.log(result.applied ? ` relay asks: tables added ${result.tablesAdded}` : ' skipped: nothing to change');
|
|
136
|
+
process.exit(0);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
console.error(`Migration 0023 failed: ${err?.message || err}`);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -16,6 +16,7 @@ import { isChatInteractionHeld } from './selection-guard.mjs';
|
|
|
16
16
|
import { questionCardHeadLabel } from './conversation-provider-indicator.mjs';
|
|
17
17
|
import { questionAcceptsFreeText } from './question-answer-rule.mjs';
|
|
18
18
|
import { beginOwnSend, followsTranscriptEnd, refreshJumpButton, scrollAfterOwnSend } from './transcript-jump-bottom-view.mjs';
|
|
19
|
+
import { renderSessionAskCards } from './inbox-ui.js';
|
|
19
20
|
|
|
20
21
|
let relayQuestionRenderHash = '';
|
|
21
22
|
let renderedRelayQuestionIds = new Set();
|
|
@@ -215,6 +216,8 @@ function scrollQuestionCardIntoMessages(el, target) {
|
|
|
215
216
|
export function renderRelayQuestions() {
|
|
216
217
|
const el = document.getElementById('messages');
|
|
217
218
|
if (!el) return;
|
|
219
|
+
// The questions that don't block go first: the cards a turn waits on stay last.
|
|
220
|
+
renderSessionAskCards();
|
|
218
221
|
const previousScrollTop = el.scrollTop;
|
|
219
222
|
// A card is shown to a reader who is at the end. Anyone further up stays
|
|
220
223
|
// where they are; the jump-to-bottom button says that a question waits.
|
|
@@ -238,6 +238,7 @@ import { initScrollbarHover } from './scrollbar-hover.mjs';
|
|
|
238
238
|
import {
|
|
239
239
|
autoCompactWindowNotes,
|
|
240
240
|
renderAutoCompactControlHtml,
|
|
241
|
+
renderAgentSessionsControlHtml,
|
|
241
242
|
renderAutoCompactNotesHtml,
|
|
242
243
|
renderContextUsageHtml,
|
|
243
244
|
renderThinkingControlHtml,
|
|
@@ -401,6 +402,8 @@ import { openStopSharingConfirmation } from './shared-conversations-settings.js'
|
|
|
401
402
|
import { showAbout, syncAboutMenuLabel } from './about-ui.js';
|
|
402
403
|
import { syncConversationOriginHeader } from './conversation-origin-ui.js';
|
|
403
404
|
import { loadRelayEventSubscriptions, onRelayEventSubscriptionsChanged, syncRelayEventHeader } from './relay-event-subscriptions-ui.js';
|
|
405
|
+
import { loadRelaySchedules, onRelaySchedulesChanged, syncRelayScheduleHeader } from './relay-schedules-ui.js';
|
|
406
|
+
import { leaveInboxView, startInbox, toggleInboxView } from './inbox-ui.js';
|
|
404
407
|
import {
|
|
405
408
|
previewAgentSessionsMaxWait,
|
|
406
409
|
saveAgentSessionsMaxWait,
|
|
@@ -3346,6 +3349,41 @@ function initAttributionControl() {
|
|
|
3346
3349
|
});
|
|
3347
3350
|
}
|
|
3348
3351
|
|
|
3352
|
+
// The owner's "agents in this conversation may start sessions" in the 🧠
|
|
3353
|
+
// modal: one request per change; the store follows the answer (other tabs
|
|
3354
|
+
// follow the socket event).
|
|
3355
|
+
let agentSessionsControlUpdateInFlight = false;
|
|
3356
|
+
function initAgentSessionsControl() {
|
|
3357
|
+
const body = document.getElementById('summary-modal-body');
|
|
3358
|
+
if (!body || body.dataset.agentSessionsControlBound === 'true') return;
|
|
3359
|
+
body.dataset.agentSessionsControlBound = 'true';
|
|
3360
|
+
body.addEventListener('change', async (event) => {
|
|
3361
|
+
const box = event.target?.closest?.('#ctx-may-create-sessions');
|
|
3362
|
+
if (!box) return;
|
|
3363
|
+
const convId = String(box.dataset.conversationId || '').trim();
|
|
3364
|
+
if (!convId || agentSessionsControlUpdateInFlight) return;
|
|
3365
|
+
const wanted = box.checked === true;
|
|
3366
|
+
agentSessionsControlUpdateInFlight = true;
|
|
3367
|
+
box.disabled = true;
|
|
3368
|
+
try {
|
|
3369
|
+
const response = await updateConversationPreferences(convId, { clientId: CLIENT_ID, mayCreateSessions: wanted });
|
|
3370
|
+
if (!response) throw new Error('The relay refused the change');
|
|
3371
|
+
const stored = response.mayCreateSessions === true;
|
|
3372
|
+
if (conversations[convId]) conversations[convId] = { ...conversations[convId], mayCreateSessions: stored };
|
|
3373
|
+
box.checked = stored;
|
|
3374
|
+
showTransientRelayNotice(stored
|
|
3375
|
+
? 'Agents in this conversation may now start sessions.'
|
|
3376
|
+
: 'Agents in this conversation can no longer start sessions.');
|
|
3377
|
+
} catch (error) {
|
|
3378
|
+
box.checked = !wanted;
|
|
3379
|
+
showTransientRelayNotice(`Could not change the setting: ${error?.message || error}`);
|
|
3380
|
+
} finally {
|
|
3381
|
+
box.disabled = false;
|
|
3382
|
+
agentSessionsControlUpdateInFlight = false;
|
|
3383
|
+
}
|
|
3384
|
+
});
|
|
3385
|
+
}
|
|
3386
|
+
|
|
3349
3387
|
function initThinkingControl() {
|
|
3350
3388
|
const body = document.getElementById('summary-modal-body');
|
|
3351
3389
|
if (!body || body.dataset.thinkingBound === '1') return;
|
|
@@ -3511,14 +3549,21 @@ async function loadContextSummaryAndRender(convId) {
|
|
|
3511
3549
|
enabled: payload.thinkingEnabled !== false,
|
|
3512
3550
|
display: payload.thinkingDisplay || 'summarized',
|
|
3513
3551
|
};
|
|
3552
|
+
// For a session an agent started (any provider): may its agents start
|
|
3553
|
+
// sessions? Only meaningful there, so only shown there.
|
|
3554
|
+
const settingsConversationId = String(payload.resolvedConversationId || trimmedConvId || '').trim();
|
|
3555
|
+
const settingsConversation = settingsConversationId ? conversations[settingsConversationId] : null;
|
|
3556
|
+
const agentSessionsHtml = settingsConversation?.origin
|
|
3557
|
+
? renderAgentSessionsControlHtml({ conversationId: settingsConversationId, mayCreateSessions: settingsConversation.mayCreateSessions === true })
|
|
3558
|
+
: '';
|
|
3514
3559
|
const detailText = String(payload.text || '').trim();
|
|
3515
3560
|
// The structured breakdown is the answer; the runtime's own text dump stays
|
|
3516
3561
|
// available underneath for the details it carries that categories don't.
|
|
3517
3562
|
const bodyHtml = usageHtml
|
|
3518
|
-
? `${usageHtml}${autoCompactHtml}${thinkingHtml}${attributionHtml}${detailText
|
|
3563
|
+
? `${usageHtml}${autoCompactHtml}${thinkingHtml}${attributionHtml}${agentSessionsHtml}${detailText
|
|
3519
3564
|
? `<details class="ctx-usage-raw"><summary>Raw details</summary><pre>${escHtml(detailText)}</pre></details>`
|
|
3520
3565
|
: ''}`
|
|
3521
|
-
: `${autoCompactHtml}${thinkingHtml}${attributionHtml}<pre>${escHtml(detailText || 'No context data available.')}</pre>`;
|
|
3566
|
+
: `${autoCompactHtml}${thinkingHtml}${attributionHtml}${agentSessionsHtml}<pre>${escHtml(detailText || 'No context data available.')}</pre>`;
|
|
3522
3567
|
|
|
3523
3568
|
renderSummaryModalContent({
|
|
3524
3569
|
title: 'Context usage',
|
|
@@ -4387,6 +4432,8 @@ function syncChatTitleControls() {
|
|
|
4387
4432
|
syncConversationOriginHeader();
|
|
4388
4433
|
// Relay events: "watching N sessions" on a watcher, "watched" on a watched one.
|
|
4389
4434
|
syncRelayEventHeader();
|
|
4435
|
+
// Relay schedules: "N scheduled" with its list and Cancel buttons.
|
|
4436
|
+
syncRelayScheduleHeader();
|
|
4390
4437
|
syncChatTitleWatcherIndicator();
|
|
4391
4438
|
}
|
|
4392
4439
|
|
|
@@ -4884,6 +4931,7 @@ async function initApp() {
|
|
|
4884
4931
|
initAutoCompactWindowControl();
|
|
4885
4932
|
initThinkingControl();
|
|
4886
4933
|
initAttributionControl();
|
|
4934
|
+
initAgentSessionsControl();
|
|
4887
4935
|
initModeSelector();
|
|
4888
4936
|
initModelSelector();
|
|
4889
4937
|
initReasoningSelector();
|
|
@@ -4965,6 +5013,11 @@ async function initApp() {
|
|
|
4965
5013
|
// The sidebar's "watched" badges follow the subscriptions.
|
|
4966
5014
|
onRelayEventSubscriptionsChanged(() => renderConvList());
|
|
4967
5015
|
void loadRelayEventSubscriptions();
|
|
5016
|
+
// The sidebar's "scheduled" markers follow the schedules.
|
|
5017
|
+
onRelaySchedulesChanged(() => renderConvList());
|
|
5018
|
+
void loadRelaySchedules();
|
|
5019
|
+
// The Inbox's count, and the cards of questions that don't block.
|
|
5020
|
+
startInbox();
|
|
4968
5021
|
const pushConversationId = consumePushConversationDeepLink();
|
|
4969
5022
|
if (pushConversationId) {
|
|
4970
5023
|
await openConversation(pushConversationId).catch(() => {});
|
|
@@ -5165,7 +5218,12 @@ window.toggleArchivedConversations = toggleArchivedConversations;
|
|
|
5165
5218
|
window.refreshNewConversationCloudRepoList = refreshNewConversationCloudRepoList;
|
|
5166
5219
|
window.openConversation = openConversation;
|
|
5167
5220
|
window.openOriginConversation = openOriginConversation;
|
|
5168
|
-
|
|
5221
|
+
// The Inbox and Status share the transcript's place: opening one leaves the other.
|
|
5222
|
+
window.toggleStatusView = () => {
|
|
5223
|
+
leaveInboxView();
|
|
5224
|
+
return toggleStatusView();
|
|
5225
|
+
};
|
|
5226
|
+
window.toggleInboxView = toggleInboxView;
|
|
5169
5227
|
window.refreshConversations = refreshConversations;
|
|
5170
5228
|
window.renderConvList = renderConvList;
|
|
5171
5229
|
window.handleAttachmentInput = handleAttachmentInput;
|
|
@@ -350,6 +350,23 @@ ${renderThinkingButtons('display', THINKING_DISPLAY_CHOICES, displayKey)}
|
|
|
350
350
|
`;
|
|
351
351
|
}
|
|
352
352
|
|
|
353
|
+
/**
|
|
354
|
+
* The owner's switch for a session an agent started: may its agents start
|
|
355
|
+
* sessions themselves (the remote_relay tool's one-level rule lifted for it)?
|
|
356
|
+
* Only the owner sets it; no agent can.
|
|
357
|
+
*/
|
|
358
|
+
export function renderAgentSessionsControlHtml({ conversationId = '', mayCreateSessions = false } = {}) {
|
|
359
|
+
return `
|
|
360
|
+
<div class="ctx-agent-sessions">
|
|
361
|
+
<label class="ctx-agent-sessions-row">
|
|
362
|
+
<input type="checkbox" id="ctx-may-create-sessions" data-conversation-id="${escapeHtml(conversationId)}"${mayCreateSessions === true ? ' checked' : ''}>
|
|
363
|
+
<span>Agents in this conversation may start sessions (even though an agent started it)</span>
|
|
364
|
+
</label>
|
|
365
|
+
<div class="ctx-thinking-note">An agent started this session, so its agents cannot start sessions of their own unless you allow it here. The limit of sessions at work, your Allow for the first one and the relay's "Agents may start and use sessions" setting still apply.</div>
|
|
366
|
+
</div>
|
|
367
|
+
`;
|
|
368
|
+
}
|
|
369
|
+
|
|
353
370
|
// A model limit is a round figure: 1M rather than 1.0M.
|
|
354
371
|
function formatModelLimit(value) {
|
|
355
372
|
const n = toNullableNumber(value);
|
|
@@ -210,12 +210,47 @@ export function pageConversationRows(rows, groups = null, { limit = 40, beforeCo
|
|
|
210
210
|
* `isOpen(groupId, group)` decides the fold; `matches(row)` keeps rows that
|
|
211
211
|
* match a filter (a group shows when any row matches: its head as context
|
|
212
212
|
* with the matching members, or whole when the head or its name matches).
|
|
213
|
+
*
|
|
214
|
+
* With `emptyGroups`, an owner's group an agent emptied (`keptEmptyAt`, no
|
|
215
|
+
* row here) shows too: a head item without members (`group.empty`), placed
|
|
216
|
+
* by the time it was emptied; under a filter only when its name matches.
|
|
213
217
|
*/
|
|
214
218
|
export function arrangeConversationList(rows, groups = null, {
|
|
215
219
|
isOpen = () => true,
|
|
216
220
|
matches = null,
|
|
217
221
|
groupNameMatches = null,
|
|
222
|
+
emptyGroups = false,
|
|
218
223
|
} = {}) {
|
|
224
|
+
const items = arrangeGroupedRows(rows, groups, { isOpen, matches, groupNameMatches });
|
|
225
|
+
if (!emptyGroups || !groups) return items;
|
|
226
|
+
const present = new Set((Array.isArray(rows) ? rows : []).map(rowGroupId).filter(Boolean));
|
|
227
|
+
const entries = typeof groups.entries === 'function' && typeof groups.get === 'function' ? [...groups.entries()] : Object.entries(groups);
|
|
228
|
+
const kept = entries
|
|
229
|
+
.map(([id, info]) => ({ id: toText(id), info }))
|
|
230
|
+
.filter(({ id, info }) => id && toText(info?.keptEmptyAt) && !present.has(id))
|
|
231
|
+
.filter(({ info }) => !matches || (typeof groupNameMatches === 'function' && groupNameMatches(info, null)))
|
|
232
|
+
.map(({ id, info }) => ({
|
|
233
|
+
kind: 'head',
|
|
234
|
+
conversation: null,
|
|
235
|
+
group: { id, info, head: null, members: [], activityMs: parseConversationTimeMs(info.keptEmptyAt), open: false, contextOnly: false, empty: true },
|
|
236
|
+
}))
|
|
237
|
+
.sort((a, b) => (b.group.activityMs - a.group.activityMs) || b.group.id.localeCompare(a.group.id));
|
|
238
|
+
if (!kept.length) return items;
|
|
239
|
+
// Merged in by time, before the first row or group that is older.
|
|
240
|
+
const timeOf = (item) => (item.kind === 'head' ? item.group.activityMs : rowOwnTimeMs(item.conversation));
|
|
241
|
+
const out = [];
|
|
242
|
+
let next = 0;
|
|
243
|
+
for (const item of items) {
|
|
244
|
+
if (item.kind !== 'member') {
|
|
245
|
+
while (next < kept.length && kept[next].group.activityMs > timeOf(item)) out.push(kept[next++]);
|
|
246
|
+
}
|
|
247
|
+
out.push(item);
|
|
248
|
+
}
|
|
249
|
+
while (next < kept.length) out.push(kept[next++]);
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function arrangeGroupedRows(rows, groups, { isOpen, matches, groupNameMatches }) {
|
|
219
254
|
const ordered = orderConversationRows(rows, groups);
|
|
220
255
|
const activity = groupActivityTimes(ordered);
|
|
221
256
|
const items = [];
|