@oar-sh/oar 0.9.19 → 0.9.21
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/.github/extensions/web-relay/polling/polling-loop.mjs +2 -26
- package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +2 -26
- package/CHANGELOG.md +529 -0
- package/README.md +14 -14
- package/bin/oar.js +392 -98
- package/package.json +4 -2
- package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +60 -0
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +258 -1
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +22 -1
- package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
- package/server/claude-worker/claude-session-process.mjs +10 -0
- package/server/claude-worker/claude-session-worker-main.mjs +8 -1
- package/server/claude-worker/claude-turn-publisher.mjs +163 -8
- package/server/claude-worker/claude-usage-totals.mjs +58 -0
- package/server/copilot-worker/copilot-plan-board.mjs +4 -7
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +86 -3
- package/server/copilot-worker/copilot-sdk-session-process.mjs +131 -5
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -1
- package/server/cursor-worker/cursor-session-worker.mjs +12 -1
- package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
- package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
- package/server/db-schema.mjs +18 -0
- package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
- package/server/grok-worker/grok-background-tasks.mjs +9 -0
- package/server/grok-worker/grok-session-worker.mjs +12 -1
- package/server/grok-worker/grok-turn-runner.mjs +47 -6
- package/server/grok-worker/grok-worker-steering.mjs +23 -0
- package/server/migrations/0001-add-structured-answer.mjs +5 -7
- package/server/migrations/0002-recent-workspace-roots-path-key.mjs +5 -7
- package/server/migrations/0003-push-subscriptions.mjs +5 -7
- package/server/migrations/0004-steer-settle-markers.mjs +5 -7
- package/server/migrations/0005-remote-relays.mjs +5 -7
- package/server/migrations/0006-workspace-root-settings.mjs +5 -7
- package/server/migrations/0007-claude-cloud.mjs +5 -7
- package/server/migrations/0008-message-pins.mjs +5 -7
- package/server/migrations/0009-subagent-timeline.mjs +5 -7
- package/server/migrations/0010-subagent-timeline-pruning.mjs +5 -7
- package/server/migrations/0011-relay-event-subscriptions.mjs +5 -7
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +5 -7
- package/server/migrations/0013-previews.mjs +5 -7
- package/server/migrations/0014-conversation-groups.mjs +5 -7
- package/server/migrations/0015-conversation-group-position.mjs +5 -7
- package/server/migrations/0016-share-access-log.mjs +5 -7
- package/server/migrations/0017-conversation-history-indexes.mjs +5 -7
- package/server/migrations/0018-background-task-rows.mjs +5 -7
- package/server/migrations/0019-relay-event-subscription-options.mjs +5 -7
- package/server/migrations/0020-conversation-may-create-sessions.mjs +5 -7
- package/server/migrations/0021-conversation-group-agent-marks.mjs +5 -7
- package/server/migrations/0022-relay-schedules.mjs +5 -7
- package/server/migrations/0023-relay-asks.mjs +5 -7
- package/server/migrations/0024-deepseek-usage-totals.mjs +5 -7
- package/server/migrations/0025-deepseek-usage-requests-daily.mjs +5 -7
- package/server/migrations/0026-deepseek-usage-served-models.mjs +5 -7
- package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +5 -7
- package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +5 -7
- package/server/migrations/0029-deepseek-balance-rises.mjs +5 -7
- package/server/migrations/0030-deepseek-usage-actuals.mjs +5 -7
- package/server/migrations/0031-queue-provider.mjs +5 -7
- package/server/migrations/0032-runtime-session-billing-switch.mjs +5 -7
- package/server/migrations/0033-claude-api-usage-checkpoints.mjs +5 -7
- package/server/migrations/0034-claude-api-usage-totals.mjs +5 -7
- package/server/migrations/0035-conversation-plan-only.mjs +87 -0
- package/server/migrations/0036-token-usage.mjs +267 -0
- package/server/public/app/about-ui.js +1 -1
- package/server/public/app/about-view.mjs +106 -3
- package/server/public/app/action-confirmations.js +1 -1
- package/server/public/app/api-client.js +6 -3
- package/server/public/app/ask-user-view.js +20 -6
- package/server/public/app/attachments-view.js +1 -1
- package/server/public/app/back-guard.mjs +31 -5
- package/server/public/app/bootstrap.js +611 -70
- package/server/public/app/chat-actions-menu-state.mjs +139 -4
- package/server/public/app/chat-header-meta.mjs +27 -0
- package/server/public/app/claude-billing-toggle-ui.js +2 -1
- package/server/public/app/claude-cloud-conversation-ui.js +2 -2
- package/server/public/app/composer-control-state.mjs +12 -2
- package/server/public/app/composer-settings-label.mjs +90 -0
- package/server/public/app/composer-settings-ui.js +442 -0
- package/server/public/app/context-menu.mjs +36 -5
- package/server/public/app/context-usage-view.mjs +16 -0
- package/server/public/app/conversation-deep-link.mjs +15 -11
- package/server/public/app/conversation-group-drag.mjs +12 -2
- package/server/public/app/conversation-group-order.mjs +9 -2
- package/server/public/app/conversation-group-state.mjs +47 -10
- package/server/public/app/conversation-origin-ui.js +17 -1
- package/server/public/app/conversation-view.js +20 -2
- package/server/public/app/cwd-menu-placement.mjs +18 -6
- package/server/public/app/cwd-picker.js +36 -6
- package/server/public/app/emoji-view.js +1 -1
- package/server/public/app/font-scaling.js +26 -0
- package/server/public/app/header-priority-actions.mjs +234 -0
- package/server/public/app/hidden-messages-view.mjs +4 -0
- package/server/public/app/inbox-ui.js +50 -2
- package/server/public/app/inbox-view.mjs +141 -5
- package/server/public/app/journal-view.js +311 -58
- package/server/public/app/phone-landscape-ui.js +225 -0
- package/server/public/app/phone-landscape.mjs +99 -0
- package/server/public/app/phone-typing-state.mjs +104 -0
- package/server/public/app/pinned-messages-view.mjs +5 -0
- package/server/public/app/plan-board-shared.mjs +49 -0
- package/server/public/app/plan-usage-view.mjs +1 -1
- package/server/public/app/push-settings.js +2 -2
- package/server/public/app/pwa-install.js +24 -3
- package/server/public/app/relay-board-inline.mjs +4 -1
- package/server/public/app/relay-board-view.js +38 -10
- package/server/public/app/relay-event-subscriptions-ui.js +71 -22
- package/server/public/app/relay-event-view.mjs +42 -0
- package/server/public/app/relay-restart-ui.js +2 -2
- package/server/public/app/relay-schedule-view.mjs +32 -6
- package/server/public/app/relay-schedules-ui.js +69 -20
- package/server/public/app/remote-relay-origin-view.mjs +41 -23
- package/server/public/app/session-history-ui.js +250 -0
- package/server/public/app/session-history.mjs +599 -0
- package/server/public/app/settings-modal.js +61 -6
- package/server/public/app/settings-tabs.js +12 -0
- package/server/public/app/share-state.js +1 -1
- package/server/public/app/shared-conversations-settings.js +2 -2
- package/server/public/app/socket-handlers.js +15 -4
- package/server/public/app/status-view.mjs +18 -2
- package/server/public/app/store.js +87 -16
- package/server/public/app/subagent-viewer.mjs +27 -2
- package/server/public/app/tab-strip-fade.mjs +101 -0
- package/server/public/app/tap-actions.js +66 -0
- package/server/public/app/usage-limit-ui.js +63 -2
- package/server/public/app/viewer-history.mjs +13 -2
- package/server/public/index.html +2258 -1585
- package/server/public/sw.js +8 -0
- package/server/relay-heap-guard.mjs +14 -1
- package/server/relay-self-restart.mjs +83 -2
- package/server/relay-tools.md +5 -3
- package/server/repositories/message-repository.mjs +54 -1
- package/server/repositories/remote-relay-repository.mjs +10 -0
- package/server/repositories/session-repository.mjs +25 -1
- package/server/routes/claude-cloud-routes.mjs +19 -0
- package/server/routes/messages-routes.mjs +144 -1
- package/server/routes/relay-ask-routes.mjs +138 -8
- package/server/routes/relay-board-routes.mjs +55 -40
- package/server/routes/sessions-routes.mjs +144 -14
- package/server/server-runtime.mjs +322 -51
- package/server/services/about-service.mjs +81 -53
- package/server/services/cache-rebuild-service.mjs +7 -0
- package/server/services/claude-billing-swap-service.mjs +7 -20
- package/server/services/claude-cloud-session-service.mjs +47 -5
- package/server/services/conversation-preferences-service.mjs +31 -0
- package/server/services/database-file.mjs +413 -0
- package/server/services/db-backup-service.mjs +809 -0
- package/server/services/deepseek-usage-import.mjs +6 -9
- package/server/services/delete-archive-service.mjs +3 -0
- package/server/services/earlier-database-service.mjs +69 -126
- package/server/services/oar-cli-helpers.mjs +214 -12
- package/server/services/oar-cli-menu.mjs +1 -0
- package/server/services/oar-state-migration-service.mjs +33 -13
- package/server/services/package-data-rescue.mjs +4 -2
- package/server/services/plan-usage-claude-api.mjs +14 -15
- package/server/services/plan-usage-contract.mjs +6 -2
- package/server/services/plan-usage-deepseek.mjs +1 -1
- package/server/services/plan-usage-tokens.mjs +248 -0
- package/server/services/push-dispatch-service.mjs +134 -9
- package/server/services/relay-cli-launcher-service.mjs +3 -0
- package/server/services/relay-event-service.mjs +30 -0
- package/server/services/relay-question-context.mjs +11 -0
- package/server/services/relay-restart-picture.mjs +67 -22
- package/server/services/remote-inbox-watch-service.mjs +204 -0
- package/server/services/remote-relay-caller-context.mjs +80 -7
- package/server/services/remote-relay-dispatcher.mjs +101 -19
- package/server/services/restart-resume-service.mjs +5 -2
- package/server/services/session-activity-tracker.mjs +1 -1
- package/server/services/session-launch-mutex-service.mjs +23 -0
- package/server/services/session-worker-launch-service.mjs +12 -3
- package/server/services/session-worker-registry-service.mjs +36 -0
- package/server/services/session-worker-stop-service.mjs +27 -6
- package/server/services/session-worker-websocket-service.mjs +40 -16
- package/server/services/token-usage-service.mjs +430 -0
- package/server/services/update-install-service.mjs +13 -0
- package/server/services/usage-limit-pause-service.mjs +8 -1
- package/server/services/worker-recycle-service.mjs +772 -0
- package/server/services/worker-replacement.mjs +197 -0
- package/server/services/worker-scope.mjs +77 -0
- package/shared/build-id.mjs +128 -0
- package/shared/plan-board-actions.mjs +130 -0
- package/shared/plan-only.mjs +43 -0
- package/shared/relay-asks.mjs +3 -0
- package/shared/relay-events.mjs +38 -4
- package/shared/remote-relay-contract.mjs +66 -11
- package/shared/turn-token-usage.mjs +144 -0
- package/shared/worker-runtime/heartbeat.mjs +5 -0
- package/shared/worker-runtime/simple-steering-state.mjs +38 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +23 -0
- package/shared/worker-runtime/working-directory.mjs +1 -1
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
// The composer's ⚙ settings: a label row above the message box
|
|
2
|
+
// ("⚙ 🔒 Opus 5.5 · high · Autopilot [Subscription] 🗂 📎") and a panel
|
|
3
|
+
// that holds Mode, Model (with the lock note), Effort, Context window,
|
|
4
|
+
// Billing and Context usage. A popover above the label row on desktop, a
|
|
5
|
+
// bottom sheet on a phone (max-width 680px).
|
|
6
|
+
//
|
|
7
|
+
// The selects live in the panel's markup with their old ids; their change
|
|
8
|
+
// listeners stay in bootstrap.js. This module only reads them: the label is
|
|
9
|
+
// redrawn whenever a select, the lock note or the billing toggle changes,
|
|
10
|
+
// and on the body's chat-kind classes (cloud chat, shared reader).
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
composerBillingPill,
|
|
14
|
+
composerSettingsLabelParts,
|
|
15
|
+
composerSettingsLabelText,
|
|
16
|
+
} from './composer-settings-label.mjs';
|
|
17
|
+
import { FONT_SCALE_CHANGE_EVENT, getUiScale } from './font-scaling.js';
|
|
18
|
+
|
|
19
|
+
const PANEL_ID = 'composer-settings';
|
|
20
|
+
const PHONE_QUERY = '(max-width: 680px)';
|
|
21
|
+
// The panel field a label part (or the pill) opens, and its control.
|
|
22
|
+
const FIELD_CONTROL_IDS = Object.freeze({
|
|
23
|
+
mode: 'mode-select',
|
|
24
|
+
model: 'model-select',
|
|
25
|
+
effort: 'reasoning-effort-select',
|
|
26
|
+
tier: 'context-tier-select',
|
|
27
|
+
billing: 'claude-billing-toggle',
|
|
28
|
+
});
|
|
29
|
+
// Gap between the label row and the popover, and the room kept free above it.
|
|
30
|
+
const POPOVER_GAP_PX = 6;
|
|
31
|
+
const POPOVER_EDGE_PX = 8;
|
|
32
|
+
const POPOVER_MIN_HEIGHT_PX = 160;
|
|
33
|
+
const POPOVER_TYPING_MIN_HEIGHT_PX = 64;
|
|
34
|
+
const POPOVER_MIN_WIDTH_PX = 200;
|
|
35
|
+
|
|
36
|
+
let deps = {};
|
|
37
|
+
let panelOpen = false;
|
|
38
|
+
let syncQueued = false;
|
|
39
|
+
let bound = false;
|
|
40
|
+
let repositionBound = false;
|
|
41
|
+
|
|
42
|
+
function el(id) {
|
|
43
|
+
return typeof document === 'undefined' ? null : document.getElementById(id);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isPhone() {
|
|
47
|
+
try { return window.matchMedia(PHONE_QUERY).matches; } catch { return false; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isShared() {
|
|
51
|
+
try {
|
|
52
|
+
if (deps.isSharedReaderMode?.() === true) return true;
|
|
53
|
+
} catch {}
|
|
54
|
+
return document.body?.classList?.contains('shared-reader-mode') === true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function shownOptionText(select) {
|
|
58
|
+
if (!select) return '';
|
|
59
|
+
const option = select.options?.[select.selectedIndex];
|
|
60
|
+
return option ? option.textContent : '';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function setText(node, text) {
|
|
64
|
+
if (node && node.textContent !== text) node.textContent = text;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readLabelInput() {
|
|
68
|
+
const mode = el('mode-select');
|
|
69
|
+
const model = el('model-select');
|
|
70
|
+
const effort = el('reasoning-effort-select');
|
|
71
|
+
const tier = el('context-tier-select');
|
|
72
|
+
const note = el('model-lock-note');
|
|
73
|
+
const noteLocked = !!note && !note.hidden && String(note.textContent || '').trim() !== '';
|
|
74
|
+
return {
|
|
75
|
+
model: shownOptionText(model),
|
|
76
|
+
locked: noteLocked || model?.dataset?.runtimeModelLocked === '1',
|
|
77
|
+
effort: shownOptionText(effort),
|
|
78
|
+
effortValue: effort ? String(effort.value || '') : '',
|
|
79
|
+
mode: shownOptionText(mode),
|
|
80
|
+
tier: shownOptionText(tier),
|
|
81
|
+
tierValue: tier ? String(tier.value || '') : '',
|
|
82
|
+
cloud: document.body?.classList?.contains('claude-cloud-conversation') === true,
|
|
83
|
+
hidden: {
|
|
84
|
+
model: !!model?.hidden,
|
|
85
|
+
effort: !!effort?.hidden,
|
|
86
|
+
mode: !!mode?.hidden,
|
|
87
|
+
tier: !!tier?.hidden,
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function renderLabel() {
|
|
93
|
+
const label = el('composer-settings-label');
|
|
94
|
+
if (!label) return;
|
|
95
|
+
const parts = composerSettingsLabelParts(readLabelInput());
|
|
96
|
+
const key = JSON.stringify(parts);
|
|
97
|
+
if (label.dataset.renderKey !== key) {
|
|
98
|
+
const nodes = [];
|
|
99
|
+
parts.forEach((part, index) => {
|
|
100
|
+
if (index) {
|
|
101
|
+
const separator = document.createElement('span');
|
|
102
|
+
separator.className = 'composer-settings-sep';
|
|
103
|
+
separator.setAttribute('aria-hidden', 'true');
|
|
104
|
+
separator.textContent = ' · ';
|
|
105
|
+
nodes.push(separator);
|
|
106
|
+
}
|
|
107
|
+
const node = document.createElement('span');
|
|
108
|
+
node.className = 'composer-settings-part';
|
|
109
|
+
node.dataset.field = part.field;
|
|
110
|
+
if (part.field === 'model' && part.locked) {
|
|
111
|
+
const lock = document.createElement('span');
|
|
112
|
+
lock.className = 'composer-settings-lock';
|
|
113
|
+
lock.textContent = '🔒';
|
|
114
|
+
lock.setAttribute('aria-label', 'locked');
|
|
115
|
+
node.append(lock, ' ');
|
|
116
|
+
}
|
|
117
|
+
node.append(part.text);
|
|
118
|
+
nodes.push(node);
|
|
119
|
+
});
|
|
120
|
+
if (!nodes.length) {
|
|
121
|
+
const empty = document.createElement('span');
|
|
122
|
+
empty.className = 'composer-settings-part composer-settings-empty';
|
|
123
|
+
empty.textContent = 'Chat settings';
|
|
124
|
+
nodes.push(empty);
|
|
125
|
+
}
|
|
126
|
+
label.replaceChildren(...nodes);
|
|
127
|
+
label.dataset.renderKey = key;
|
|
128
|
+
}
|
|
129
|
+
const text = composerSettingsLabelText(parts);
|
|
130
|
+
const title = text ? `Chat settings: ${text}` : 'Chat settings';
|
|
131
|
+
if (label.title !== title) label.title = title;
|
|
132
|
+
if (label.getAttribute('aria-label') !== title) label.setAttribute('aria-label', title);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Image models turn the effort select into Quality and the context-window
|
|
136
|
+
// select into the image size (bootstrap.js sets their titles).
|
|
137
|
+
function renderFieldLabels() {
|
|
138
|
+
const effort = el('reasoning-effort-select');
|
|
139
|
+
const tier = el('context-tier-select');
|
|
140
|
+
setText(el('reasoning-effort-label'), effort?.title === 'Quality' ? 'Quality' : 'Effort');
|
|
141
|
+
setText(el('context-tier-label'), tier?.title === 'Image size' ? 'Image size' : 'Context window');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function renderBillingPill() {
|
|
145
|
+
const pill = el('composer-billing-pill');
|
|
146
|
+
if (!pill) return;
|
|
147
|
+
const toggle = el('claude-billing-toggle');
|
|
148
|
+
const model = composerBillingPill({
|
|
149
|
+
toggleVisible: !!toggle && !toggle.hidden,
|
|
150
|
+
billing: toggle?.dataset?.billing || '',
|
|
151
|
+
});
|
|
152
|
+
const visible = model.visible && !isShared();
|
|
153
|
+
if (pill.hidden !== !visible) pill.hidden = !visible;
|
|
154
|
+
setText(pill, model.text);
|
|
155
|
+
if (model.billing) {
|
|
156
|
+
if (pill.dataset.billing !== model.billing) pill.dataset.billing = model.billing;
|
|
157
|
+
} else if ('billing' in pill.dataset) {
|
|
158
|
+
delete pill.dataset.billing;
|
|
159
|
+
}
|
|
160
|
+
const title = visible ? `Billing: ${model.text}` : '';
|
|
161
|
+
if (pill.title !== title) pill.title = title;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Redraws the label, the field names and the billing pill now. */
|
|
165
|
+
export function syncComposerSettings() {
|
|
166
|
+
if (typeof document === 'undefined') return;
|
|
167
|
+
syncQueued = false;
|
|
168
|
+
renderFieldLabels();
|
|
169
|
+
renderLabel();
|
|
170
|
+
renderBillingPill();
|
|
171
|
+
if (panelOpen && isShared()) closeComposerSettings();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Redraws once after the current task: callers may set several values in a row. */
|
|
175
|
+
export function scheduleComposerSettingsSync() {
|
|
176
|
+
if (syncQueued) return;
|
|
177
|
+
syncQueued = true;
|
|
178
|
+
queueMicrotask(syncComposerSettings);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function isRendered(node) {
|
|
182
|
+
return !!node && !node.hidden && node.getClientRects().length > 0;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function fieldFocusTarget(field) {
|
|
186
|
+
const control = el(FIELD_CONTROL_IDS[field] || '');
|
|
187
|
+
if (!isRendered(control)) return null;
|
|
188
|
+
if (field === 'billing') {
|
|
189
|
+
return control.querySelector('.claude-billing-option[aria-pressed="true"]')
|
|
190
|
+
|| control.querySelector('.claude-billing-option');
|
|
191
|
+
}
|
|
192
|
+
return control.disabled ? null : control;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function firstFocusableField() {
|
|
196
|
+
for (const field of ['mode', 'model', 'effort', 'tier', 'billing']) {
|
|
197
|
+
const target = fieldFocusTarget(field);
|
|
198
|
+
if (target) return target;
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// `moveFocus` false: the field is marked but the focus stays where it is (the
|
|
204
|
+
// message box, whose keyboard stays up in a phone's typing mode).
|
|
205
|
+
function focusField(field, { moveFocus = true } = {}) {
|
|
206
|
+
const panel = el(PANEL_ID);
|
|
207
|
+
if (!panel) return;
|
|
208
|
+
for (const node of panel.querySelectorAll('.composer-settings-field-focus')) {
|
|
209
|
+
node.classList.remove('composer-settings-field-focus');
|
|
210
|
+
}
|
|
211
|
+
let target = field ? fieldFocusTarget(field) : null;
|
|
212
|
+
if (field) {
|
|
213
|
+
const wrapper = el(FIELD_CONTROL_IDS[field] || '')?.closest?.('.composer-settings-field');
|
|
214
|
+
wrapper?.classList?.add('composer-settings-field-focus');
|
|
215
|
+
wrapper?.scrollIntoView?.({ block: 'nearest' });
|
|
216
|
+
}
|
|
217
|
+
if (!moveFocus) return;
|
|
218
|
+
// A phone gets no select focused by itself: the sheet is for the thumb,
|
|
219
|
+
// and a focused select there reads as an open picker.
|
|
220
|
+
if (!target && !field && !isPhone()) target = firstFocusableField();
|
|
221
|
+
try { (target || panel).focus({ preventScroll: true }); } catch {}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function positionPanel() {
|
|
225
|
+
const panel = el(PANEL_ID);
|
|
226
|
+
const backdrop = el('composer-settings-backdrop');
|
|
227
|
+
if (!panel || panel.hidden) return;
|
|
228
|
+
const phone = isPhone();
|
|
229
|
+
if (backdrop) backdrop.hidden = !phone;
|
|
230
|
+
if (phone) {
|
|
231
|
+
// The bottom sheet comes from the media query alone.
|
|
232
|
+
panel.style.removeProperty('max-height');
|
|
233
|
+
panel.style.removeProperty('max-width');
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
// The popover sits above the label row (CSS) and must not run off the
|
|
237
|
+
// top of the transcript. In a phone's typing mode (body.phone-typing,
|
|
238
|
+
// phone-landscape-ui.js) the row is folded away: the popover sits above
|
|
239
|
+
// the floating ⚙ and may cover the header, the keyboard leaves so little.
|
|
240
|
+
const typing = document.body?.classList?.contains('phone-typing') === true;
|
|
241
|
+
const floatGear = typing ? el('composer-float-settings') : null;
|
|
242
|
+
const anchor = floatGear && !floatGear.hidden ? floatGear : el('composer-label-row');
|
|
243
|
+
const messages = el('messages');
|
|
244
|
+
const rowTop = anchor?.getBoundingClientRect?.().top ?? 0;
|
|
245
|
+
const ceiling = typing ? 0 : Math.max(0, messages?.getBoundingClientRect?.().top ?? 0);
|
|
246
|
+
// The gap, the edge and the floor are design sizes at 100 %; the zoom
|
|
247
|
+
// scales them like the stylesheet does (the CSS gap is 6px * --ui-scale).
|
|
248
|
+
const scale = getUiScale();
|
|
249
|
+
const room = Math.round(rowTop - ceiling - (POPOVER_GAP_PX + POPOVER_EDGE_PX) * scale);
|
|
250
|
+
// With the keyboard up the usual floor would push the popover off the
|
|
251
|
+
// top: it takes the room there is and scrolls.
|
|
252
|
+
const floor = typing ? POPOVER_TYPING_MIN_HEIGHT_PX : POPOVER_MIN_HEIGHT_PX;
|
|
253
|
+
panel.style.maxHeight = `${Math.max(Math.round(floor * scale), room)}px`;
|
|
254
|
+
// The CSS width counts from the screen's left edge, but the popover starts
|
|
255
|
+
// at the label row (or, in typing mode, ends at the floating ⚙): under
|
|
256
|
+
// zoom it would run off the screen with its ✕. The width it may take is
|
|
257
|
+
// the room from where it is pinned to the far edge, less the edge gap.
|
|
258
|
+
panel.style.removeProperty('max-width');
|
|
259
|
+
const viewportWidth = Number(document.documentElement?.clientWidth) || Number(window.innerWidth) || 0;
|
|
260
|
+
const box = panel.getBoundingClientRect?.();
|
|
261
|
+
if (!(viewportWidth > 0) || !box || !(box.width > 0)) return;
|
|
262
|
+
const edge = POPOVER_EDGE_PX * scale;
|
|
263
|
+
const widthRoom = typing ? box.right - edge : viewportWidth - box.left - edge;
|
|
264
|
+
if (box.width > widthRoom) {
|
|
265
|
+
panel.style.maxWidth = `${Math.max(Math.round(POPOVER_MIN_WIDTH_PX * scale), Math.floor(widthRoom))}px`;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Fits an open panel to where it now sits (typing mode came or went). */
|
|
270
|
+
export function repositionComposerSettings() {
|
|
271
|
+
if (panelOpen) positionPanel();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function bindReposition() {
|
|
275
|
+
if (repositionBound) return;
|
|
276
|
+
repositionBound = true;
|
|
277
|
+
window.addEventListener('resize', positionPanel);
|
|
278
|
+
// A zoom moves the label row and rescales the popover's gap and floor.
|
|
279
|
+
window.addEventListener(FONT_SCALE_CHANGE_EVENT, positionPanel);
|
|
280
|
+
window.visualViewport?.addEventListener?.('resize', positionPanel);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function unbindReposition() {
|
|
284
|
+
if (!repositionBound) return;
|
|
285
|
+
repositionBound = false;
|
|
286
|
+
window.removeEventListener('resize', positionPanel);
|
|
287
|
+
window.removeEventListener(FONT_SCALE_CHANGE_EVENT, positionPanel);
|
|
288
|
+
window.visualViewport?.removeEventListener?.('resize', positionPanel);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function setExpanded(expanded) {
|
|
292
|
+
for (const id of ['composer-settings-btn', 'composer-settings-label', 'composer-float-settings']) {
|
|
293
|
+
el(id)?.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function isComposerSettingsOpen() {
|
|
298
|
+
return panelOpen;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Opens the panel; `field` ('mode' | 'model' | 'effort' | 'tier' | 'billing')
|
|
303
|
+
* gets the focus. `keepComposerFocus`: the message box keeps the focus (the
|
|
304
|
+
* floating ⚙ of a phone's typing mode), except where the bottom sheet takes
|
|
305
|
+
* the whole screen and the keyboard has to go anyway.
|
|
306
|
+
*/
|
|
307
|
+
export function openComposerSettings({ field = '', keepComposerFocus = false } = {}) {
|
|
308
|
+
if (isShared()) return;
|
|
309
|
+
const panel = el(PANEL_ID);
|
|
310
|
+
if (!panel) return;
|
|
311
|
+
if (!panelOpen) {
|
|
312
|
+
panelOpen = true;
|
|
313
|
+
syncComposerSettings();
|
|
314
|
+
panel.hidden = false;
|
|
315
|
+
setExpanded(true);
|
|
316
|
+
document.body.classList.add('composer-settings-open');
|
|
317
|
+
// The keyboard would resize the viewport under the sheet.
|
|
318
|
+
if (isPhone()) el('msg-input')?.blur?.();
|
|
319
|
+
positionPanel();
|
|
320
|
+
bindReposition();
|
|
321
|
+
}
|
|
322
|
+
focusField(String(field || ''), { moveFocus: !keepComposerFocus || isPhone() });
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function closeComposerSettings({ focusButton = false } = {}) {
|
|
326
|
+
if (!panelOpen) return;
|
|
327
|
+
panelOpen = false;
|
|
328
|
+
const panel = el(PANEL_ID);
|
|
329
|
+
const backdrop = el('composer-settings-backdrop');
|
|
330
|
+
const hadFocus = !!panel && panel.contains(document.activeElement);
|
|
331
|
+
if (panel) panel.hidden = true;
|
|
332
|
+
if (backdrop) backdrop.hidden = true;
|
|
333
|
+
setExpanded(false);
|
|
334
|
+
document.body.classList.remove('composer-settings-open');
|
|
335
|
+
unbindReposition();
|
|
336
|
+
if (focusButton || hadFocus) {
|
|
337
|
+
try { closeFocusTarget()?.focus({ preventScroll: true }); } catch {}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Where the focus goes when the panel closes: its ⚙. In a phone's typing
|
|
342
|
+
// mode the label row with that ⚙ is folded away: the message box then, so
|
|
343
|
+
// the keyboard (and with it typing mode and the floating ⚙) stays.
|
|
344
|
+
function closeFocusTarget() {
|
|
345
|
+
if (document.body?.classList?.contains('phone-typing') === true) {
|
|
346
|
+
const input = el('msg-input');
|
|
347
|
+
if (input && !input.disabled) return input;
|
|
348
|
+
const floatGear = el('composer-float-settings');
|
|
349
|
+
if (floatGear && !floatGear.hidden) return floatGear;
|
|
350
|
+
}
|
|
351
|
+
return el('composer-settings-btn');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function toggleComposerSettings() {
|
|
355
|
+
if (panelOpen) closeComposerSettings({ focusButton: true });
|
|
356
|
+
else openComposerSettings();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function isPanelTrigger(node) {
|
|
360
|
+
return node instanceof Element
|
|
361
|
+
&& (node.id === 'composer-settings-btn'
|
|
362
|
+
|| node.id === 'composer-settings-label'
|
|
363
|
+
|| node.id === 'composer-billing-pill'
|
|
364
|
+
|| node.id === 'composer-float-settings');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function bindEvents() {
|
|
368
|
+
if (bound) return;
|
|
369
|
+
bound = true;
|
|
370
|
+
el('composer-settings-btn')?.addEventListener('click', (event) => {
|
|
371
|
+
event.preventDefault();
|
|
372
|
+
toggleComposerSettings();
|
|
373
|
+
});
|
|
374
|
+
el('composer-settings-label')?.addEventListener('click', (event) => {
|
|
375
|
+
event.preventDefault();
|
|
376
|
+
const part = event.target instanceof Element ? event.target.closest('[data-field]') : null;
|
|
377
|
+
openComposerSettings({ field: part?.dataset?.field || '' });
|
|
378
|
+
});
|
|
379
|
+
el('composer-billing-pill')?.addEventListener('click', (event) => {
|
|
380
|
+
event.preventDefault();
|
|
381
|
+
openComposerSettings({ field: 'billing' });
|
|
382
|
+
});
|
|
383
|
+
el('composer-settings-close')?.addEventListener('click', (event) => {
|
|
384
|
+
event.preventDefault();
|
|
385
|
+
closeComposerSettings({ focusButton: true });
|
|
386
|
+
});
|
|
387
|
+
el('composer-settings-backdrop')?.addEventListener('click', () => closeComposerSettings());
|
|
388
|
+
el('composer-settings-context-btn')?.addEventListener('click', (event) => {
|
|
389
|
+
event.preventDefault();
|
|
390
|
+
closeComposerSettings();
|
|
391
|
+
try { void deps.showContext?.(); } catch {}
|
|
392
|
+
});
|
|
393
|
+
const panel = el(PANEL_ID);
|
|
394
|
+
panel?.addEventListener('change', () => scheduleComposerSettingsSync());
|
|
395
|
+
// Outside the panel and its triggers: close. Capture phase, so a control
|
|
396
|
+
// that stops the event still counts.
|
|
397
|
+
document.addEventListener('pointerdown', (event) => {
|
|
398
|
+
if (!panelOpen) return;
|
|
399
|
+
const path = typeof event.composedPath === 'function' ? event.composedPath() : [event.target];
|
|
400
|
+
const current = el(PANEL_ID);
|
|
401
|
+
if (current && path.includes(current)) return;
|
|
402
|
+
if (path.some(isPanelTrigger)) return;
|
|
403
|
+
closeComposerSettings();
|
|
404
|
+
}, true);
|
|
405
|
+
document.addEventListener('keydown', (event) => {
|
|
406
|
+
if (!panelOpen || event.key !== 'Escape') return;
|
|
407
|
+
event.stopPropagation();
|
|
408
|
+
closeComposerSettings({ focusButton: true });
|
|
409
|
+
}, true);
|
|
410
|
+
// Option rebuilds, the lock note, the billing toggle and the selects'
|
|
411
|
+
// titles all change inside the panel; the chat kind changes on <body>.
|
|
412
|
+
if (typeof MutationObserver === 'function') {
|
|
413
|
+
if (panel) {
|
|
414
|
+
new MutationObserver(() => scheduleComposerSettingsSync()).observe(panel, {
|
|
415
|
+
subtree: true,
|
|
416
|
+
childList: true,
|
|
417
|
+
characterData: true,
|
|
418
|
+
attributes: true,
|
|
419
|
+
attributeFilter: ['hidden', 'title', 'disabled', 'data-billing', 'data-runtime-model-locked', 'data-plan-only'],
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
new MutationObserver(() => scheduleComposerSettingsSync()).observe(document.body, {
|
|
423
|
+
attributes: true,
|
|
424
|
+
attributeFilter: ['class'],
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export function initComposerSettings(options = {}) {
|
|
430
|
+
if (typeof document === 'undefined') return;
|
|
431
|
+
deps = options && typeof options === 'object' ? options : {};
|
|
432
|
+
bindEvents();
|
|
433
|
+
syncComposerSettings();
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export function __resetComposerSettingsForTests() {
|
|
437
|
+
deps = {};
|
|
438
|
+
panelOpen = false;
|
|
439
|
+
syncQueued = false;
|
|
440
|
+
bound = false;
|
|
441
|
+
repositionBound = false;
|
|
442
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// A context menu for the conversation list: right-click on a desktop, a long
|
|
2
|
-
// press on a phone. One menu at a time, closed by a pointer-down outside it,
|
|
2
|
+
// press on a phone (and the row's ⓘ details menu). One menu at a time, closed by a pointer-down outside it,
|
|
3
3
|
// Escape, a scroll or a resize, like a native menu; flipped at the screen
|
|
4
4
|
// edge rather than clipped. The entries are buttons, so keyboard users get
|
|
5
|
-
// focus on the first one.
|
|
5
|
+
// focus on the first one; a menu of text lines only takes the focus itself.
|
|
6
6
|
|
|
7
7
|
const LONG_PRESS_MS = 500;
|
|
8
8
|
const LONG_PRESS_SLOP_PX = 10;
|
|
@@ -24,9 +24,12 @@ export function closeContextMenu() {
|
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Open a menu at viewport coordinates. `items`: `{ label, onSelect, danger?,
|
|
27
|
-
* disabled?, id? }`,
|
|
27
|
+
* disabled?, id? }`, `{ label, note: true, id? }` for a line of text without
|
|
28
|
+
* an action, or MENU_SEPARATOR between groups. `onEscape` runs after Escape
|
|
29
|
+
* closed the menu. `label` names the menu for a screen reader. A menu of
|
|
30
|
+
* text lines only is focused itself. Returns the menu node.
|
|
28
31
|
*/
|
|
29
|
-
export function openContextMenu(x, y, items, { documentRef = document, windowRef = window } = {}) {
|
|
32
|
+
export function openContextMenu(x, y, items, { documentRef = document, windowRef = window, onEscape = null, label = '' } = {}) {
|
|
30
33
|
closeOpenMenu?.();
|
|
31
34
|
const menu = documentRef.createElement('div');
|
|
32
35
|
menu.className = 'context-menu';
|
|
@@ -48,6 +51,8 @@ export function openContextMenu(x, y, items, { documentRef = document, windowRef
|
|
|
48
51
|
if (event.key === 'Escape') {
|
|
49
52
|
event.stopPropagation();
|
|
50
53
|
close();
|
|
54
|
+
// The opener can take the focus back (a button that opened the menu).
|
|
55
|
+
onEscape?.();
|
|
51
56
|
return;
|
|
52
57
|
}
|
|
53
58
|
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
|
|
@@ -70,6 +75,17 @@ export function openContextMenu(x, y, items, { documentRef = document, windowRef
|
|
|
70
75
|
continue;
|
|
71
76
|
}
|
|
72
77
|
if (!item) continue;
|
|
78
|
+
if (item.note === true) {
|
|
79
|
+
// Read in the menu, skipped by the arrow keys (they move between buttons).
|
|
80
|
+
const line = documentRef.createElement('div');
|
|
81
|
+
line.className = 'context-menu-note';
|
|
82
|
+
line.setAttribute('role', 'menuitem');
|
|
83
|
+
line.setAttribute('aria-disabled', 'true');
|
|
84
|
+
if (item.id) line.dataset.menuItem = String(item.id);
|
|
85
|
+
line.textContent = String(item.label ?? '');
|
|
86
|
+
menu.append(line);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
73
89
|
const entry = documentRef.createElement('button');
|
|
74
90
|
entry.type = 'button';
|
|
75
91
|
entry.className = `context-menu-item${item.danger ? ' context-menu-item-danger' : ''}`;
|
|
@@ -99,7 +115,22 @@ export function openContextMenu(x, y, items, { documentRef = document, windowRef
|
|
|
99
115
|
documentRef.addEventListener('keydown', onKey, true);
|
|
100
116
|
windowRef.addEventListener('resize', close);
|
|
101
117
|
windowRef.addEventListener('scroll', close, true);
|
|
102
|
-
|
|
118
|
+
const firstButton = menu.querySelector('button:not([disabled])');
|
|
119
|
+
if (label) menu.setAttribute('aria-label', String(label));
|
|
120
|
+
if (!firstButton) {
|
|
121
|
+
// Only lines of text: the menu itself takes the focus, so a screen
|
|
122
|
+
// reader reads them out and a second Enter does not reopen it. Its name
|
|
123
|
+
// is those lines unless the opener gave one.
|
|
124
|
+
menu.tabIndex = -1;
|
|
125
|
+
if (!label) {
|
|
126
|
+
const lines = [...menu.querySelectorAll('.context-menu-note')].map((line) => line.textContent.trim()).filter(Boolean);
|
|
127
|
+
menu.setAttribute('aria-label', lines.join('. ') || 'Details');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
queueMicrotask(() => {
|
|
131
|
+
if (closeOpenMenu !== close) return;
|
|
132
|
+
(firstButton || menu).focus?.({ preventScroll: true });
|
|
133
|
+
});
|
|
103
134
|
return menu;
|
|
104
135
|
}
|
|
105
136
|
|
|
@@ -367,6 +367,22 @@ export function renderAgentSessionsControlHtml({ conversationId = '', mayCreateS
|
|
|
367
367
|
`;
|
|
368
368
|
}
|
|
369
369
|
|
|
370
|
+
/**
|
|
371
|
+
* The owner's plan-only switch, for every session: agents cannot take it out
|
|
372
|
+
* of plan mode while it is on (the owner still changes the mode freely).
|
|
373
|
+
*/
|
|
374
|
+
export function renderPlanOnlyControlHtml({ conversationId = '', planOnly = false } = {}) {
|
|
375
|
+
return `
|
|
376
|
+
<div class="ctx-agent-sessions ctx-plan-only">
|
|
377
|
+
<label class="ctx-agent-sessions-row">
|
|
378
|
+
<input type="checkbox" id="ctx-plan-only" data-conversation-id="${escapeHtml(conversationId)}"${planOnly === true ? ' checked' : ''}>
|
|
379
|
+
<span>Plan only: agents can't take this session out of plan mode</span>
|
|
380
|
+
</label>
|
|
381
|
+
<div class="ctx-thinking-note">While this is on, an agent's message or settings change that names another mode is refused; you can still change the mode yourself. "Approve plan" runs one turn with write access and the session plans again after it. Agents may switch it off.</div>
|
|
382
|
+
</div>
|
|
383
|
+
`;
|
|
384
|
+
}
|
|
385
|
+
|
|
370
386
|
// A model limit is a round figure: 1M rather than 1.0M.
|
|
371
387
|
function formatModelLimit(value) {
|
|
372
388
|
const n = toNullableNumber(value);
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
// Links that open one conversation when the app loads:
|
|
2
2
|
// ?push_conv=<id> a notification tap (sw.js opens a fresh window with it)
|
|
3
|
-
// ?conv=<id>
|
|
3
|
+
// ?conv=<id> the address of the session on screen (session-history.mjs):
|
|
4
|
+
// a reload, a copied link, another relay's provenance badge
|
|
4
5
|
// (remoteConversationUrl in shared/remote-relay-contract.mjs)
|
|
5
|
-
//
|
|
6
|
-
//
|
|
6
|
+
// `push_conv` is consumed once and becomes `conv`; `conv` stays in the
|
|
7
|
+
// address bar, so a reload opens the same conversation.
|
|
7
8
|
|
|
8
9
|
export const CONVERSATION_DEEP_LINK_PARAMS = Object.freeze(['push_conv', 'conv']);
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Reads the conversation a URL asks for. `cleanedPath` is the URL to put back
|
|
12
|
-
* in the address bar (path +
|
|
13
|
-
*
|
|
13
|
+
* in the address bar (path + query with `conv=<id>` + hash), or null when the
|
|
14
|
+
* URL needs no rewrite.
|
|
14
15
|
*/
|
|
15
16
|
export function readConversationDeepLink(href) {
|
|
16
17
|
let url;
|
|
@@ -20,23 +21,26 @@ export function readConversationDeepLink(href) {
|
|
|
20
21
|
return { conversationId: '', cleanedPath: null };
|
|
21
22
|
}
|
|
22
23
|
let conversationId = '';
|
|
23
|
-
let found = false;
|
|
24
24
|
for (const param of CONVERSATION_DEEP_LINK_PARAMS) {
|
|
25
25
|
if (!url.searchParams.has(param)) continue;
|
|
26
|
-
found = true;
|
|
27
26
|
const value = String(url.searchParams.get(param) || '').trim();
|
|
28
27
|
if (!conversationId && value) conversationId = value;
|
|
29
|
-
url.searchParams.delete(param);
|
|
30
28
|
}
|
|
29
|
+
const before = `${url.pathname}${url.search}${url.hash}`;
|
|
30
|
+
url.searchParams.delete('push_conv');
|
|
31
|
+
if (conversationId) url.searchParams.set('conv', conversationId);
|
|
32
|
+
else url.searchParams.delete('conv');
|
|
33
|
+
const after = `${url.pathname}${url.search}${url.hash}`;
|
|
31
34
|
return {
|
|
32
35
|
conversationId,
|
|
33
|
-
cleanedPath:
|
|
36
|
+
cleanedPath: after === before ? null : after,
|
|
34
37
|
};
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
/** Reads
|
|
40
|
+
/** Reads the deep link from the current location and settles the address on `?conv=`. */
|
|
38
41
|
export function consumeConversationDeepLink({ location = window.location, history = window.history, title = document.title } = {}) {
|
|
39
42
|
const { conversationId, cleanedPath } = readConversationDeepLink(location?.href);
|
|
40
|
-
|
|
43
|
+
// The entry keeps its state: the back guard and the session history mark it.
|
|
44
|
+
if (cleanedPath !== null) history?.replaceState?.(history?.state ?? null, title, cleanedPath);
|
|
41
45
|
return conversationId;
|
|
42
46
|
}
|
|
@@ -43,8 +43,9 @@ export function dragItemKey({ conversationId = null, groupId = null } = {}) {
|
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
45
|
* Plain items from arrangeConversationList(): `{ key, kind, conversationId,
|
|
46
|
-
* groupId }`,
|
|
47
|
-
*
|
|
46
|
+
* groupId }`, on heads `open` and `memberIds` (the group's rows after the
|
|
47
|
+
* head, in list order), and `kept` on the open conversation's row under a
|
|
48
|
+
* folded head.
|
|
48
49
|
*/
|
|
49
50
|
export function describeDragItems(arranged) {
|
|
50
51
|
const items = [];
|
|
@@ -54,6 +55,8 @@ export function describeDragItems(arranged) {
|
|
|
54
55
|
const groupId = item.kind === 'row' ? null : (toText(item.group?.id) || null);
|
|
55
56
|
if (!conversationId && !groupId) continue;
|
|
56
57
|
const described = { key: dragItemKey({ conversationId, groupId }), kind: item.kind, conversationId, groupId };
|
|
58
|
+
// The open conversation, shown under its folded group's head.
|
|
59
|
+
if (item.kind === 'member' && item.kept === true) described.kept = true;
|
|
57
60
|
if (item.kind === 'head') {
|
|
58
61
|
described.open = item.group?.open !== false;
|
|
59
62
|
described.memberIds = (Array.isArray(item.group?.members) ? item.group.members : [])
|
|
@@ -104,6 +107,13 @@ export function resolveDropTarget({ items = [], draggedId = '', hover = null } =
|
|
|
104
107
|
const y = Math.min(1, Math.max(0, Number(hover.y) || 0));
|
|
105
108
|
const half = y < 0.5 ? 'before' : 'after';
|
|
106
109
|
|
|
110
|
+
// A kept row stands alone under a folded head: a drop on it is a drop on
|
|
111
|
+
// the head, not a place among members the user cannot see.
|
|
112
|
+
if (target.kind === 'member' && target.kept === true) {
|
|
113
|
+
const head = headOf(target.groupId);
|
|
114
|
+
if (head) return resolveDropTarget({ items: list, draggedId: id, hover: { key: head.key, y: 0.5 } });
|
|
115
|
+
}
|
|
116
|
+
|
|
107
117
|
if (target.kind === 'member') {
|
|
108
118
|
const groupId = target.groupId;
|
|
109
119
|
if (draggedIsHead && draggedGroupId === groupId) return NO_DROP;
|
|
@@ -211,6 +211,9 @@ export function pageConversationRows(rows, groups = null, { limit = 40, beforeCo
|
|
|
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
213
|
*
|
|
214
|
+
* `keepVisibleId` is the open conversation: a member of a folded group with
|
|
215
|
+
* that id still shows under its head, as `{ kind: 'member', kept: true }`.
|
|
216
|
+
*
|
|
214
217
|
* With `emptyGroups`, an owner's group an agent emptied (`keptEmptyAt`, no
|
|
215
218
|
* row here) shows too: a head item without members (`group.empty`), placed
|
|
216
219
|
* by the time it was emptied; under a filter only when its name matches.
|
|
@@ -220,8 +223,9 @@ export function arrangeConversationList(rows, groups = null, {
|
|
|
220
223
|
matches = null,
|
|
221
224
|
groupNameMatches = null,
|
|
222
225
|
emptyGroups = false,
|
|
226
|
+
keepVisibleId = null,
|
|
223
227
|
} = {}) {
|
|
224
|
-
const items = arrangeGroupedRows(rows, groups, { isOpen, matches, groupNameMatches });
|
|
228
|
+
const items = arrangeGroupedRows(rows, groups, { isOpen, matches, groupNameMatches, keepVisibleId: toText(keepVisibleId) });
|
|
225
229
|
if (!emptyGroups || !groups) return items;
|
|
226
230
|
const present = new Set((Array.isArray(rows) ? rows : []).map(rowGroupId).filter(Boolean));
|
|
227
231
|
const entries = typeof groups.entries === 'function' && typeof groups.get === 'function' ? [...groups.entries()] : Object.entries(groups);
|
|
@@ -250,7 +254,7 @@ export function arrangeConversationList(rows, groups = null, {
|
|
|
250
254
|
return out;
|
|
251
255
|
}
|
|
252
256
|
|
|
253
|
-
function arrangeGroupedRows(rows, groups, { isOpen, matches, groupNameMatches }) {
|
|
257
|
+
function arrangeGroupedRows(rows, groups, { isOpen, matches, groupNameMatches, keepVisibleId = '' }) {
|
|
254
258
|
const ordered = orderConversationRows(rows, groups);
|
|
255
259
|
const activity = groupActivityTimes(ordered);
|
|
256
260
|
const items = [];
|
|
@@ -294,6 +298,9 @@ function arrangeGroupedRows(rows, groups, { isOpen, matches, groupNameMatches })
|
|
|
294
298
|
items.push({ kind: 'head', conversation: headRow, group });
|
|
295
299
|
if (group.open) {
|
|
296
300
|
for (const member of shownMembers) items.push({ kind: 'member', conversation: member, group });
|
|
301
|
+
} else if (keepVisibleId) {
|
|
302
|
+
const kept = shownMembers.find((member) => rowId(member) === keepVisibleId);
|
|
303
|
+
if (kept) items.push({ kind: 'member', conversation: kept, group, kept: true });
|
|
297
304
|
}
|
|
298
305
|
}
|
|
299
306
|
return items;
|