@oar-sh/oar 0.9.15 → 0.9.17

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 (109) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
  2. package/CHANGELOG.md +305 -0
  3. package/README.md +12 -11
  4. package/package.json +2 -1
  5. package/server/claude-worker/claude-attachments.mjs +7 -4
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
  7. package/server/claude-worker/claude-sdk-adapter.mjs +167 -9
  8. package/server/claude-worker/claude-session-process.mjs +131 -33
  9. package/server/claude-worker/claude-session-worker-main.mjs +266 -0
  10. package/server/claude-worker/claude-session-worker.mjs +2 -253
  11. package/server/claude-worker/claude-turn-failure.mjs +8 -0
  12. package/server/claude-worker/claude-turn-publisher.mjs +282 -13
  13. package/server/claude-worker/claude-usage-totals.mjs +108 -0
  14. package/server/claude-worker/sdk-message-normalizer.mjs +55 -0
  15. package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
  16. package/server/copilot-worker/copilot-sdk-session-process.mjs +73 -7
  17. package/server/db-schema.mjs +115 -0
  18. package/server/deepseek-worker/deepseek-claude-backend.mjs +433 -0
  19. package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
  20. package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
  21. package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
  22. package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
  23. package/server/migrations/0022-relay-schedules.mjs +142 -0
  24. package/server/migrations/0023-relay-asks.mjs +141 -0
  25. package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
  26. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
  27. package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
  28. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
  29. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
  30. package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
  31. package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
  32. package/server/public/app/api-client.js +53 -0
  33. package/server/public/app/ask-user-view.js +3 -0
  34. package/server/public/app/bootstrap.js +162 -16
  35. package/server/public/app/context-tier-options.mjs +4 -0
  36. package/server/public/app/context-usage-view.mjs +17 -0
  37. package/server/public/app/conversation-group-order.mjs +35 -0
  38. package/server/public/app/conversation-provider-indicator.mjs +7 -0
  39. package/server/public/app/conversation-view.js +2 -0
  40. package/server/public/app/deepseek-settings-ui.js +342 -0
  41. package/server/public/app/inbox-ui.js +367 -0
  42. package/server/public/app/inbox-view.mjs +370 -0
  43. package/server/public/app/journal-view.js +54 -5
  44. package/server/public/app/model-selector-options.mjs +11 -0
  45. package/server/public/app/model-vision-support.mjs +2 -0
  46. package/server/public/app/no-conversation-state.mjs +1 -1
  47. package/server/public/app/plan-usage-view.mjs +165 -13
  48. package/server/public/app/provider-readiness.mjs +4 -1
  49. package/server/public/app/relay-error-ctas.mjs +8 -0
  50. package/server/public/app/relay-event-subscriptions-ui.js +4 -2
  51. package/server/public/app/relay-event-view.mjs +33 -7
  52. package/server/public/app/relay-schedule-view.mjs +119 -0
  53. package/server/public/app/relay-schedules-ui.js +172 -0
  54. package/server/public/app/settings-modal.js +11 -0
  55. package/server/public/app/settings-tabs.js +1 -1
  56. package/server/public/app/socket-handlers.js +37 -3
  57. package/server/public/app/store.js +18 -5
  58. package/server/public/index.html +567 -35
  59. package/server/relay-tools.md +18 -3
  60. package/server/repositories/conversation-group-repository.mjs +110 -17
  61. package/server/repositories/remote-relay-repository.mjs +20 -2
  62. package/server/repositories/session-repository.mjs +7 -1
  63. package/server/routes/ask-user-routes.mjs +57 -3
  64. package/server/routes/cache-routes.mjs +6 -0
  65. package/server/routes/conversation-group-routes.mjs +19 -3
  66. package/server/routes/deepseek-usage-import-routes.mjs +72 -0
  67. package/server/routes/messages-routes.mjs +195 -18
  68. package/server/routes/relay-ask-routes.mjs +387 -0
  69. package/server/routes/relay-event-routes.mjs +3 -1
  70. package/server/routes/relay-schedule-routes.mjs +57 -0
  71. package/server/routes/sessions-routes.mjs +932 -97
  72. package/server/server-runtime.mjs +203 -11
  73. package/server/services/about-service.mjs +1 -0
  74. package/server/services/cache-rebuild-service.mjs +19 -2
  75. package/server/services/claude-session-leftovers.mjs +226 -0
  76. package/server/services/conversation-preferences-service.mjs +17 -0
  77. package/server/services/deepseek-balance-service.mjs +102 -0
  78. package/server/services/deepseek-settings-service.mjs +380 -0
  79. package/server/services/deepseek-usage-import.mjs +471 -0
  80. package/server/services/model-variant-catalog-service.mjs +1 -0
  81. package/server/services/plan-usage-contract.mjs +100 -5
  82. package/server/services/plan-usage-deepseek.mjs +931 -0
  83. package/server/services/plan-usage-service.mjs +431 -0
  84. package/server/services/provider-model-selection.mjs +1 -0
  85. package/server/services/provider-reasoning-effort.mjs +15 -2
  86. package/server/services/relay-ask-link-service.mjs +174 -0
  87. package/server/services/relay-ask-service.mjs +604 -0
  88. package/server/services/relay-event-service.mjs +589 -73
  89. package/server/services/relay-schedule-service.mjs +596 -0
  90. package/server/services/remote-relay-caller-context.mjs +89 -20
  91. package/server/services/remote-relay-dispatcher.mjs +958 -31
  92. package/server/services/session-worker-launch-service.mjs +233 -82
  93. package/server/services/worker-secret-redaction.mjs +318 -0
  94. package/shared/context-window-fallbacks.mjs +3 -0
  95. package/shared/deepseek-api.mjs +137 -0
  96. package/shared/deepseek-models.mjs +114 -0
  97. package/shared/deepseek-pricing.mjs +146 -0
  98. package/shared/host-claude-session-env.mjs +101 -0
  99. package/shared/model-id.mjs +7 -0
  100. package/shared/preview-tool-core.mjs +1 -1
  101. package/shared/provider-routing.mjs +2 -2
  102. package/shared/relay-asks.mjs +145 -0
  103. package/shared/relay-events.mjs +268 -18
  104. package/shared/relay-schedules.mjs +128 -0
  105. package/shared/remote-relay-contract.mjs +554 -26
  106. package/shared/remote-relay-tool-core.mjs +16 -5
  107. package/shared/steer-settle-failure.mjs +1 -0
  108. package/shared/worker-runtime/direct-openai-image.mjs +128 -0
  109. package/shared/worker-runtime/worker-log-file.mjs +60 -0
