@oar-sh/oar 0.9.2 → 0.9.4
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/extension.mjs +6 -1
- package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
- package/CHANGELOG.md +336 -0
- package/README.md +455 -423
- package/bin/oar.js +25 -10
- package/package.json +11 -5
- package/server/README.md +44 -4
- package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
- package/server/claude-worker/claude-session-process.mjs +1073 -69
- package/server/claude-worker/claude-session-worker.mjs +45 -4
- package/server/claude-worker/claude-turn-publisher.mjs +81 -20
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
- package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +113 -14
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2349 -498
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
- package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
- package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
- package/server/db-schema.mjs +28 -0
- package/server/grok-worker/acp-client.mjs +92 -6
- package/server/grok-worker/grok-turn-runner.mjs +59 -8
- package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
- package/server/mcp/oar-mcp-server.mjs +473 -0
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/migrations/0005-remote-relays.mjs +90 -0
- package/server/public/app/action-confirmations.js +141 -13
- package/server/public/app/api-client.js +125 -2
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +37 -2
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +33 -18
- package/server/public/app/bootstrap.js +55 -14
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/conversation-deep-link.mjs +42 -0
- package/server/public/app/conversation-draft-sync.mjs +187 -0
- package/server/public/app/conversation-list-filter.mjs +86 -0
- package/server/public/app/conversation-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +1016 -200
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/host-suspend-ui.js +272 -0
- package/server/public/app/journal-view.js +149 -6
- package/server/public/app/mention-autocomplete.mjs +298 -0
- package/server/public/app/model-selector-options.mjs +1 -1
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/pwa-install.js +8 -3
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +61 -3
- package/server/public/app/remote-relay-origin-view.mjs +76 -0
- package/server/public/app/remote-relay-shared.mjs +85 -0
- package/server/public/app/remote-relays-settings.js +618 -0
- package/server/public/app/remote-relays-store.mjs +126 -0
- package/server/public/app/settings-modal.js +12 -8
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +37 -1
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +126 -15
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/thinking-follow.js +260 -0
- package/server/public/app/tool-activity-emoji.mjs +40 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/index.html +463 -17
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +7 -1
- package/server/repositories/message-repository.mjs +68 -18
- package/server/repositories/question-repository.mjs +18 -0
- package/server/repositories/remote-relay-repository.mjs +75 -0
- package/server/repositories/session-repository.mjs +13 -32
- package/server/routes/ask-user-routes.mjs +42 -2
- package/server/routes/messages-routes.mjs +572 -56
- package/server/routes/remote-relay-routes.mjs +133 -0
- package/server/routes/sessions-routes.mjs +378 -84
- package/server/server-runtime.mjs +303 -25
- package/server/services/github-ci-activity-service.mjs +199 -0
- package/server/services/host-suspend-command.mjs +38 -0
- package/server/services/host-suspend-service.mjs +254 -0
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/push-dispatch-service.mjs +37 -4
- package/server/services/relay-prompt-sanitizer.mjs +17 -2
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/remote-relay-caller-context.mjs +341 -0
- package/server/services/remote-relay-client.mjs +332 -0
- package/server/services/remote-relay-dispatcher.mjs +1500 -0
- package/server/services/remote-relay-inbound.mjs +187 -0
- package/server/services/remote-relay-pairing.mjs +189 -0
- package/server/services/remote-relay-registry-service.mjs +581 -0
- package/server/services/sdk-session-import-service.mjs +49 -7
- package/server/services/session-worker-launch-service.mjs +131 -12
- package/server/services/session-worker-registry-service.mjs +75 -0
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +23 -1
- package/server/services/steer-resend.mjs +45 -0
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/control-poller.mjs +4 -1
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -0
- package/shared/remote-relay-contract.mjs +539 -0
- package/shared/remote-relay-mentions.mjs +129 -0
- package/shared/remote-relay-tool-core.mjs +314 -0
- package/shared/slash-command-guard.mjs +76 -0
- package/shared/steer-note.mjs +66 -0
- package/shared/steer-settle-failure.mjs +89 -0
- package/shared/steer-settle-markers.mjs +18 -0
- package/shared/worker-crash-guard.mjs +40 -25
- package/shared/worker-runtime/api-client.mjs +112 -22
- package/shared/worker-runtime/heartbeat.mjs +55 -1
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
- package/server/claude-worker/claude-session-test-harness.mjs +0 -250
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
- package/server/copilot-worker/fixtures/abort-turn.json +0 -588
- package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
- package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
- package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
- package/server/copilot-worker/fixtures/happy-turn.json +0 -566
- package/server/copilot-worker/fixtures/quota-turn.json +0 -647
- package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
- package/server/copilot-worker/fixtures/resume-turn.json +0 -700
- package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
- package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
- package/server/routes/messages-routes-test-harness.mjs +0 -111
- package/server/services/fixtures/claude-auth-stub.cmd +0 -1
- package/server/services/fixtures/claude-auth-stub.mjs +0 -76
- package/server/services/fixtures/claude-auth-stub.sh +0 -4
- package/server/services/fixtures/cli-install-stub.cmd +0 -1
- package/server/services/fixtures/cli-install-stub.mjs +0 -98
- package/server/services/fixtures/cli-install-stub.sh +0 -4
- package/server/services/fixtures/grok-stub.cmd +0 -1
- package/server/services/fixtures/grok-stub.mjs +0 -89
- package/server/services/fixtures/grok-stub.sh +0 -4
- package/shared/fixtures/copilot-catalog-raw-2026-09-07.json +0 -2363
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// The browser's copy of this relay's remote-relay list (Settings → Relays and
|
|
2
|
+
// the composer's @mention popup read it). Filled lazily from
|
|
3
|
+
// GET /api/remote-relays and replaced wholesale by the relay's
|
|
4
|
+
// `remote_relays_updated` socket event; never carries tokens.
|
|
5
|
+
|
|
6
|
+
import { IS_SHARED_VIEW } from './store.js';
|
|
7
|
+
import { loadRemoteRelays } from './api-client.js';
|
|
8
|
+
|
|
9
|
+
// A relay without the feature answers 404; do not ask again on every "@".
|
|
10
|
+
const FAILED_LOAD_RETRY_MS = 60_000;
|
|
11
|
+
const KNOWN_STATUSES = new Set(['online', 'offline', 'unauthorized', 'error', 'unknown']);
|
|
12
|
+
|
|
13
|
+
/** A remote's health for its dot: online, offline, unauthorized, error or unknown. */
|
|
14
|
+
export function remoteRelayStatus(relay) {
|
|
15
|
+
const status = String(relay?.lastStatus || '').trim().toLowerCase();
|
|
16
|
+
return KNOWN_STATUSES.has(status) ? status : 'unknown';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let snapshot = null; // { relays: [], self: {} | null } once known
|
|
20
|
+
let loadPromise = null;
|
|
21
|
+
let lastFailedLoadAt = 0;
|
|
22
|
+
const listeners = new Set();
|
|
23
|
+
|
|
24
|
+
function normalizeRelay(raw) {
|
|
25
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
26
|
+
const id = String(raw.id || '').trim();
|
|
27
|
+
if (!id) return null;
|
|
28
|
+
return { ...raw, id };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function normalizeSnapshot(payload) {
|
|
32
|
+
const relays = Array.isArray(payload?.relays)
|
|
33
|
+
? payload.relays.map(normalizeRelay).filter(Boolean)
|
|
34
|
+
: [];
|
|
35
|
+
const self = payload?.self && typeof payload.self === 'object' ? { ...payload.self } : null;
|
|
36
|
+
return { relays, self };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function notify() {
|
|
40
|
+
for (const listener of listeners) {
|
|
41
|
+
try {
|
|
42
|
+
listener(snapshot);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
console.warn('[remote-relays] listener failed', error?.message || error);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The last known `{ relays, self }`, or null before the first load. */
|
|
50
|
+
export function getRemoteRelaysSnapshot() {
|
|
51
|
+
return snapshot;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getRemoteRelays() {
|
|
55
|
+
return snapshot?.relays || [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Replaces the list (socket event, GET response). A payload without a
|
|
60
|
+
* `relays` array is ignored rather than wiping what is known; `self` survives
|
|
61
|
+
* a payload that omits it.
|
|
62
|
+
*/
|
|
63
|
+
export function setRemoteRelaysSnapshot(payload) {
|
|
64
|
+
if (!payload || typeof payload !== 'object' || !Array.isArray(payload.relays)) return snapshot;
|
|
65
|
+
const next = normalizeSnapshot(payload);
|
|
66
|
+
if (!next.self && snapshot?.self) next.self = snapshot.self;
|
|
67
|
+
snapshot = next;
|
|
68
|
+
lastFailedLoadAt = 0;
|
|
69
|
+
notify();
|
|
70
|
+
return snapshot;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Applies one relay a PATCH/check answered with, without a full reload. */
|
|
74
|
+
export function upsertRemoteRelay(relay) {
|
|
75
|
+
const normalized = normalizeRelay(relay);
|
|
76
|
+
if (!normalized || !snapshot) return snapshot;
|
|
77
|
+
const relays = snapshot.relays.slice();
|
|
78
|
+
const index = relays.findIndex((entry) => entry.id === normalized.id);
|
|
79
|
+
if (index === -1) relays.push(normalized);
|
|
80
|
+
else relays[index] = normalized;
|
|
81
|
+
snapshot = { ...snapshot, relays };
|
|
82
|
+
notify();
|
|
83
|
+
return snapshot;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function removeRemoteRelayFromSnapshot(id) {
|
|
87
|
+
const key = String(id || '').trim();
|
|
88
|
+
if (!snapshot || !key) return snapshot;
|
|
89
|
+
snapshot = { ...snapshot, relays: snapshot.relays.filter((entry) => entry.id !== key) };
|
|
90
|
+
notify();
|
|
91
|
+
return snapshot;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Fetches the list now. Resolves to the snapshot (null when unavailable). */
|
|
95
|
+
export async function refreshRemoteRelays() {
|
|
96
|
+
if (IS_SHARED_VIEW) return snapshot;
|
|
97
|
+
const payload = await loadRemoteRelays();
|
|
98
|
+
if (payload && Array.isArray(payload.relays)) return setRemoteRelaysSnapshot(payload);
|
|
99
|
+
lastFailedLoadAt = Date.now();
|
|
100
|
+
return snapshot;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Loads once, lazily (first "@" in the composer); later calls reuse the cache. */
|
|
104
|
+
export function ensureRemoteRelaysLoaded() {
|
|
105
|
+
if (snapshot) return Promise.resolve(snapshot);
|
|
106
|
+
if (IS_SHARED_VIEW) return Promise.resolve(null);
|
|
107
|
+
if (loadPromise) return loadPromise;
|
|
108
|
+
if (lastFailedLoadAt && Date.now() - lastFailedLoadAt < FAILED_LOAD_RETRY_MS) return Promise.resolve(null);
|
|
109
|
+
loadPromise = refreshRemoteRelays().finally(() => {
|
|
110
|
+
loadPromise = null;
|
|
111
|
+
});
|
|
112
|
+
return loadPromise;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function subscribeRemoteRelays(listener) {
|
|
116
|
+
if (typeof listener !== 'function') return () => {};
|
|
117
|
+
listeners.add(listener);
|
|
118
|
+
return () => listeners.delete(listener);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Test hook: forget everything. */
|
|
122
|
+
export function resetRemoteRelaysStoreForTests() {
|
|
123
|
+
snapshot = null;
|
|
124
|
+
loadPromise = null;
|
|
125
|
+
lastFailedLoadAt = 0;
|
|
126
|
+
}
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
updateFeatureFlagSetting as requestFeatureFlagSetting,
|
|
34
34
|
requestRelayRestart,
|
|
35
35
|
} from './api-client.js';
|
|
36
|
+
import { DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES } from './background-task-timeout-options.mjs';
|
|
36
37
|
import { syncFontScaleSelect } from './font-scaling.js';
|
|
37
38
|
import { syncPwaAppNameInput } from './pwa-install.js';
|
|
38
39
|
import { normalizeKnownCwdPath } from './cwd-picker.js';
|
|
@@ -42,6 +43,7 @@ import { refreshClaudeAuthSection } from './claude-auth-ui.js';
|
|
|
42
43
|
import { refreshGrokAuthSection } from './grok-auth-ui.js';
|
|
43
44
|
import { refreshCliInstallSections } from './cli-install-ui.js';
|
|
44
45
|
import { refreshUpdateSection } from './update-ui.js';
|
|
46
|
+
import { refreshRemoteRelaysSection } from './remote-relays-settings.js';
|
|
45
47
|
import {
|
|
46
48
|
getPreviews,
|
|
47
49
|
renderPreviewRowsInto,
|
|
@@ -152,9 +154,10 @@ export async function updateTurnCeilingSetting(value) {
|
|
|
152
154
|
}
|
|
153
155
|
}
|
|
154
156
|
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
157
|
+
// Starts at the default the relay enforces, so the slider tells the truth while
|
|
158
|
+
// the settings request is in flight or after it failed. Bounds come from the
|
|
159
|
+
// API response.
|
|
160
|
+
let backgroundTaskTimeoutMinutes = DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES;
|
|
158
161
|
let backgroundTaskTimeoutUpdateInFlight = false;
|
|
159
162
|
|
|
160
163
|
function syncBackgroundTaskTimeoutSlider() {
|
|
@@ -353,8 +356,8 @@ export function applyCopilotSettingsState(settings = {}, { error = '' } = {}) {
|
|
|
353
356
|
status.dataset.state = 'error';
|
|
354
357
|
} else {
|
|
355
358
|
status.textContent = copilotSettingsState.engine === 'sdk'
|
|
356
|
-
? 'New Copilot conversations run on the
|
|
357
|
-
: 'New Copilot conversations run on the Copilot CLI with the web-relay extension
|
|
359
|
+
? 'New Copilot conversations run on the headless SDK worker. No tmux inspector for these sessions.'
|
|
360
|
+
: 'New Copilot conversations run on the Copilot CLI with the web-relay extension.';
|
|
358
361
|
status.dataset.state = copilotSettingsState.engine === 'sdk' ? 'active' : 'unconfigured';
|
|
359
362
|
}
|
|
360
363
|
}
|
|
@@ -396,7 +399,7 @@ export async function saveCopilotSettings() {
|
|
|
396
399
|
settled = result;
|
|
397
400
|
showTransientRelayNotice(
|
|
398
401
|
String(result.engine) === 'sdk'
|
|
399
|
-
? 'Copilot engine set to SDK
|
|
402
|
+
? 'Copilot engine set to SDK. New conversations use the headless worker; running sessions keep their engine until they restart.'
|
|
400
403
|
: 'Copilot engine set to Extension. New conversations use the Copilot CLI.',
|
|
401
404
|
6000,
|
|
402
405
|
);
|
|
@@ -1630,8 +1633,8 @@ subscribePreviews(() => {
|
|
|
1630
1633
|
renderPreviewsSection();
|
|
1631
1634
|
});
|
|
1632
1635
|
|
|
1633
|
-
// `tab` / `providerTab` are optional deep links (e.g. openSettingsModal('providers', 'claude')
|
|
1634
|
-
// omitting them restores the last tab the user was on.
|
|
1636
|
+
// `tab` / `providerTab` are optional deep links (e.g. openSettingsModal('providers', 'claude')
|
|
1637
|
+
// or openSettingsModal('relays')); omitting them restores the last tab the user was on.
|
|
1635
1638
|
export function openSettingsModal(tab, providerTab) {
|
|
1636
1639
|
closeChatActionsMenu();
|
|
1637
1640
|
const modal = document.getElementById('settings-modal');
|
|
@@ -1674,6 +1677,7 @@ export function openSettingsModal(tab, providerTab) {
|
|
|
1674
1677
|
void refreshBackgroundTaskTimeoutSetting();
|
|
1675
1678
|
void refreshPushSettingsSection();
|
|
1676
1679
|
void refreshPreviewsSection();
|
|
1680
|
+
void refreshRemoteRelaysSection();
|
|
1677
1681
|
void refreshFeatureFlagsSection();
|
|
1678
1682
|
void refreshUpdateSection();
|
|
1679
1683
|
modal?.classList.add('visible');
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const SETTINGS_TAB_STORAGE_KEY = 'copilot_settings_tab';
|
|
2
2
|
const SETTINGS_PROVIDER_TAB_STORAGE_KEY = 'copilot_settings_provider_tab';
|
|
3
3
|
|
|
4
|
-
const SETTINGS_TABS = ['general', 'providers', 'previews', 'notifications', 'features'];
|
|
4
|
+
const SETTINGS_TABS = ['general', 'providers', 'relays', 'previews', '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
7
|
const SETTINGS_PROVIDER_TABS = ['copilot', 'openai', 'claude', 'grok', 'cursor'];
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
clearSubagentCancelInFlight,
|
|
30
30
|
setConversationWatcherCount,
|
|
31
31
|
setUpdateState,
|
|
32
|
+
applySessionWorkerSteering,
|
|
32
33
|
} from './store.js';
|
|
33
34
|
import { scheduleContextUsageRefresh } from './api-client.js';
|
|
34
35
|
import { publishStatusEvent, recordStatusEvent } from './status-store.mjs';
|
|
@@ -41,10 +42,13 @@ import {
|
|
|
41
42
|
import { upsertRelayBoard, loadRelayBoards, renderRelayBoards } from './relay-board-view.js';
|
|
42
43
|
import { setConversationBackgroundTasks } from './background-tasks-view.mjs';
|
|
43
44
|
import { setPreviews } from './preview-cards.mjs';
|
|
45
|
+
import { setRemoteRelaysSnapshot } from './remote-relays-store.mjs';
|
|
46
|
+
import { REMOTE_RELAY_SOCKET_EVENT } from './remote-relay-shared.mjs';
|
|
44
47
|
import { applyClaudeAuthState } from './claude-auth-ui.js';
|
|
45
48
|
import { applyGrokAuthState } from './grok-auth-ui.js';
|
|
46
49
|
import { applyCliInstallState } from './cli-install-ui.js';
|
|
47
50
|
import { renderUpdateSection } from './update-ui.js';
|
|
51
|
+
import { applyHostSuspendState, applyRelayShutdownState } from './host-suspend-ui.js';
|
|
48
52
|
import {
|
|
49
53
|
showThinking,
|
|
50
54
|
removeThinking,
|
|
@@ -66,6 +70,8 @@ import {
|
|
|
66
70
|
removeUserBubbleCancelButton,
|
|
67
71
|
updateSubagentBubbleFromStatus,
|
|
68
72
|
markSubagentStopUnsupported,
|
|
73
|
+
syncComposerButtonState,
|
|
74
|
+
syncCancellableSteerButtons,
|
|
69
75
|
} from './conversation-view.js';
|
|
70
76
|
import { loadRepoBrowserTree, refreshRepoBrowserIfWorkspaceOpen } from './attachments-view.js';
|
|
71
77
|
import { clearMessageSearchRuntimeState } from './message-search-view.js';
|
|
@@ -385,6 +391,14 @@ export async function connectSocket(overrideDeps) {
|
|
|
385
391
|
setUpdateState(payload || null);
|
|
386
392
|
renderUpdateSection();
|
|
387
393
|
});
|
|
394
|
+
// Pending host suspend / relay restart: banner + menu labels on every
|
|
395
|
+
// device. Also sent on connect, so a reconnecting client catches up.
|
|
396
|
+
socket.on('host_suspend_state', (payload) => {
|
|
397
|
+
applyHostSuspendState(payload || null);
|
|
398
|
+
});
|
|
399
|
+
socket.on('relay_shutdown_state', (payload) => {
|
|
400
|
+
applyRelayShutdownState(payload || null);
|
|
401
|
+
});
|
|
388
402
|
socket.on('grok_settings_updated', (payload) => {
|
|
389
403
|
deps?.applyGrokSettingsState?.(payload || {});
|
|
390
404
|
if (Number(payload?.reconciliation?.updatedUnstartedConversations || 0) > 0) {
|
|
@@ -419,7 +433,8 @@ export async function connectSocket(overrideDeps) {
|
|
|
419
433
|
socket.on('user_message', ({ conversationId, messageId, senderClientId, message }) => {
|
|
420
434
|
const normalizedMessage = {
|
|
421
435
|
...(message && typeof message === 'object' ? message : {}),
|
|
422
|
-
|
|
436
|
+
// `origin`: a prompt another relay's agent sent; its header line hides.
|
|
437
|
+
text: stripRelayPromptContext(message?.text, message?.mode, { origin: message?.origin }),
|
|
423
438
|
};
|
|
424
439
|
if (senderClientId && senderClientId === CLIENT_ID) {
|
|
425
440
|
pendingUserMessageIds.delete(messageId);
|
|
@@ -492,6 +507,11 @@ export async function connectSocket(overrideDeps) {
|
|
|
492
507
|
socket.on('previews', ({ previews }) => {
|
|
493
508
|
setPreviews(previews);
|
|
494
509
|
});
|
|
510
|
+
// REPLACE semantics too: the relay ships the whole remote-relay list (no
|
|
511
|
+
// tokens) whenever a remote is added, removed, renamed or changes status.
|
|
512
|
+
socket.on(REMOTE_RELAY_SOCKET_EVENT, (payload) => {
|
|
513
|
+
setRemoteRelaysSnapshot(payload || null);
|
|
514
|
+
});
|
|
495
515
|
socket.on('relay_activity', ({ conversationId, messageId, text, subagentRunId, metadata }) => {
|
|
496
516
|
if (!messageId || !text) return;
|
|
497
517
|
const entry = {
|
|
@@ -627,11 +647,27 @@ export async function connectSocket(overrideDeps) {
|
|
|
627
647
|
runtimeSessionId: String(runtimeSessionId || conversations[id].runtimeSessionId || '').trim() || null,
|
|
628
648
|
};
|
|
629
649
|
}
|
|
650
|
+
// The composer reads the worker's steering snapshot under the session id
|
|
651
|
+
// just bound; fetch it now rather than on the next status poll.
|
|
652
|
+
refreshSessionWorkerStatus().catch(() => {});
|
|
630
653
|
await refreshConversations();
|
|
631
654
|
if (currentConvId === id) {
|
|
632
655
|
await openConversation(id);
|
|
633
656
|
}
|
|
634
657
|
});
|
|
658
|
+
// A worker's steering snapshot changed (its heartbeat): the composer's
|
|
659
|
+
// Steer/Queue label and the pushed rows' Cancel follow it at once instead of
|
|
660
|
+
// on the next status poll.
|
|
661
|
+
socket.on('session_worker_steering', ({ sdkSessionId, steering } = {}) => {
|
|
662
|
+
const changed = applySessionWorkerSteering(sdkSessionId, steering);
|
|
663
|
+
if (changed === null) {
|
|
664
|
+
refreshSessionWorkerStatus().catch(() => {});
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
if (!changed) return;
|
|
668
|
+
syncComposerButtonState();
|
|
669
|
+
syncCancellableSteerButtons();
|
|
670
|
+
});
|
|
635
671
|
socket.on('message_status', ({ messageId, conversationId, status }) => {
|
|
636
672
|
const normalizedStatus = String(status || '').trim().toLowerCase();
|
|
637
673
|
const clearsProcessingStatus = ['done', 'failed', 'dropped', 'pending', 'parked', 'cancelled'].includes(normalizedStatus);
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Mid-turn steering: a message sent while a Claude turn was running is pushed
|
|
2
|
+
// into the live turn. Three settle shapes reach the transcript:
|
|
3
|
+
// - handoff: the interrupted reply settles with kind='absorbed', and the rest
|
|
4
|
+
// of the turn (with the final reply) lands on the steered message's own row.
|
|
5
|
+
// Those rows render as ONE flow — absorbed reply, steered user bubble,
|
|
6
|
+
// continuing reply.
|
|
7
|
+
// - fold: the CLI answered the steer inside the running turn; its row settles
|
|
8
|
+
// with a kind='folded' stub, a compact marker under its own user message.
|
|
9
|
+
// It never merges forward — the next message is an ordinary turn.
|
|
10
|
+
// - stopped: the steer was pushed into a turn the user then stopped, so it
|
|
11
|
+
// went unanswered (kind='stopped', a marker with Resend).
|
|
12
|
+
// The steered user message of a fold or stop is still classed as steered.
|
|
13
|
+
//
|
|
14
|
+
// One idempotent DOM pass, same contract as syncTranscriptSeparators: every
|
|
15
|
+
// insertion path (full render, live append, prepended history page) re-runs it,
|
|
16
|
+
// so classes heal when the pair arrives across two loads and un-apply when a
|
|
17
|
+
// re-render drops the absorbed marker. Kept DOM-only and import-light so it is
|
|
18
|
+
// jsdom-testable (conversation-view.js touches window at module scope).
|
|
19
|
+
import { SEPARATOR_CLASS } from './transcript-separators.mjs';
|
|
20
|
+
|
|
21
|
+
export const ABSORBED_MSG_CLASS = 'msg-absorbed';
|
|
22
|
+
export const STEERED_MSG_CLASS = 'msg-steered';
|
|
23
|
+
export const STEERED_CONTINUATION_CLASS = 'msg-steered-continuation';
|
|
24
|
+
export const FOLDED_MSG_CLASS = 'msg-folded';
|
|
25
|
+
export const STEER_STOPPED_MSG_CLASS = 'msg-steer-stopped';
|
|
26
|
+
|
|
27
|
+
/** The assistant row kinds that are settle markers of a steer, not replies. */
|
|
28
|
+
export const STEER_MARKER_CLASS_BY_KIND = Object.freeze({
|
|
29
|
+
folded: FOLDED_MSG_CLASS,
|
|
30
|
+
stopped: STEER_STOPPED_MSG_CLASS,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function isSteerMarker(node) {
|
|
34
|
+
return !!node?.classList
|
|
35
|
+
&& node.classList.contains('assistant')
|
|
36
|
+
&& (node.classList.contains(FOLDED_MSG_CLASS) || node.classList.contains(STEER_STOPPED_MSG_CLASS));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The `.msg` row above, refusing to cross a separator row: a steering pair is
|
|
41
|
+
* seconds apart, so a day or compaction break between two rows proves they are
|
|
42
|
+
* NOT that pair, and pulling them together across it would look broken.
|
|
43
|
+
*/
|
|
44
|
+
function previousMessageNode(node) {
|
|
45
|
+
for (let prev = node?.previousElementSibling; prev; prev = prev.previousElementSibling) {
|
|
46
|
+
if (prev.classList?.contains(SEPARATOR_CLASS)) return null;
|
|
47
|
+
if (prev.classList?.contains('msg')) return prev;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The `.msg` row below, refusing to cross a separator row. */
|
|
53
|
+
function nextMessageNode(node) {
|
|
54
|
+
for (let next = node?.nextElementSibling; next; next = next.nextElementSibling) {
|
|
55
|
+
if (next.classList?.contains(SEPARATOR_CLASS)) return null;
|
|
56
|
+
if (next.classList?.contains('msg')) return next;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function setClass(node, className, on) {
|
|
62
|
+
if (!node?.classList) return 0;
|
|
63
|
+
if (node.classList.contains(className) === on) return 0;
|
|
64
|
+
node.classList.toggle(className, on);
|
|
65
|
+
return 1;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Sync the merge classes over the container's rows. Returns the number of
|
|
70
|
+
* class changes (0 = the pass was a no-op), which the tests assert on.
|
|
71
|
+
*/
|
|
72
|
+
export function syncSteeredTurnMerge(container) {
|
|
73
|
+
if (!container || !container.children) return 0;
|
|
74
|
+
let changes = 0;
|
|
75
|
+
for (const node of container.children) {
|
|
76
|
+
if (!node.classList?.contains('msg')) continue;
|
|
77
|
+
if (node.classList.contains('user')) {
|
|
78
|
+
// A user row is steered iff the row above it is an absorbed reply, or
|
|
79
|
+
// its own settle marker (fold/stop) sits right below it.
|
|
80
|
+
const previous = previousMessageNode(node);
|
|
81
|
+
const steered = (!!previous
|
|
82
|
+
&& previous.classList.contains('assistant')
|
|
83
|
+
&& previous.classList.contains(ABSORBED_MSG_CLASS))
|
|
84
|
+
|| isSteerMarker(nextMessageNode(node));
|
|
85
|
+
changes += setClass(node, STEERED_MSG_CLASS, steered);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (node.classList.contains('assistant')) {
|
|
89
|
+
// The reply continuing a steered message keeps the group together. A
|
|
90
|
+
// settle marker is not a reply: it keeps its own compact styling.
|
|
91
|
+
const previous = previousMessageNode(node);
|
|
92
|
+
const continues = !isSteerMarker(node)
|
|
93
|
+
&& !!previous
|
|
94
|
+
&& previous.classList.contains('user')
|
|
95
|
+
&& previous.classList.contains(STEERED_MSG_CLASS);
|
|
96
|
+
changes += setClass(node, STEERED_CONTINUATION_CLASS, continues);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return changes;
|
|
100
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveNearBottomThresholdPx } from './transcript-viewport-anchor.mjs';
|
|
1
2
|
import { recordCliLifecycleEvent, recordRelayLifecycleEvent } from './status-store.mjs';
|
|
2
3
|
import { resolveRelayDotState } from './relay-dot-state.mjs';
|
|
3
4
|
import { readRepoBrowserPreferences } from './repo-browser-preferences.mjs';
|
|
@@ -309,10 +310,23 @@ function cleanupStalePendingUserMessages(maxAgeMs = 15 * 60 * 1000) {
|
|
|
309
310
|
}
|
|
310
311
|
}
|
|
311
312
|
|
|
312
|
-
|
|
313
|
+
// An attachment-only send has no text to fingerprint; its attachments stand
|
|
314
|
+
// in, so a screenshot send is tracked (and its bubble kept across rebuilds)
|
|
315
|
+
// like any other.
|
|
316
|
+
function pendingAttachmentFingerprint(attachments) {
|
|
317
|
+
const list = Array.isArray(attachments) ? attachments.filter(Boolean) : [];
|
|
318
|
+
if (!list.length) return '';
|
|
319
|
+
const firstSha = String(list[0]?.sha256 || list[0]?.uploaded?.sha256 || '').trim().toLowerCase();
|
|
320
|
+
return `attachments:${list.length}:${firstSha}`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Always records the entry — the conversation key is what scopes a kept
|
|
324
|
+
// pending bubble to its own conversation — even when there is nothing to
|
|
325
|
+
// fingerprint for the duplicate check.
|
|
326
|
+
export function trackPendingUserMessage(messageId, conversationId, text, { attachments = [] } = {}) {
|
|
313
327
|
const id = String(messageId || '').trim();
|
|
314
|
-
|
|
315
|
-
|
|
328
|
+
if (!id) return false;
|
|
329
|
+
const fingerprint = normalizePendingMessageText(text) || pendingAttachmentFingerprint(attachments);
|
|
316
330
|
cleanupStalePendingUserMessages();
|
|
317
331
|
pendingUserMessageEntries.set(id, {
|
|
318
332
|
conversationKey: pendingConversationKey(conversationId),
|
|
@@ -597,10 +611,7 @@ function resolveMessagesNearBottomThreshold(el, thresholdPx = null) {
|
|
|
597
611
|
if (Number.isFinite(Number(thresholdPx)) && Number(thresholdPx) >= 0) {
|
|
598
612
|
return Math.trunc(Number(thresholdPx));
|
|
599
613
|
}
|
|
600
|
-
|
|
601
|
-
if (!Number.isFinite(viewportHeight) || viewportHeight <= 0) return 0;
|
|
602
|
-
const relativeThreshold = Math.floor(viewportHeight * 0.08);
|
|
603
|
-
return Math.min(48, Math.max(12, relativeThreshold));
|
|
614
|
+
return resolveNearBottomThresholdPx(el?.clientHeight);
|
|
604
615
|
}
|
|
605
616
|
|
|
606
617
|
export function isMessagesNearBottom(thresholdPx = null) {
|
|
@@ -876,6 +887,28 @@ export function resolveConversationUiState({ conversation = null, workerState =
|
|
|
876
887
|
return fallbackUiState || 'offline';
|
|
877
888
|
}
|
|
878
889
|
|
|
890
|
+
// Composer steering snapshot from the worker's heartbeat: whether a typed
|
|
891
|
+
// message would steer into the live turn, and why not when it wouldn't.
|
|
892
|
+
function normalizeWorkerSteering(raw) {
|
|
893
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
894
|
+
return {
|
|
895
|
+
turnActive: raw.turnActive === true,
|
|
896
|
+
canSteer: raw.canSteer === true,
|
|
897
|
+
holdReason: String(raw.holdReason || '').trim() || null,
|
|
898
|
+
// The turn the snapshot describes — lets the composer drop a hold that
|
|
899
|
+
// belongs to a previous turn (the heartbeat lags ~10s).
|
|
900
|
+
messageId: String(raw.messageId || '').trim() || null,
|
|
901
|
+
// The worker's own opt-in to mid-turn steering (both SDK workers send
|
|
902
|
+
// it); the composer's only steering gate — no provider rule backs it up.
|
|
903
|
+
supported: raw.supported === true,
|
|
904
|
+
// Pushed-but-unconsumed rows the worker can still pull back out of its
|
|
905
|
+
// runtime: their bubbles keep Cancel while listed here.
|
|
906
|
+
cancellableIds: Array.isArray(raw.cancellableIds)
|
|
907
|
+
? raw.cancellableIds.map((id) => String(id || '').trim()).filter(Boolean)
|
|
908
|
+
: [],
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
|
|
879
912
|
function normalizeWorkerStateEntry(worker) {
|
|
880
913
|
const sdkSessionId = normalizeWorkerSessionId(worker?.sdkSessionId);
|
|
881
914
|
if (!sdkSessionId) return null;
|
|
@@ -889,13 +922,17 @@ function normalizeWorkerStateEntry(worker) {
|
|
|
889
922
|
workerId: String(worker?.workerId || '').trim() || null,
|
|
890
923
|
pid: Number.isInteger(Number(worker?.pid)) ? Number(worker.pid) : null,
|
|
891
924
|
updatedAt: String(worker?.updatedAt || '').trim() || null,
|
|
925
|
+
steering: normalizeWorkerSteering(worker?.steering),
|
|
892
926
|
};
|
|
893
927
|
}
|
|
894
928
|
|
|
895
929
|
function buildSessionWorkerStateHash(map) {
|
|
896
930
|
const parts = [];
|
|
897
931
|
for (const [sid, state] of map.entries()) {
|
|
898
|
-
|
|
932
|
+
const steering = state.steering
|
|
933
|
+
? `${state.steering.turnActive ? 1 : 0}${state.steering.canSteer ? 1 : 0}${state.steering.supported ? 1 : 0}${state.steering.holdReason || ''}:${state.steering.messageId || ''}:${state.steering.cancellableIds.join(',')}`
|
|
934
|
+
: '';
|
|
935
|
+
parts.push(`${sid}:${state.status}:${state.uiState || ''}:${state.derivedUiState || ''}:${state.workerId || ''}:${state.pid || ''}:${state.updatedAt || ''}:${steering}`);
|
|
899
936
|
}
|
|
900
937
|
return parts.sort().join('|');
|
|
901
938
|
}
|
|
@@ -917,6 +954,26 @@ export function setSessionWorkerStatesFromStatusPayload(payload) {
|
|
|
917
954
|
return true;
|
|
918
955
|
}
|
|
919
956
|
|
|
957
|
+
/**
|
|
958
|
+
* Apply one worker's steering snapshot as the relay broadcasts it
|
|
959
|
+
* (`session_worker_steering`), ahead of the next status poll. Only an entry
|
|
960
|
+
* the client already knows is patched — the snapshot alone carries none of
|
|
961
|
+
* the entry's other fields. Returns null for an unknown session (the caller
|
|
962
|
+
* refreshes the whole status instead), otherwise whether anything changed.
|
|
963
|
+
*/
|
|
964
|
+
export function applySessionWorkerSteering(sdkSessionId, steering) {
|
|
965
|
+
const sid = normalizeWorkerSessionId(sdkSessionId);
|
|
966
|
+
const existing = sid ? sessionWorkerStates.get(sid) : null;
|
|
967
|
+
if (!existing) return null;
|
|
968
|
+
const next = new Map(sessionWorkerStates);
|
|
969
|
+
next.set(sid, { ...existing, steering: normalizeWorkerSteering(steering) });
|
|
970
|
+
const nextHash = buildSessionWorkerStateHash(next);
|
|
971
|
+
if (nextHash === sessionWorkerStateHash) return false;
|
|
972
|
+
sessionWorkerStateHash = nextHash;
|
|
973
|
+
sessionWorkerStates = next;
|
|
974
|
+
return true;
|
|
975
|
+
}
|
|
976
|
+
|
|
920
977
|
export function getSessionWorkerState(sdkSessionId) {
|
|
921
978
|
const sid = normalizeWorkerSessionId(sdkSessionId);
|
|
922
979
|
if (!sid) return null;
|
|
@@ -1194,7 +1251,19 @@ export function setModelBanner(message) {
|
|
|
1194
1251
|
el.classList.add('visible');
|
|
1195
1252
|
}
|
|
1196
1253
|
|
|
1197
|
-
|
|
1254
|
+
let transientNoticeSeq = 0;
|
|
1255
|
+
// The action notice on screen (or covered by a plain one), so a plain notice
|
|
1256
|
+
// that covers it hands the toast back afterwards instead of dropping the
|
|
1257
|
+
// action for good.
|
|
1258
|
+
let activeActionNotice = null;
|
|
1259
|
+
|
|
1260
|
+
/**
|
|
1261
|
+
* `action` ({ actionLabel, onAction }) adds one button to the toast; the
|
|
1262
|
+
* toast then takes pointer input and hides as soon as the button is used.
|
|
1263
|
+
* A plain notice shown over an action notice hands the toast back to it when
|
|
1264
|
+
* it hides, for whatever time the action notice had left.
|
|
1265
|
+
*/
|
|
1266
|
+
export function showTransientRelayNotice(message, ms = 4000, action = null) {
|
|
1198
1267
|
const text = String(message || '').trim();
|
|
1199
1268
|
if (!text) return;
|
|
1200
1269
|
// Transient notices render in the fixed #relay-toast, not #model-banner:
|
|
@@ -1209,17 +1278,59 @@ export function showTransientRelayNotice(message, ms = 4000) {
|
|
|
1209
1278
|
}, Math.max(1500, Number(ms) || 4000));
|
|
1210
1279
|
return;
|
|
1211
1280
|
}
|
|
1281
|
+
const durationMs = Math.max(1500, Number(ms) || 4000);
|
|
1282
|
+
const noticeId = String(++transientNoticeSeq);
|
|
1283
|
+
const actionLabel = String(action?.actionLabel || '').trim();
|
|
1284
|
+
const hasAction = !!actionLabel && typeof action?.onAction === 'function';
|
|
1285
|
+
const covered = !hasAction && activeActionNotice && activeActionNotice.expiresAt > Date.now()
|
|
1286
|
+
? activeActionNotice
|
|
1287
|
+
: null;
|
|
1288
|
+
if (hasAction) {
|
|
1289
|
+
activeActionNotice = { noticeId, message: text, action, expiresAt: Date.now() + durationMs };
|
|
1290
|
+
}
|
|
1291
|
+
const hide = () => {
|
|
1292
|
+
if (toast.dataset.noticeId !== noticeId) return;
|
|
1293
|
+
toast.textContent = '';
|
|
1294
|
+
toast.classList.remove('visible', 'has-action');
|
|
1295
|
+
if (hasAction) {
|
|
1296
|
+
if (activeActionNotice?.noticeId === noticeId) activeActionNotice = null;
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
if (covered && activeActionNotice === covered) {
|
|
1300
|
+
const remainingMs = covered.expiresAt - Date.now();
|
|
1301
|
+
if (remainingMs > 0) showTransientRelayNotice(covered.message, Math.max(4000, remainingMs), covered.action);
|
|
1302
|
+
}
|
|
1303
|
+
};
|
|
1304
|
+
toast.dataset.noticeId = noticeId;
|
|
1212
1305
|
toast.textContent = text;
|
|
1306
|
+
if (hasAction) {
|
|
1307
|
+
const button = document.createElement('button');
|
|
1308
|
+
button.type = 'button';
|
|
1309
|
+
button.className = 'relay-toast-action';
|
|
1310
|
+
button.textContent = actionLabel;
|
|
1311
|
+
button.addEventListener('click', () => {
|
|
1312
|
+
hide();
|
|
1313
|
+
action.onAction();
|
|
1314
|
+
});
|
|
1315
|
+
toast.appendChild(button);
|
|
1316
|
+
}
|
|
1317
|
+
toast.classList.toggle('has-action', hasAction);
|
|
1213
1318
|
toast.classList.add('visible');
|
|
1214
|
-
setTimeout(
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1319
|
+
setTimeout(hide, durationMs);
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
// The screen's short side sizes phone-landscape text (index.html); unlike
|
|
1323
|
+
// the viewport, the on-screen keyboard never changes it. Refreshed here for
|
|
1324
|
+
// foldables, whose screen changes when they unfold.
|
|
1325
|
+
function syncScreenShortSide() {
|
|
1326
|
+
const shortSide = Math.min(Number(window.screen?.width) || 0, Number(window.screen?.height) || 0);
|
|
1327
|
+
if (!shortSide) return;
|
|
1328
|
+
document.documentElement.style.setProperty('--screen-short-side', `${shortSide}px`);
|
|
1329
|
+
document.documentElement.toggleAttribute('data-phone-screen', shortSide <= 500);
|
|
1220
1330
|
}
|
|
1221
1331
|
|
|
1222
1332
|
export function syncViewportMetrics() {
|
|
1333
|
+
syncScreenShortSide();
|
|
1223
1334
|
const layoutHeight = window.innerHeight || document.documentElement.clientHeight || 0;
|
|
1224
1335
|
const layoutWidth = window.innerWidth || document.documentElement.clientWidth || 0;
|
|
1225
1336
|
const vv = window.visualViewport;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { BASE, CLIENT_ID, authHeaders, conversations } from './store.js';
|
|
2
|
+
import { draftFlushRequestBody, getSyncedDraft, isStaleDraftFlushEntry } from './conversation-draft-sync.mjs';
|
|
2
3
|
|
|
3
4
|
// Durable outbox for Background Sync: message sends, ask_user answers, and
|
|
4
5
|
// draft flushes that failed (or might die mid-flight on pagehide) are queued
|
|
@@ -129,6 +130,10 @@ export async function replayOutboxFromPage() {
|
|
|
129
130
|
db = await openOutboxDb();
|
|
130
131
|
const entries = await readOutboxEntries(db);
|
|
131
132
|
for (const { key, value } of entries) {
|
|
133
|
+
if (isStaleDraftFlushEntry(value)) {
|
|
134
|
+
await deleteOutboxEntry(db, key);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
132
137
|
let response = null;
|
|
133
138
|
try {
|
|
134
139
|
response = await fetch(`${BASE}${value.path}`, {
|
|
@@ -185,23 +190,26 @@ export async function initOutboxFallbackReplay() {
|
|
|
185
190
|
* replay treats as final. Only queues when the draft differs from the last
|
|
186
191
|
* persisted state.
|
|
187
192
|
*/
|
|
188
|
-
export async function enqueueDraftFlushForBackgroundSync(conversationId) {
|
|
193
|
+
export async function enqueueDraftFlushForBackgroundSync(conversationId, { afterPendingSend = false } = {}) {
|
|
189
194
|
const id = String(conversationId || '').trim();
|
|
190
195
|
if (!id) return false;
|
|
191
196
|
const conversation = conversations[id];
|
|
192
197
|
if (!conversation) return false;
|
|
193
198
|
const input = document.getElementById('msg-input');
|
|
194
|
-
const
|
|
195
|
-
|
|
199
|
+
const body = draftFlushRequestBody({
|
|
200
|
+
inputText: input ? input.value : (conversation.draftText || ''),
|
|
201
|
+
synced: getSyncedDraft(id),
|
|
202
|
+
fallbackText: conversation.draftText,
|
|
203
|
+
fallbackUpdatedAt: conversation.draftUpdatedAt,
|
|
204
|
+
clientId: CLIENT_ID,
|
|
205
|
+
afterPendingSend,
|
|
206
|
+
});
|
|
207
|
+
if (!body) return false;
|
|
196
208
|
const queued = await enqueueOutboxRequest({
|
|
197
209
|
kind: 'draft-flush',
|
|
198
210
|
path: `/api/conversation/${encodeURIComponent(id)}/draft`,
|
|
199
211
|
method: 'PATCH',
|
|
200
|
-
body: JSON.stringify(
|
|
201
|
-
draftText,
|
|
202
|
-
clientId: CLIENT_ID,
|
|
203
|
-
baseDraftUpdatedAt: conversation.draftUpdatedAt || null,
|
|
204
|
-
}),
|
|
212
|
+
body: JSON.stringify(body),
|
|
205
213
|
});
|
|
206
214
|
if (queued) void registerOutboxSync();
|
|
207
215
|
return queued;
|