@oar-sh/oar 0.9.20 → 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/CHANGELOG.md +212 -0
- package/README.md +13 -13
- package/bin/oar.js +2 -2
- package/package.json +1 -1
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +231 -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-session-process.mjs +7 -0
- package/server/claude-worker/claude-session-worker-main.mjs +8 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +74 -1
- 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-worker-steering.mjs +20 -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 +3 -0
- package/server/grok-worker/grok-worker-steering.mjs +23 -0
- package/server/public/app/about-ui.js +1 -1
- package/server/public/app/about-view.mjs +19 -0
- package/server/public/app/action-confirmations.js +1 -1
- package/server/public/app/api-client.js +4 -2
- 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 +556 -68
- 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-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/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 +3 -0
- 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 +18 -2
- 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/push-settings.js +2 -2
- package/server/public/app/pwa-install.js +24 -3
- package/server/public/app/relay-event-subscriptions-ui.js +71 -22
- package/server/public/app/relay-event-view.mjs +38 -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 +5 -2
- 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 +2249 -1590
- package/server/relay-tools.md +1 -0
- package/server/repositories/message-repository.mjs +23 -0
- package/server/repositories/session-repository.mjs +19 -0
- package/server/routes/claude-cloud-routes.mjs +19 -0
- package/server/routes/messages-routes.mjs +57 -1
- package/server/routes/sessions-routes.mjs +41 -7
- package/server/server-runtime.mjs +190 -40
- package/server/services/about-service.mjs +22 -53
- package/server/services/claude-billing-swap-service.mjs +7 -20
- package/server/services/claude-cloud-session-service.mjs +47 -5
- package/server/services/remote-relay-dispatcher.mjs +61 -14
- package/server/services/session-activity-tracker.mjs +1 -1
- package/server/services/session-launch-mutex-service.mjs +23 -0
- package/server/services/session-worker-registry-service.mjs +36 -0
- package/server/services/session-worker-websocket-service.mjs +40 -16
- package/server/services/worker-recycle-service.mjs +772 -0
- package/server/services/worker-replacement.mjs +197 -0
- package/shared/build-id.mjs +128 -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
|
@@ -13,10 +13,12 @@ import {
|
|
|
13
13
|
import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
|
|
14
14
|
import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
|
|
15
15
|
import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
|
|
16
|
+
import { resolveBuildId } from '../../shared/build-id.mjs';
|
|
16
17
|
import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-file.mjs';
|
|
17
18
|
import { createControlPoller } from '../../shared/control-poller.mjs';
|
|
18
19
|
import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
|
|
19
20
|
import { createCursorTurnRunner } from './cursor-turn-runner.mjs';
|
|
21
|
+
import { buildCursorSteeringState } from './cursor-worker-steering.mjs';
|
|
20
22
|
import { resolveCursorSessionStoreDir } from '../services/cursor-session-root-service.mjs';
|
|
21
23
|
|
|
22
24
|
// Windows console launch: the launcher names the worker log, this copies
|
|
@@ -48,6 +50,10 @@ async function main() {
|
|
|
48
50
|
|
|
49
51
|
const configPath = String(process.env.COPILOT_WEB_RELAY_CONFIG || '').trim()
|
|
50
52
|
|| path.resolve(__dirname, '..', 'config.json');
|
|
53
|
+
// The code this process started on, read once from the repo root and kept
|
|
54
|
+
// for its whole life: the relay recycles a worker whose build differs from
|
|
55
|
+
// its own, and a tree that moves on under us must not change our answer.
|
|
56
|
+
const buildId = resolveBuildId(path.resolve(__dirname, '..', '..')).id;
|
|
51
57
|
const serverUrl = resolveRelayServerUrl({ configPath });
|
|
52
58
|
const token = loadTokenFromConfig(configPath);
|
|
53
59
|
const cwd = String(process.env.COPILOT_WORKSPACE_ROOT || '').trim() || process.cwd();
|
|
@@ -93,17 +99,22 @@ async function main() {
|
|
|
93
99
|
let heartbeatTimer = null;
|
|
94
100
|
const heartbeat = createHeartbeatController({
|
|
95
101
|
api,
|
|
102
|
+
buildId,
|
|
96
103
|
pollMs: HEARTBEAT_MS,
|
|
97
104
|
getSessionReady: () => true,
|
|
98
105
|
getHeartbeatTimer: () => heartbeatTimer,
|
|
99
106
|
setHeartbeatTimer: (timer) => { heartbeatTimer = timer; },
|
|
100
107
|
getActiveQueueMessageId: () => turnRunner.getActiveQueueMessageId(),
|
|
108
|
+
// No steering here; the snapshot tells the relay when it may replace
|
|
109
|
+
// this worker: between turns (the agent handle is rebuilt on demand).
|
|
110
|
+
getSteeringState: () => buildCursorSteeringState(turnRunner),
|
|
101
111
|
});
|
|
102
112
|
|
|
103
113
|
const wsLink = createWorkerWebSocketLink({
|
|
104
114
|
serverUrl,
|
|
105
115
|
token,
|
|
106
116
|
dbg,
|
|
117
|
+
buildId,
|
|
107
118
|
getSessionReady: () => true,
|
|
108
119
|
getSessionId: () => sdkSessionId,
|
|
109
120
|
getPid: () => process.pid,
|
|
@@ -146,7 +157,7 @@ async function main() {
|
|
|
146
157
|
getActiveQueueMessageIds: () => [turnRunner.getActiveQueueAttempt()],
|
|
147
158
|
});
|
|
148
159
|
|
|
149
|
-
dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'}`);
|
|
160
|
+
dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'} build=${buildId || 'unknown'}`);
|
|
150
161
|
heartbeat.startHeartbeat();
|
|
151
162
|
wsLink.start();
|
|
152
163
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Cursor worker's heartbeat steering snapshot, from its turn runner
|
|
3
|
+
* (cursor-turn-runner.mjs). A module of its own so it can be tested: the
|
|
4
|
+
* worker main runs at import.
|
|
5
|
+
*
|
|
6
|
+
* Only a turn holds the release. The Cursor SDK tells the runner of no work
|
|
7
|
+
* that outlives a run: whether a sub-agent or a background shell can keep
|
|
8
|
+
* running after the run's result is not something its types say, and the
|
|
9
|
+
* runner sees neither of them, so there is no background count to send. The
|
|
10
|
+
* relay's quiet rule (10 minutes without a delivery or a turn end) covers
|
|
11
|
+
* what the SDK does not say.
|
|
12
|
+
*/
|
|
13
|
+
import { buildSimpleSteeringState } from '../../shared/worker-runtime/simple-steering-state.mjs';
|
|
14
|
+
|
|
15
|
+
export function buildCursorSteeringState(turnRunner) {
|
|
16
|
+
return buildSimpleSteeringState({
|
|
17
|
+
activeAttempt: turnRunner.getActiveQueueAttempt(),
|
|
18
|
+
turnActive: turnRunner.isTurnActive(),
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -235,6 +235,15 @@ export function createGrokTaskPublisher({
|
|
|
235
235
|
stop,
|
|
236
236
|
/** The ids of the live terminals, for the heartbeat's digest. */
|
|
237
237
|
backgroundTaskIds: () => liveEntries().map((entry) => entry.taskId),
|
|
238
|
+
/**
|
|
239
|
+
* Terminals whose process runs and that the agent has not released, from
|
|
240
|
+
* the moment they start: a superset of the live ids, which wait out the
|
|
241
|
+
* publish grace first. What may die with the worker, so the relay must
|
|
242
|
+
* not replace it while any is open (the steering snapshot's count); a
|
|
243
|
+
* `sleep 600` started a second before a recycle is as live as one the
|
|
244
|
+
* panel already shows.
|
|
245
|
+
*/
|
|
246
|
+
openTerminalCount: () => [...terminals.values()].filter((entry) => !entry.exited && !entry.released).length,
|
|
238
247
|
/** The relay asked for the set again (heartbeat answer). */
|
|
239
248
|
resync: () => publish({ resync: true }),
|
|
240
249
|
/** The relay answered a heartbeat: settle notices kept for it go out. */
|
|
@@ -12,10 +12,12 @@ import {
|
|
|
12
12
|
import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
|
|
13
13
|
import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
|
|
14
14
|
import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
|
|
15
|
+
import { resolveBuildId } from '../../shared/build-id.mjs';
|
|
15
16
|
import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-file.mjs';
|
|
16
17
|
import { createControlPoller } from '../../shared/control-poller.mjs';
|
|
17
18
|
import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
|
|
18
19
|
import { createGrokTurnRunner } from './grok-turn-runner.mjs';
|
|
20
|
+
import { buildGrokSteeringState } from './grok-worker-steering.mjs';
|
|
19
21
|
import { createGrokTaskPublisher } from './grok-background-tasks.mjs';
|
|
20
22
|
|
|
21
23
|
// Windows console launch: the launcher names the worker log, this copies
|
|
@@ -47,6 +49,10 @@ async function main() {
|
|
|
47
49
|
|
|
48
50
|
const configPath = String(process.env.COPILOT_WEB_RELAY_CONFIG || '').trim()
|
|
49
51
|
|| path.resolve(__dirname, '..', 'config.json');
|
|
52
|
+
// The code this process started on, read once from the repo root and kept
|
|
53
|
+
// for its whole life: the relay recycles a worker whose build differs from
|
|
54
|
+
// its own, and a tree that moves on under us must not change our answer.
|
|
55
|
+
const buildId = resolveBuildId(path.resolve(__dirname, '..', '..')).id;
|
|
50
56
|
const serverUrl = resolveRelayServerUrl({ configPath });
|
|
51
57
|
const token = loadTokenFromConfig(configPath);
|
|
52
58
|
const cwd = String(process.env.COPILOT_WORKSPACE_ROOT || '').trim() || process.cwd();
|
|
@@ -96,6 +102,7 @@ async function main() {
|
|
|
96
102
|
let heartbeatTimer = null;
|
|
97
103
|
heartbeat = createHeartbeatController({
|
|
98
104
|
api,
|
|
105
|
+
buildId,
|
|
99
106
|
pollMs: HEARTBEAT_MS,
|
|
100
107
|
getSessionReady: () => true,
|
|
101
108
|
getHeartbeatTimer: () => heartbeatTimer,
|
|
@@ -106,12 +113,16 @@ async function main() {
|
|
|
106
113
|
getBackgroundTaskIds: () => turnRunner.backgroundTaskIds(),
|
|
107
114
|
onResyncBackgroundTasks: () => turnRunner.resyncBackgroundTasks(),
|
|
108
115
|
onAnswered: () => turnRunner.flushKeptTaskSettles(),
|
|
116
|
+
// No steering here; the snapshot tells the relay when it may replace
|
|
117
|
+
// this worker: no turn, and no open terminal (they end with the worker).
|
|
118
|
+
getSteeringState: () => buildGrokSteeringState(turnRunner),
|
|
109
119
|
});
|
|
110
120
|
|
|
111
121
|
const wsLink = createWorkerWebSocketLink({
|
|
112
122
|
serverUrl,
|
|
113
123
|
token,
|
|
114
124
|
dbg,
|
|
125
|
+
buildId,
|
|
115
126
|
getSessionReady: () => true,
|
|
116
127
|
// A relay that just (re)started learns of the terminals from the digest
|
|
117
128
|
// on the next beat; ask for it now.
|
|
@@ -171,7 +182,7 @@ async function main() {
|
|
|
171
182
|
getActiveQueueMessageIds: () => [turnRunner.getActiveQueueAttempt()],
|
|
172
183
|
});
|
|
173
184
|
|
|
174
|
-
dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'}`);
|
|
185
|
+
dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'} build=${buildId || 'unknown'}`);
|
|
175
186
|
heartbeat.startHeartbeat();
|
|
176
187
|
wsLink.start();
|
|
177
188
|
}
|
|
@@ -667,6 +667,9 @@ export function createGrokTurnRunner({
|
|
|
667
667
|
// The terminals as background tasks: the heartbeat's digest and resync,
|
|
668
668
|
// the panel's Stop, kept settle notices.
|
|
669
669
|
backgroundTaskIds: () => terminalTasks?.backgroundTaskIds() || [],
|
|
670
|
+
// Every terminal that runs and is not released, published or not yet:
|
|
671
|
+
// the steering snapshot's background count (grok-worker-steering.mjs).
|
|
672
|
+
openTerminalCount: () => terminalTasks?.openTerminalCount?.() || 0,
|
|
670
673
|
resyncBackgroundTasks: () => terminalTasks?.resync(),
|
|
671
674
|
stopBackgroundTask: (taskId) => terminalTasks?.stop(taskId) || false,
|
|
672
675
|
flushKeptTaskSettles: () => terminalTasks?.flushKeptSettles(),
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Grok worker's heartbeat steering snapshot, from its turn runner
|
|
3
|
+
* (grok-turn-runner.mjs). A module of its own so it can be tested: the worker
|
|
4
|
+
* main runs at import.
|
|
5
|
+
*
|
|
6
|
+
* A turn holds the release, then any open terminal: it dies with the worker.
|
|
7
|
+
* Counted from the terminal's start (`openTerminalCount`), not from when it
|
|
8
|
+
* becomes a panel row (`backgroundTaskIds`, after a 2 s grace), so a command
|
|
9
|
+
* started just before a recycle sweep still holds it.
|
|
10
|
+
*/
|
|
11
|
+
import { buildSimpleSteeringState } from '../../shared/worker-runtime/simple-steering-state.mjs';
|
|
12
|
+
|
|
13
|
+
export function buildGrokSteeringState(turnRunner) {
|
|
14
|
+
const open = Number(turnRunner.openTerminalCount?.()) || 0;
|
|
15
|
+
// The live ids are open terminals too; the larger count wins should the two
|
|
16
|
+
// ever disagree.
|
|
17
|
+
const listed = turnRunner.backgroundTaskIds?.().length || 0;
|
|
18
|
+
return buildSimpleSteeringState({
|
|
19
|
+
activeAttempt: turnRunner.getActiveQueueAttempt(),
|
|
20
|
+
turnActive: turnRunner.isTurnActive(),
|
|
21
|
+
backgroundTaskCount: Math.max(open, listed),
|
|
22
|
+
});
|
|
23
|
+
}
|
|
@@ -256,6 +256,23 @@ function backupsRow(backups, formatDateTime) {
|
|
|
256
256
|
return row('Backups', esc(value), { hint });
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
/**
|
|
260
|
+
* The running workers and how many still run the code before the last deploy
|
|
261
|
+
* (the relay recycles those once idle): "14, all on this build", "14, 3 on an
|
|
262
|
+
* older build", or the bare count when the relay cannot tell. Empty when no
|
|
263
|
+
* worker runs, so the row is left out.
|
|
264
|
+
*/
|
|
265
|
+
export function workersBuildText(workers) {
|
|
266
|
+
const total = finite(workers?.total) || 0;
|
|
267
|
+
if (total <= 0) return '';
|
|
268
|
+
const count = formatCount(total);
|
|
269
|
+
// Number(null) is 0: an unknown count must not read as "all on this build".
|
|
270
|
+
const other = workers?.onOtherBuild === null || workers?.onOtherBuild === undefined ? null : finite(workers.onOtherBuild);
|
|
271
|
+
if (other === null) return count;
|
|
272
|
+
if (other <= 0) return total === 1 ? `${count}, on this build` : `${count}, all on this build`;
|
|
273
|
+
return `${count}, ${formatCount(other)} on an older build`;
|
|
274
|
+
}
|
|
275
|
+
|
|
259
276
|
/**
|
|
260
277
|
* @param {object} about the GET /api/about payload
|
|
261
278
|
* @param {object} [options]
|
|
@@ -270,8 +287,10 @@ export function buildAboutModalHtml(about = {}, {
|
|
|
270
287
|
|
|
271
288
|
const commit = String(about.commit || '').trim();
|
|
272
289
|
const install = about.installMethod === 'git-checkout' ? 'git checkout' : (about.installMethod === 'npm-global' ? 'npm package' : '');
|
|
290
|
+
const workersText = workersBuildText(about.workers);
|
|
273
291
|
sections.push(section('build', 'OAR', table([
|
|
274
292
|
row('Version', esc(about.version || '–'), install ? { hint: install } : {}),
|
|
293
|
+
...(workersText ? [row('Workers', esc(workersText))] : []),
|
|
275
294
|
...(commit ? [row('Commit', `<code>${esc(commit.slice(0, 7))}</code>`, { title: commit })] : []),
|
|
276
295
|
row('Started', esc(about.startedAt ? formatDateTime(about.startedAt) : '–')),
|
|
277
296
|
row('Uptime', esc(formatUptime(about.uptimeSeconds))),
|
|
@@ -107,7 +107,7 @@ export async function confirmKillCurrentSession() {
|
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
//
|
|
110
|
+
// ︙ → Restart web relay first shows what the restart would wait for and
|
|
111
111
|
// leave running (relay-restart-ui.js); its buttons land in
|
|
112
112
|
// confirmRestartWebRelay below or in confirmRestartRelayNow.
|
|
113
113
|
export { openRestartRelayConfirmation };
|
|
@@ -982,7 +982,9 @@ export async function loadConversation(id, options = {}) {
|
|
|
982
982
|
const aroundMessageId = String(options.aroundMessageId || '').trim();
|
|
983
983
|
if (aroundMessageId) params.set('aroundMessageId', aroundMessageId);
|
|
984
984
|
const query = params.toString();
|
|
985
|
-
|
|
985
|
+
// `onErrorResponse` tells a conversation the relay does not know (404) from a failed load.
|
|
986
|
+
const onErrorResponse = typeof options.onErrorResponse === 'function' ? options.onErrorResponse : undefined;
|
|
987
|
+
return apiFetch(`/api/conversation/${convId}${query ? `?${query}` : ''}`, onErrorResponse ? { onErrorResponse } : undefined);
|
|
986
988
|
}
|
|
987
989
|
|
|
988
990
|
export async function refreshConversationHistory(id, options = {}) {
|
|
@@ -1247,7 +1249,7 @@ export async function createConversationShareLink(id) {
|
|
|
1247
1249
|
});
|
|
1248
1250
|
}
|
|
1249
1251
|
|
|
1250
|
-
// Share management (Settings → Shared conversations, the
|
|
1252
|
+
// Share management (Settings → Shared conversations, the ︙ menu). These throw
|
|
1251
1253
|
// with the relay's reason, so the caller can say why an action failed.
|
|
1252
1254
|
export async function listConversationShares() {
|
|
1253
1255
|
return apiFetchOrThrow('/api/shares');
|
|
@@ -17,6 +17,7 @@ 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
19
|
import { renderSessionAskCards } from './inbox-ui.js';
|
|
20
|
+
import { isConversationAutoOpenHeld } from './session-history-ui.js';
|
|
20
21
|
|
|
21
22
|
let relayQuestionRenderHash = '';
|
|
22
23
|
let renderedRelayQuestionIds = new Set();
|
|
@@ -124,13 +125,20 @@ export async function loadRelayQuestions(conversationId) {
|
|
|
124
125
|
window.renderConvList?.();
|
|
125
126
|
window.syncComposerButtonState?.();
|
|
126
127
|
|
|
127
|
-
|
|
128
|
+
// Nothing open: the latest question's conversation opens. Not from the
|
|
129
|
+
// Inbox or Status, which show nothing open too: they are being read. And
|
|
130
|
+
// in place of the empty screen, not as a step of its own in history. Nor
|
|
131
|
+
// after a link the relay did not know: its notice keeps the screen empty
|
|
132
|
+
// until the reader opens something.
|
|
133
|
+
const readingAView = document.body?.classList?.contains('inbox-view-active')
|
|
134
|
+
|| document.body?.classList?.contains('status-view-active');
|
|
135
|
+
if (!conversationId && pending.length && !readingAView && !isConversationAutoOpenHeld()) {
|
|
128
136
|
const latest = pending
|
|
129
137
|
.slice()
|
|
130
138
|
.sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0))[0];
|
|
131
139
|
const targetConversationId = String(latest?.conversationId || '').trim();
|
|
132
140
|
if (targetConversationId && targetConversationId !== currentConvId && conversations[targetConversationId]) {
|
|
133
|
-
await window.openConversation?.(targetConversationId);
|
|
141
|
+
await window.openConversation?.(targetConversationId, { history: 'replace' });
|
|
134
142
|
return;
|
|
135
143
|
}
|
|
136
144
|
}
|
|
@@ -171,10 +179,16 @@ export function updatePendingQuestionBanner() {
|
|
|
171
179
|
banner.dataset.targetQuestionId = String(latest?.id || '').trim();
|
|
172
180
|
const count = pending.length;
|
|
173
181
|
const currentCount = currentConvId ? pending.filter((q) => q?.conversationId === currentConvId).length : 0;
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
182
|
+
const rawTitle = String(conversations[targetConversationId]?.title || targetConversationId || 'conversation');
|
|
183
|
+
const countText = `❓ ${count} open question${count === 1 ? '' : 's'}`;
|
|
184
|
+
const tailText = currentCount > 0 ? '' : ` · latest in ${rawTitle}`;
|
|
185
|
+
// The full text, and the compact chip "❓ N" the header fit switches to
|
|
186
|
+
// (data-compact) where the row is short. Either way the button's name and
|
|
187
|
+
// tooltip are the full text.
|
|
188
|
+
banner.innerHTML = `<span class="pending-question-full"><span class="pending-question-count">${escHtml(countText)}</span>${tailText ? `<span class="pending-question-tail">${escHtml(tailText)}</span>` : ''}</span>`
|
|
189
|
+
+ `<span class="pending-question-compact" aria-hidden="true">❓ ${count}</span>`;
|
|
190
|
+
banner.title = `${countText}${tailText}`;
|
|
191
|
+
banner.setAttribute('aria-label', `${countText}${tailText}`);
|
|
178
192
|
banner.classList.add('visible');
|
|
179
193
|
banner.disabled = false;
|
|
180
194
|
}
|
|
@@ -277,7 +277,7 @@ export function renderAttachmentPreview() {
|
|
|
277
277
|
const annotatable = att.isImage && att.previewUrl && isAnnotatableImageType(att.type);
|
|
278
278
|
const thumb = att.isImage && att.previewUrl
|
|
279
279
|
? `<img src="${escHtml(att.previewUrl)}" alt="${escHtml(att.name)}"${annotatable ? ` onclick="openAnnotateEditorForPending(${idx})" title="Annotate ${escHtml(att.name)}" style="cursor: pointer;"` : ''}>`
|
|
280
|
-
: `<div class="attachment-preview-meta" style="height:88px;display:flex;align-items:center;justify-content:center">📎</div>`;
|
|
280
|
+
: `<div class="attachment-preview-meta" style="height:calc(88px * var(--ui-scale));display:flex;align-items:center;justify-content:center">📎</div>`;
|
|
281
281
|
const annotateBadge = annotatable
|
|
282
282
|
? `<span class="attachment-preview-annotate" aria-hidden="true">🖍️</span>`
|
|
283
283
|
: '';
|
|
@@ -9,7 +9,26 @@
|
|
|
9
9
|
|
|
10
10
|
export const BACK_GUARD_FLOOR = 'oarBackGuardFloor';
|
|
11
11
|
export const BACK_GUARD_APP = 'oarApp';
|
|
12
|
-
|
|
12
|
+
// Every entry the app writes carries its place in the stack, so a pop tells
|
|
13
|
+
// back from forward (session-history.mjs, viewer-history.mjs).
|
|
14
|
+
export const HISTORY_POSITION = 'oarPos';
|
|
15
|
+
// The visit's base (the floor, the phone's session list) is laid: a reload
|
|
16
|
+
// on any later entry must not lay it again.
|
|
17
|
+
export const VISIT_LAID = 'oarLaid';
|
|
18
|
+
// The place in the stack of the visit's lowest own entry (the phone's list,
|
|
19
|
+
// else the first session): back from above it stays in the app.
|
|
20
|
+
export const VISIT_BASE = 'oarBase';
|
|
21
|
+
export const FIRST_INTERACTION_EVENTS = Object.freeze(['pointerdown', 'keydown', 'touchstart']);
|
|
22
|
+
|
|
23
|
+
/** The marks every later entry of the visit carries over from the one below. */
|
|
24
|
+
export function inheritedVisitMarks(state) {
|
|
25
|
+
const marks = {};
|
|
26
|
+
if (!state || typeof state !== 'object') return marks;
|
|
27
|
+
if (state[BACK_GUARD_APP]) marks[BACK_GUARD_APP] = true;
|
|
28
|
+
if (state[VISIT_LAID]) marks[VISIT_LAID] = state[VISIT_LAID];
|
|
29
|
+
if (Number.isFinite(state[VISIT_BASE])) marks[VISIT_BASE] = state[VISIT_BASE];
|
|
30
|
+
return marks;
|
|
31
|
+
}
|
|
13
32
|
|
|
14
33
|
/**
|
|
15
34
|
* Lay the floor at the first touch or key, not at start-up: an entry a page
|
|
@@ -19,13 +38,13 @@ const FIRST_INTERACTION_EVENTS = ['pointerdown', 'keydown', 'touchstart'];
|
|
|
19
38
|
* interaction nothing a page does keeps back from leaving; from then on
|
|
20
39
|
* the floor holds.
|
|
21
40
|
*/
|
|
22
|
-
export function armBackGuardOnFirstInteraction({ history, enabled = true, target, onFloor = null } = {}) {
|
|
41
|
+
export function armBackGuardOnFirstInteraction({ history, enabled = true, target, onFloor = null, entryAgain = null } = {}) {
|
|
23
42
|
if (!enabled || !history || !target) return null;
|
|
24
43
|
const state = { guard: null };
|
|
25
44
|
const arm = () => {
|
|
26
45
|
for (const eventName of FIRST_INTERACTION_EVENTS) target.removeEventListener(eventName, arm, true);
|
|
27
46
|
if (state.guard) return;
|
|
28
|
-
state.guard = installBackGuard({ history, enabled: true, onFloor });
|
|
47
|
+
state.guard = installBackGuard({ history, enabled: true, onFloor, entryAgain });
|
|
29
48
|
target.addEventListener('popstate', () => state.guard.onPopState());
|
|
30
49
|
};
|
|
31
50
|
for (const eventName of FIRST_INTERACTION_EVENTS) target.addEventListener(eventName, arm, true);
|
|
@@ -38,8 +57,12 @@ export function armBackGuardOnFirstInteraction({ history, enabled = true, target
|
|
|
38
57
|
* lets the next back leave unless the user touches the app in between:
|
|
39
58
|
* one swallowed press, then the app closes — the pattern phones use, and
|
|
40
59
|
* the moment to say "press back again to close".
|
|
60
|
+
*
|
|
61
|
+
* `entryAgain` names the entry pushed back above the floor: `{ state, url }`
|
|
62
|
+
* (the session list on a phone, else the session on screen). Without it a
|
|
63
|
+
* bare app entry goes back up.
|
|
41
64
|
*/
|
|
42
|
-
export function installBackGuard({ history, enabled = true, onFloor = null } = {}) {
|
|
65
|
+
export function installBackGuard({ history, enabled = true, onFloor = null, entryAgain = null } = {}) {
|
|
43
66
|
if (!enabled || !history) return null;
|
|
44
67
|
const current = history.state && typeof history.state === 'object' ? history.state : {};
|
|
45
68
|
// After a reload on the app's entry the floor already lies beneath it.
|
|
@@ -51,7 +74,10 @@ export function installBackGuard({ history, enabled = true, onFloor = null } = {
|
|
|
51
74
|
/** Feed every popstate here. */
|
|
52
75
|
onPopState() {
|
|
53
76
|
if (!history.state?.[BACK_GUARD_FLOOR]) return;
|
|
54
|
-
|
|
77
|
+
const again = typeof entryAgain === 'function' ? entryAgain() : null;
|
|
78
|
+
if (again?.state && again.url) history.pushState({ ...again.state, [BACK_GUARD_APP]: true }, '', again.url);
|
|
79
|
+
else if (again?.state) history.pushState({ ...again.state, [BACK_GUARD_APP]: true }, '');
|
|
80
|
+
else history.pushState({ [BACK_GUARD_APP]: true }, '');
|
|
55
81
|
onFloor?.();
|
|
56
82
|
},
|
|
57
83
|
};
|