@@ -0,0 +1,172 @@
1
+ // Relay schedules on the page: the header's "N scheduled" chip with its list
2
+ // and Cancel buttons (on a conversation a schedule fires into or that set
3
+ // one), and the "scheduled" marker on the sidebar row of a conversation a
4
+ // schedule fires into. The relay sends the whole active list on every change
5
+ // (socket `relay_schedules`); the page loads it once at start.
6
+
7
+ import { apiFetch } from './api-client.js';
8
+ import { conversations, currentConvId, escHtml, IS_SHARED_VIEW, showTransientRelayNotice } from './store.js';
9
+ import {
10
+ nextScheduledAt,
11
+ renderScheduledBadgeHtml,
12
+ renderSchedulePanelHtml,
13
+ scheduleChipText,
14
+ schedulesOf,
15
+ } from './relay-schedule-view.mjs';
16
+
17
+ let schedules = [];
18
+ let renderedChipKey = '';
19
+ let panelOpen = false;
20
+ let panelConversationId = '';
21
+ const changeListeners = new Set();
22
+
23
+ /** Runs after every change of the list (the sidebar re-renders its markers). */
24
+ export function onRelaySchedulesChanged(listener) {
25
+ changeListeners.add(listener);
26
+ return () => changeListeners.delete(listener);
27
+ }
28
+
29
+ export function applyRelaySchedules(list) {
30
+ schedules = Array.isArray(list) ? list.filter((schedule) => schedule && typeof schedule === 'object') : [];
31
+ syncRelayScheduleHeader();
32
+ for (const listener of changeListeners) {
33
+ try { listener(schedules); } catch {}
34
+ }
35
+ }
36
+
37
+ export async function loadRelaySchedules() {
38
+ if (IS_SHARED_VIEW) return;
39
+ const response = await apiFetch('/api/relay-schedules');
40
+ if (response && Array.isArray(response.schedules)) applyRelaySchedules(response.schedules);
41
+ }
42
+
43
+ /** The sidebar's marker for one conversation. */
44
+ export function relayScheduleMarkerHtml(conversationId) {
45
+ if (IS_SHARED_VIEW) return '';
46
+ return renderScheduledBadgeHtml(schedules, conversationId, { escHtml });
47
+ }
48
+
49
+ async function cancelSchedule(scheduleId, button) {
50
+ if (!scheduleId) return;
51
+ if (button) button.disabled = true;
52
+ const response = await apiFetch(`/api/relay-schedules/${encodeURIComponent(scheduleId)}`, { method: 'DELETE' });
53
+ if (!response?.ok) {
54
+ if (button) button.disabled = false;
55
+ showTransientRelayNotice?.('The schedule could not be cancelled. Try again in a moment.');
56
+ return;
57
+ }
58
+ // The socket brings the new list; this page does not wait for it.
59
+ applyRelaySchedules(schedules.filter((schedule) => schedule.id !== scheduleId));
60
+ }
61
+
62
+ // A fixed popover on <body>, placed under the chip, like the subscriptions'
63
+ // list: the title slot clips its overflow.
64
+ function ensurePanel() {
65
+ let panel = document.getElementById('relay-schedule-panel');
66
+ if (panel) return panel;
67
+ panel = document.createElement('div');
68
+ panel.id = 'relay-schedule-panel';
69
+ panel.className = 'relay-watch-panel';
70
+ panel.hidden = true;
71
+ document.body.appendChild(panel);
72
+ panel.addEventListener('click', (event) => {
73
+ const target = event.target instanceof Element ? event.target : null;
74
+ const cancelButton = target?.closest?.('.relay-schedule-cancel');
75
+ if (!cancelButton) return;
76
+ void cancelSchedule(cancelButton.getAttribute('data-schedule-id'), cancelButton);
77
+ });
78
+ document.addEventListener('click', (event) => {
79
+ if (!panelOpen) return;
80
+ // composedPath: a Cancel click may have re-rendered the panel already.
81
+ const path = event.composedPath();
82
+ if (path.includes(panel) || path.some((node) => node instanceof Element && node.classList.contains('relay-schedule-chip'))) return;
83
+ setPanelOpen(false);
84
+ });
85
+ document.addEventListener('keydown', (event) => {
86
+ if (!panelOpen || event.key !== 'Escape') return;
87
+ event.stopPropagation();
88
+ setPanelOpen(false);
89
+ document.querySelector('#chat-title-relay-schedules .relay-schedule-chip')?.focus?.();
90
+ }, true);
91
+ window.addEventListener('resize', () => { if (panelOpen) placePanel(); });
92
+ return panel;
93
+ }
94
+
95
+ function setPanelOpen(open) {
96
+ panelOpen = Boolean(open);
97
+ renderedChipKey = '';
98
+ syncRelayScheduleHeader();
99
+ }
100
+
101
+ function placePanel() {
102
+ const panel = document.getElementById('relay-schedule-panel');
103
+ const chip = document.querySelector('#chat-title-relay-schedules .relay-schedule-chip');
104
+ if (!panel || panel.hidden) return;
105
+ const rect = chip?.getBoundingClientRect?.();
106
+ if (!rect || (!rect.width && !rect.height)) {
107
+ panel.hidden = true;
108
+ return;
109
+ }
110
+ const margin = 8;
111
+ const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
112
+ const top = Math.round(rect.bottom + 4);
113
+ panel.style.top = `${top}px`;
114
+ panel.style.maxHeight = `${Math.max(120, window.innerHeight - top - margin)}px`;
115
+ panel.style.left = '0px';
116
+ const width = panel.offsetWidth;
117
+ const left = Math.max(margin, Math.min(rect.left, viewportWidth - width - margin));
118
+ panel.style.left = `${Math.round(left)}px`;
119
+ }
120
+
121
+ function ensureHeaderElements() {
122
+ const slot = document.getElementById('chat-title-slot');
123
+ if (!slot) return null;
124
+ let line = document.getElementById('chat-title-relay-schedules');
125
+ if (!line) {
126
+ line = document.createElement('span');
127
+ line.id = 'chat-title-relay-schedules';
128
+ line.hidden = true;
129
+ const after = document.getElementById('chat-title-relay-events') || document.getElementById('chat-title-origin');
130
+ if (after?.nextSibling) slot.insertBefore(line, after.nextSibling);
131
+ else slot.appendChild(line);
132
+ line.addEventListener('click', (event) => {
133
+ const target = event.target instanceof Element ? event.target : null;
134
+ if (target?.closest?.('.relay-schedule-chip')) {
135
+ event.stopPropagation();
136
+ setPanelOpen(!panelOpen);
137
+ }
138
+ });
139
+ }
140
+ return line;
141
+ }
142
+
143
+ /** The header's chip for the conversation on screen. Idempotent. */
144
+ export function syncRelayScheduleHeader() {
145
+ const line = ensureHeaderElements();
146
+ if (!line) return;
147
+ const panel = ensurePanel();
148
+ const id = String(currentConvId || '').trim();
149
+ // Another conversation on screen closes the list.
150
+ if (id !== panelConversationId) panelOpen = false;
151
+ panelConversationId = id;
152
+ const mine = IS_SHARED_VIEW || !id ? [] : schedulesOf(schedules, id);
153
+ const chipText = scheduleChipText(mine);
154
+ if (!chipText) panelOpen = false;
155
+ const key = JSON.stringify([id, chipText, panelOpen, mine.map((schedule) => [schedule.id, schedule.nextFireAt, schedule.skippedCount || 0])]);
156
+ if (key === renderedChipKey) return;
157
+ renderedChipKey = key;
158
+ if (!chipText) {
159
+ line.hidden = true;
160
+ line.innerHTML = '';
161
+ panel.hidden = true;
162
+ panel.innerHTML = '';
163
+ return;
164
+ }
165
+ const next = nextScheduledAt(mine);
166
+ const nextText = next ? new Date(next).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
167
+ line.innerHTML = `<button type="button" class="relay-watch-chip relay-schedule-chip" aria-expanded="${panelOpen ? 'true' : 'false'}" aria-controls="relay-schedule-panel" title="Prompts the relay queues at set times (agents set them with remote_relay schedule)">⏰ ${escHtml(chipText)}${nextText ? ` <span class="relay-watch-hint">next ${escHtml(nextText)}</span>` : ''}</button>`;
168
+ line.hidden = false;
169
+ panel.innerHTML = panelOpen ? renderSchedulePanelHtml(schedules, id, { conversations, escHtml }) : '';
170
+ panel.hidden = !panelOpen;
171
+ if (panelOpen) placePanel();
172
+ }
@@ -55,6 +55,16 @@ import { refreshPushSettingsSection } from './push-settings.js';
55
55
  import { selectSettingsTab } from './settings-tabs.js';
56
56
  import { refreshClaudeAuthSection } from './claude-auth-ui.js';
57
57
  import { refreshGrokAuthSection } from './grok-auth-ui.js';
58
+ import { syncDeepSeekSettingsInputs } from './deepseek-settings-ui.js';
59
+
60
+ export {
61
+ applyDeepSeekSettingsState,
62
+ importDeepSeekUsageExport,
63
+ refreshDeepSeekSettingsState,
64
+ removeDeepSeekSettings,
65
+ saveDeepSeekSettings,
66
+ toggleDeepSeekProvider,
67
+ } from './deepseek-settings-ui.js';
58
68
  import { openClaudeCloudSettingsSection } from './claude-cloud-settings-ui.js';
59
69
  import { refreshCliInstallSections } from './cli-install-ui.js';
60
70
  import { refreshUpdateSection } from './update-ui.js';
@@ -1949,6 +1959,7 @@ export function openSettingsModal(tab, providerTab) {
1949
1959
  cursorSettingsInputsDirty = false;
1950
1960
  ensureCursorSettingsInputTracking();
1951
1961
  void syncCursorSettingsInputs();
1962
+ void syncDeepSeekSettingsInputs();
1952
1963
  void refreshCursorAllowanceState();
1953
1964
  void refreshCursorDashboardTokenState();
1954
1965
  void refreshGrokAllowanceState();
@@ -4,7 +4,7 @@ const SETTINGS_PROVIDER_TAB_STORAGE_KEY = 'copilot_settings_provider_tab';
4
4
  const SETTINGS_TABS = ['general', 'providers', 'relays', 'previews', 'shares', 'notifications', 'features'];
5
5
  // Copilot leads: it is the default provider, and `SETTINGS_PROVIDER_TABS[0]`
6
6
  // is also the sub-tab a first-time visitor lands on.
7
- const SETTINGS_PROVIDER_TABS = ['copilot', 'openai', 'claude', 'claude-cloud', 'grok', 'cursor'];
7
+ const SETTINGS_PROVIDER_TABS = ['copilot', 'openai', 'claude', 'claude-cloud', 'grok', 'cursor', 'deepseek'];
8
8
 
9
9
  let settingsTabsBound = false;
10
10
 
@@ -38,6 +38,9 @@ import { publishStatusEvent, recordStatusEvent } from './status-store.mjs';
38
38
  import { clearOpenConversation, renderConvList, refreshConversations, scheduleLoadedConversationsRefresh, applyConversationGroupEvent, openConversation } from './journal-view.js';
39
39
  import { applyBackgroundTasksToConversation, applyTurnStatusToConversation } from './conversation-turn-state.mjs';
40
40
  import { applyRelayEventSubscriptions, loadRelayEventSubscriptions } from './relay-event-subscriptions-ui.js';
41
+ import { applyRelaySchedules, loadRelaySchedules } from './relay-schedules-ui.js';
42
+ import { noteInboxQuestionsChanged, noteRelayAskUpdated, refreshInbox } from './inbox-ui.js';
43
+ import { RELAY_ASK_SOCKET_EVENT } from './inbox-view.mjs';
41
44
  import {
42
45
  upsertRelayQuestion,
43
46
  loadRelayQuestions,
@@ -150,6 +153,7 @@ let deps = null;
150
153
  * @property {(payload?: object) => void} applyOpenAISettingsState
151
154
  * @property {(payload?: object) => void} applyClaudeSettingsState
152
155
  * @property {(payload?: object) => void} applyGrokSettingsState
156
+ * @property {(payload?: object) => void} applyDeepSeekSettingsState
153
157
  * @property {(payload?: object) => void} applyCursorSettingsState
154
158
  */
155
159
 
@@ -368,6 +372,10 @@ export async function connectSocket(overrideDeps) {
368
372
  void handleTaskConsoleReconnect();
369
373
  // Subscriptions that changed while away.
370
374
  void loadRelayEventSubscriptions();
375
+ // Schedules that changed or fired while away.
376
+ void loadRelaySchedules();
377
+ // Questions asked or answered while away.
378
+ void refreshInbox();
371
379
  // Shares made or stopped while away.
372
380
  void refreshShares().catch(() => {});
373
381
  });
@@ -490,6 +498,15 @@ export async function connectSocket(overrideDeps) {
490
498
  .catch(() => {});
491
499
  }
492
500
  });
501
+ socket.on('deepseek_settings_updated', (payload) => {
502
+ const state = deps?.applyDeepSeekSettingsState?.(payload || {});
503
+ updateProviderReadinessInputs({ deepseek: state || payload || null });
504
+ if (Number(payload?.reconciliation?.updatedUnstartedConversations || 0) > 0) {
505
+ void Promise.resolve()
506
+ .then(() => deps?.refreshCurrentView?.())
507
+ .catch(() => {});
508
+ }
509
+ });
493
510
  socket.on('cursor_settings_updated', (payload) => {
494
511
  const state = deps?.applyCursorSettingsState?.(payload || {});
495
512
  updateProviderReadinessInputs({ cursor: state || payload || null });
@@ -618,11 +635,20 @@ export async function connectSocket(overrideDeps) {
618
635
  noteTranscriptLiveRow();
619
636
  keepLiveRow(conversationId, message, messageId);
620
637
  });
621
- socket.on('relay_question', ({ question }) => upsertRelayQuestion(question));
622
- socket.on('relay_question_updated', ({ question }) => upsertRelayQuestion(question));
638
+ socket.on('relay_question', ({ question }) => {
639
+ upsertRelayQuestion(question);
640
+ noteInboxQuestionsChanged();
641
+ });
642
+ socket.on('relay_question_updated', ({ question }) => {
643
+ upsertRelayQuestion(question);
644
+ noteInboxQuestionsChanged();
645
+ });
623
646
  socket.on('relay_question_changed', () => {
624
647
  loadRelayQuestions(currentConvId);
648
+ noteInboxQuestionsChanged();
625
649
  });
650
+ // Questions that don't block: one ask, after every change of it.
651
+ socket.on(RELAY_ASK_SOCKET_EVENT, (payload) => noteRelayAskUpdated(payload));
626
652
  socket.on('relay_board', ({ board }) => upsertRelayBoard(board));
627
653
  socket.on('relay_board_updated', ({ board }) => upsertRelayBoard(board));
628
654
  socket.on('relay_board_changed', () => {
@@ -632,6 +658,10 @@ export async function connectSocket(overrideDeps) {
632
658
  socket.on('relay_event_subscriptions', ({ subscriptions }) => {
633
659
  applyRelayEventSubscriptions(subscriptions);
634
660
  });
661
+ // Relay schedules: the whole active list, after every change.
662
+ socket.on('relay_schedules', ({ schedules }) => {
663
+ applyRelaySchedules(schedules);
664
+ });
635
665
  socket.on('background_tasks', ({ conversationId, tasks, endedTasks }) => {
636
666
  setConversationBackgroundTasks(conversationId, tasks, { endedTasks, event: true });
637
667
  // The sidebar's row (and its group's head) count background work as
@@ -799,7 +829,7 @@ export async function connectSocket(overrideDeps) {
799
829
  applyConversationTitleUpdate(conversationId, title, updatedAt);
800
830
  syncChatTitleControls();
801
831
  });
802
- socket.on('conversation_preferences_updated', ({ conversationId, preferredRelayMode, preferredModel, preferredReasoningEffort, autoCompactWindow, thinkingEnabled, thinkingDisplay, senderClientId }) => {
832
+ socket.on('conversation_preferences_updated', ({ conversationId, preferredRelayMode, preferredModel, preferredReasoningEffort, autoCompactWindow, thinkingEnabled, thinkingDisplay, mayCreateSessions, senderClientId }) => {
803
833
  if (senderClientId && senderClientId === CLIENT_ID) return;
804
834
  const id = String(conversationId || '').trim();
805
835
  if (!id || !conversations[id]) return;
@@ -821,6 +851,10 @@ export async function connectSocket(overrideDeps) {
821
851
  thinkingDisplay: thinkingDisplay === undefined
822
852
  ? (conversations[id].thinkingDisplay ?? null)
823
853
  : (thinkingDisplay ?? null),
854
+ // The owner's "agents may start sessions"; an older relay omits it.
855
+ mayCreateSessions: typeof mayCreateSessions === 'boolean'
856
+ ? mayCreateSessions
857
+ : conversations[id].mayCreateSessions === true,
824
858
  };
825
859
  if (String(currentConvId || '').trim() === id) {
826
860
  applyConversationPreferencesForConversation(id, {
@@ -222,6 +222,16 @@ export function setCurrentConv(id) {
222
222
  updateCompactButton();
223
223
  }
224
224
 
225
+ /**
226
+ * Forgets the kept empty groups before the list's first page names the ones
227
+ * still kept (one the owner dissolved meanwhile is not named again).
228
+ */
229
+ export function forgetKeptEmptyConversationGroups() {
230
+ for (const [id, group] of Object.entries(conversationGroups)) {
231
+ if (group?.keptEmptyAt) delete conversationGroups[id];
232
+ }
233
+ }
234
+
225
235
  /** Merges a list payload's `groups` (by id) into the known session groups. */
226
236
  export function mergeConversationGroups(groups) {
227
237
  if (!groups || typeof groups !== 'object') return;
@@ -240,13 +250,15 @@ export function forgetConversationGroup(groupId) {
240
250
  /**
241
251
  * Keeps only the groups that a loaded row still belongs to: after a list
242
252
  * refresh a dissolved group has no row left, and the picker must not offer
243
- * it. Returns the ids dropped.
253
+ * it. An owner's group an agent emptied (`keptEmptyAt`) has no row and
254
+ * stays: the list's first page names it while it is kept. Returns the ids
255
+ * dropped.
244
256
  */
245
257
  export function pruneConversationGroups(keepGroupIds) {
246
258
  const keep = new Set((Array.isArray(keepGroupIds) ? keepGroupIds : []).map((id) => String(id || '').trim()).filter(Boolean));
247
259
  const dropped = [];
248
260
  for (const id of Object.keys(conversationGroups)) {
249
- if (keep.has(id)) continue;
261
+ if (keep.has(id) || conversationGroups[id]?.keptEmptyAt) continue;
250
262
  delete conversationGroups[id];
251
263
  dropped.push(id);
252
264
  }
@@ -1126,9 +1138,10 @@ function measureSidebarHeaderMinWidthPx() {
1126
1138
  sectionCount += 1;
1127
1139
  }
1128
1140
 
1129
- const statusButton = document.getElementById('status-btn');
1130
- if (isVisibleElement(statusButton)) {
1131
- const buttonStyle = window.getComputedStyle(statusButton);
1141
+ for (const id of ['status-btn', 'inbox-btn']) {
1142
+ const headerButton = document.getElementById(id);
1143
+ if (!isVisibleElement(headerButton)) continue;
1144
+ const buttonStyle = window.getComputedStyle(headerButton);
1132
1145
  neededWidth += Math.ceil(parseCssPx(buttonStyle.minWidth));
1133
1146
  sectionCount += 1;
1134
1147
  }