@oar-sh/oar 0.9.1 → 0.9.3
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 +0 -3
- package/.github/extensions/web-relay/polling/heartbeat.mjs +2 -55
- package/.github/extensions/web-relay/polling/polling-loop.mjs +0 -1
- package/.github/extensions/web-relay/runtime/api-client.mjs +2 -34
- package/.github/extensions/web-relay/runtime/config-loader.mjs +2 -104
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +2 -170
- package/.github/extensions/web-relay/runtime/session-io.mjs +1 -71
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +2 -325
- package/.github/extensions/web-relay/skills/prompt-context.mjs +2 -160
- package/CHANGELOG.md +269 -0
- package/README.md +407 -424
- package/bin/oar.js +142 -11
- package/package.json +8 -4
- package/server/README.md +59 -22
- package/server/claude-worker/claude-sdk-adapter.mjs +7 -2
- package/server/claude-worker/claude-session-process.mjs +1013 -45
- package/server/claude-worker/claude-session-worker.mjs +54 -9
- package/server/claude-worker/claude-turn-publisher.mjs +110 -19
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/copilot-sdk-runtime.mjs +12 -3
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-attachments.mjs +35 -2
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-model-switch.mjs +425 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +20 -8
- package/server/copilot-worker/copilot-question-bridge.mjs +129 -16
- package/server/copilot-worker/copilot-sdk-adapter.mjs +59 -11
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +136 -12
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2753 -527
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +94 -25
- package/server/cursor-worker/cursor-session-worker.mjs +5 -5
- package/server/cursor-worker/cursor-turn-runner.mjs +50 -4
- package/server/db-schema.mjs +61 -1
- package/server/features.mjs +155 -41
- package/server/grok-worker/grok-session-worker.mjs +5 -5
- package/server/grok-worker/grok-turn-runner.mjs +45 -4
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/public/app/annotate-core.mjs +181 -0
- package/server/public/app/annotate-editor.js +617 -0
- package/server/public/app/api-client.js +86 -5
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +172 -3
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +34 -19
- package/server/public/app/bootstrap.js +153 -69
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/context-tier-options.mjs +24 -4
- 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-preferences.mjs +47 -10
- package/server/public/app/conversation-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +1032 -218
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/journal-view.js +190 -24
- package/server/public/app/model-selector-options.mjs +185 -1
- package/server/public/app/new-conversation-model-choice.mjs +29 -9
- package/server/public/app/pwa-install.js +64 -136
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/reasoning-effort-labels.mjs +13 -3
- package/server/public/app/relay-prompt-sanitizer.mjs +26 -3
- package/server/public/app/router.js +120 -11
- package/server/public/app/settings-modal.js +225 -20
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +47 -4
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +159 -16
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/tool-activity-emoji.mjs +36 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/app/update-ui.js +194 -0
- package/server/public/index.html +483 -24
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +2 -0
- package/server/relay.mjs +23 -21
- package/server/repositories/message-repository.mjs +78 -21
- package/server/repositories/question-repository.mjs +19 -1
- package/server/repositories/session-repository.mjs +7 -31
- package/server/routes/ask-user-routes.mjs +10 -0
- package/server/routes/messages-routes.mjs +689 -159
- package/server/routes/sessions-routes.mjs +574 -80
- package/server/server-runtime.mjs +393 -925
- package/server/services/claude-auth-service.mjs +7 -1
- package/server/services/cli-install-service.mjs +8 -27
- package/server/services/cli-process-runner.mjs +30 -0
- package/server/services/copilot-model-discovery-service.mjs +202 -0
- package/server/services/data-dir-resolution.mjs +47 -0
- package/server/services/grok-auth-service.mjs +9 -1
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/model-variant-catalog-service.mjs +1183 -0
- package/server/services/oar-cli-helpers.mjs +6 -2
- package/server/services/relay-prompt-sanitizer.mjs +30 -3
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/sdk-session-import-service.mjs +144 -16
- package/server/services/sdk-session-sync-service.mjs +84 -27
- package/server/services/session-worker-registry-service.mjs +116 -3
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +119 -24
- package/server/services/status-event-service.mjs +8 -2
- package/server/services/steer-resend.mjs +45 -0
- package/server/services/update-check-service.mjs +220 -0
- package/server/services/update-install-service.mjs +279 -0
- package/server/services/windows-autostart-service.mjs +42 -6
- package/server/services/windows-boot-autostart-service.mjs +487 -0
- package/shared/ask-user-bridge.mjs +37 -5
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/claude-model-catalog.mjs +49 -0
- package/shared/context-window-fallbacks.mjs +1 -0
- package/shared/control-poller.mjs +4 -1
- package/shared/copilot-model-order.mjs +182 -0
- package/shared/media-embed-instructions.mjs +35 -0
- package/shared/model-descriptors.mjs +181 -30
- package/shared/model-id.mjs +6 -0
- package/shared/pwa-app-name.mjs +44 -0
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -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/update-semver.mjs +50 -0
- package/shared/worker-crash-guard.mjs +40 -14
- package/shared/worker-runtime/api-client.mjs +34 -0
- package/shared/worker-runtime/config-loader.mjs +104 -0
- package/shared/worker-runtime/heartbeat.mjs +109 -0
- package/shared/worker-runtime/prompt-context.mjs +160 -0
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/send-and-wait-errors.mjs +170 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +436 -0
- package/server/claude-worker/claude-session-test-harness.mjs +0 -236
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -329
- 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.sh +0 -62
- package/server/services/fixtures/cli-install-stub.sh +0 -75
- package/server/services/fixtures/grok-stub.sh +0 -86
|
@@ -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';
|
|
@@ -73,6 +74,9 @@ export let relayOnline = false;
|
|
|
73
74
|
// Latest cloudflared tunnel status, from /api/status on load and the
|
|
74
75
|
// cloudflared_tunnel_status socket event thereafter. Null until first reported.
|
|
75
76
|
export let cloudflaredTunnelState = null;
|
|
77
|
+
// Latest self-update payload, from /api/status on load and the update_state
|
|
78
|
+
// socket event thereafter. Null until first reported.
|
|
79
|
+
export let updateState = null;
|
|
76
80
|
export let activeRuntimeSessionCount = 0;
|
|
77
81
|
export let runtimeSessionBindingCount = 0;
|
|
78
82
|
export let conversations = {};
|
|
@@ -306,10 +310,23 @@ function cleanupStalePendingUserMessages(maxAgeMs = 15 * 60 * 1000) {
|
|
|
306
310
|
}
|
|
307
311
|
}
|
|
308
312
|
|
|
309
|
-
|
|
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 = [] } = {}) {
|
|
310
327
|
const id = String(messageId || '').trim();
|
|
311
|
-
|
|
312
|
-
|
|
328
|
+
if (!id) return false;
|
|
329
|
+
const fingerprint = normalizePendingMessageText(text) || pendingAttachmentFingerprint(attachments);
|
|
313
330
|
cleanupStalePendingUserMessages();
|
|
314
331
|
pendingUserMessageEntries.set(id, {
|
|
315
332
|
conversationKey: pendingConversationKey(conversationId),
|
|
@@ -340,6 +357,15 @@ export function hasPendingUserMessageDuplicate(conversationId, text) {
|
|
|
340
357
|
return false;
|
|
341
358
|
}
|
|
342
359
|
|
|
360
|
+
export function hasPendingUserMessageForConversation(conversationId) {
|
|
361
|
+
cleanupStalePendingUserMessages();
|
|
362
|
+
const conversationKey = pendingConversationKey(conversationId);
|
|
363
|
+
for (const entry of pendingUserMessageEntries.values()) {
|
|
364
|
+
if (entry?.conversationKey === conversationKey) return true;
|
|
365
|
+
}
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
|
|
343
369
|
export function authHeaders() {
|
|
344
370
|
return TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {};
|
|
345
371
|
}
|
|
@@ -585,10 +611,7 @@ function resolveMessagesNearBottomThreshold(el, thresholdPx = null) {
|
|
|
585
611
|
if (Number.isFinite(Number(thresholdPx)) && Number(thresholdPx) >= 0) {
|
|
586
612
|
return Math.trunc(Number(thresholdPx));
|
|
587
613
|
}
|
|
588
|
-
|
|
589
|
-
if (!Number.isFinite(viewportHeight) || viewportHeight <= 0) return 0;
|
|
590
|
-
const relativeThreshold = Math.floor(viewportHeight * 0.08);
|
|
591
|
-
return Math.min(48, Math.max(12, relativeThreshold));
|
|
614
|
+
return resolveNearBottomThresholdPx(el?.clientHeight);
|
|
592
615
|
}
|
|
593
616
|
|
|
594
617
|
export function isMessagesNearBottom(thresholdPx = null) {
|
|
@@ -767,6 +790,14 @@ export function setCloudflaredTunnelState(value) {
|
|
|
767
790
|
updateCliStatus();
|
|
768
791
|
}
|
|
769
792
|
|
|
793
|
+
export function setUpdateState(value) {
|
|
794
|
+
updateState = value && typeof value === 'object' ? value : null;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
export function getUpdateState() {
|
|
798
|
+
return updateState;
|
|
799
|
+
}
|
|
800
|
+
|
|
770
801
|
export function setImageEditTarget(value) {
|
|
771
802
|
imageEditTarget = value && typeof value === 'object'
|
|
772
803
|
? {
|
|
@@ -856,6 +887,28 @@ export function resolveConversationUiState({ conversation = null, workerState =
|
|
|
856
887
|
return fallbackUiState || 'offline';
|
|
857
888
|
}
|
|
858
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
|
+
|
|
859
912
|
function normalizeWorkerStateEntry(worker) {
|
|
860
913
|
const sdkSessionId = normalizeWorkerSessionId(worker?.sdkSessionId);
|
|
861
914
|
if (!sdkSessionId) return null;
|
|
@@ -869,13 +922,17 @@ function normalizeWorkerStateEntry(worker) {
|
|
|
869
922
|
workerId: String(worker?.workerId || '').trim() || null,
|
|
870
923
|
pid: Number.isInteger(Number(worker?.pid)) ? Number(worker.pid) : null,
|
|
871
924
|
updatedAt: String(worker?.updatedAt || '').trim() || null,
|
|
925
|
+
steering: normalizeWorkerSteering(worker?.steering),
|
|
872
926
|
};
|
|
873
927
|
}
|
|
874
928
|
|
|
875
929
|
function buildSessionWorkerStateHash(map) {
|
|
876
930
|
const parts = [];
|
|
877
931
|
for (const [sid, state] of map.entries()) {
|
|
878
|
-
|
|
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}`);
|
|
879
936
|
}
|
|
880
937
|
return parts.sort().join('|');
|
|
881
938
|
}
|
|
@@ -897,6 +954,26 @@ export function setSessionWorkerStatesFromStatusPayload(payload) {
|
|
|
897
954
|
return true;
|
|
898
955
|
}
|
|
899
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
|
+
|
|
900
977
|
export function getSessionWorkerState(sdkSessionId) {
|
|
901
978
|
const sid = normalizeWorkerSessionId(sdkSessionId);
|
|
902
979
|
if (!sid) return null;
|
|
@@ -1174,20 +1251,86 @@ export function setModelBanner(message) {
|
|
|
1174
1251
|
el.classList.add('visible');
|
|
1175
1252
|
}
|
|
1176
1253
|
|
|
1177
|
-
|
|
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) {
|
|
1178
1267
|
const text = String(message || '').trim();
|
|
1179
1268
|
if (!text) return;
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1269
|
+
// Transient notices render in the fixed #relay-toast, not #model-banner:
|
|
1270
|
+
// the banner is composer-scoped and stacks below modals, while these
|
|
1271
|
+
// notices must stay readable over full-screen overlays.
|
|
1272
|
+
const toast = document.getElementById('relay-toast');
|
|
1273
|
+
if (!toast) {
|
|
1274
|
+
setModelBanner(text);
|
|
1275
|
+
setTimeout(() => {
|
|
1276
|
+
const el = document.getElementById('model-banner');
|
|
1277
|
+
if (el && String(el.textContent || '').trim() === text) setModelBanner('');
|
|
1278
|
+
}, Math.max(1500, Number(ms) || 4000));
|
|
1279
|
+
return;
|
|
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);
|
|
1186
1302
|
}
|
|
1187
|
-
}
|
|
1303
|
+
};
|
|
1304
|
+
toast.dataset.noticeId = noticeId;
|
|
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);
|
|
1318
|
+
toast.classList.add('visible');
|
|
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);
|
|
1188
1330
|
}
|
|
1189
1331
|
|
|
1190
1332
|
export function syncViewportMetrics() {
|
|
1333
|
+
syncScreenShortSide();
|
|
1191
1334
|
const layoutHeight = window.innerHeight || document.documentElement.clientHeight || 0;
|
|
1192
1335
|
const layoutWidth = window.innerWidth || document.documentElement.clientWidth || 0;
|
|
1193
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;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Leading emoji for a tool-activity line ("Tool (PowerShell): …" → "🪓 Tool
|
|
2
|
+
// (PowerShell): …"), shared by the transcript's activity rows and the
|
|
3
|
+
// composer's background-tasks panel so a tool reads the same everywhere.
|
|
4
|
+
// Text that already starts with an emoji is returned unchanged.
|
|
5
|
+
export function prefixToolActivityEmoji(text) {
|
|
6
|
+
const value = String(text || '').trim();
|
|
7
|
+
if (!value) return '';
|
|
8
|
+
if (/^[\u{1F300}-\u{1FAFF}☀-➿]/u.test(value)) return value;
|
|
9
|
+
if (value.startsWith('● ')) return `🔄 ${value.slice(2).trim()}`;
|
|
10
|
+
if (/^Model selected:/i.test(value)) return `🧠 ${value}`;
|
|
11
|
+
if (/^Search \((glob|grep)\)/i.test(value)) return `🔍 ${value}`;
|
|
12
|
+
if (/^Tool \(ask_user\)/i.test(value)) return `❓ ${value}`;
|
|
13
|
+
if (/^Tool \(view\)/i.test(value)) return `👀 ${value}`;
|
|
14
|
+
if (/^Tool \(apply_patch\)/i.test(value)) return `🪡 ${value}`;
|
|
15
|
+
if (/^Tool \(powershell\)/i.test(value)) return `🪓 ${value}`;
|
|
16
|
+
if (/^Tool \(edit\)/i.test(value)) return `📝 ${value}`;
|
|
17
|
+
if (/^Tool \(read_file\)/i.test(value)) return `📄 ${value}`;
|
|
18
|
+
if (/^Tool \((grep_search|file_search)\)/i.test(value)) return `🔎 ${value}`;
|
|
19
|
+
if (/^Tool \(semantic_search\)/i.test(value)) return `🧭 ${value}`;
|
|
20
|
+
if (/^Tool \(vscode_listCodeUsages\)/i.test(value)) return `🔗 ${value}`;
|
|
21
|
+
if (/^Tool \(vscode_renameSymbol\)/i.test(value)) return `✏️ ${value}`;
|
|
22
|
+
if (/^Tool \(list_dir\)/i.test(value)) return `📂 ${value}`;
|
|
23
|
+
if (/^Tool \(create_directory\)/i.test(value)) return `📁 ${value}`;
|
|
24
|
+
if (/^Tool \((delete|remove)\)/i.test(value)) return `🗑️ ${value}`;
|
|
25
|
+
if (/^Tool \(execution_subagent\)/i.test(value)) return `🚀 ${value}`;
|
|
26
|
+
if (/^Tool \(get_errors\)/i.test(value)) return `🚨 ${value}`;
|
|
27
|
+
if (/^Tool \(debug_[^)]+\)/i.test(value)) return `🐞 ${value}`;
|
|
28
|
+
if (/^Tool \(fetch_webpage\)/i.test(value)) return `🌐 ${value}`;
|
|
29
|
+
if (/^Tool \(github_[^)]+\)/i.test(value)) return `🐙 ${value}`;
|
|
30
|
+
if (/^Tool \(run_in_terminal\)/i.test(value)) return `🖥️ ${value}`;
|
|
31
|
+
if (/^Tool \((create_file|write)\)/i.test(value)) return `🆕 ${value}`;
|
|
32
|
+
if (/^Tool \((bash|shell|terminal)\)/i.test(value)) return `🔧 ${value}`;
|
|
33
|
+
if (/^Tool \((sql|sqlite)\)/i.test(value)) return `🗄️ ${value}`;
|
|
34
|
+
if (/^Tool \(/i.test(value)) return `🛠️ ${value}`;
|
|
35
|
+
return `ℹ️ ${value}`;
|
|
36
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Keeps the transcript's reading position through layout changes that
|
|
2
|
+
// reflow the rows: phone rotation, the on-screen keyboard, a desktop window
|
|
3
|
+
// resize, a font-scale change. The browser only preserves scrollTop as a
|
|
4
|
+
// pixel count (and its own scroll anchoring pins the top-most row), so a
|
|
5
|
+
// reader sitting at the end of a portrait transcript lands screens above
|
|
6
|
+
// the end once the wider landscape rows shrink the content. A position is
|
|
7
|
+
// therefore described by content, not pixels: "at the bottom", or "this
|
|
8
|
+
// message is the top-most visible one, with this fraction of it scrolled
|
|
9
|
+
// past" — a fraction, because the rows themselves change height when the
|
|
10
|
+
// width changes, and a pixel offset would hide a whole shrunken row.
|
|
11
|
+
|
|
12
|
+
const BOTTOM_EPSILON_PX = 1;
|
|
13
|
+
|
|
14
|
+
/** Same rule the composer's follow-live logic uses: 8% of the viewport,
|
|
15
|
+
* clamped to 12–48 px. */
|
|
16
|
+
export function resolveNearBottomThresholdPx(clientHeight) {
|
|
17
|
+
const viewportHeight = Number(clientHeight || 0);
|
|
18
|
+
if (!Number.isFinite(viewportHeight) || viewportHeight <= 0) return 0;
|
|
19
|
+
return Math.min(48, Math.max(12, Math.floor(viewportHeight * 0.08)));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function distanceFromBottom(el) {
|
|
23
|
+
return Math.max(0, Number(el.scrollHeight || 0) - Number(el.clientHeight || 0) - Number(el.scrollTop || 0));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function transcriptRows(el) {
|
|
27
|
+
return Array.from(el.querySelectorAll('.msg[data-message-id]'));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Describes where the reader is. `atBottom` uses the near-bottom threshold
|
|
32
|
+
* rather than an exact pixel match: a reader a few pixels short of the end
|
|
33
|
+
* still means "the end", and a rotation must not strand them above it.
|
|
34
|
+
* Returns null for an empty transcript.
|
|
35
|
+
*/
|
|
36
|
+
export function captureTranscriptAnchor(el, { nearBottomThresholdPx = null } = {}) {
|
|
37
|
+
if (!el) return null;
|
|
38
|
+
const rows = transcriptRows(el);
|
|
39
|
+
if (!rows.length) return null;
|
|
40
|
+
const threshold = nearBottomThresholdPx !== null && Number.isFinite(Number(nearBottomThresholdPx))
|
|
41
|
+
? Math.max(0, Number(nearBottomThresholdPx))
|
|
42
|
+
: Math.max(BOTTOM_EPSILON_PX, resolveNearBottomThresholdPx(el.clientHeight));
|
|
43
|
+
const atBottom = distanceFromBottom(el) <= threshold;
|
|
44
|
+
const containerRect = el.getBoundingClientRect();
|
|
45
|
+
let anchorRow = null;
|
|
46
|
+
let offsetTop = 0;
|
|
47
|
+
let rowHeight = 0;
|
|
48
|
+
for (const row of rows) {
|
|
49
|
+
const rect = row.getBoundingClientRect();
|
|
50
|
+
if (rect.bottom <= containerRect.top + 1) continue;
|
|
51
|
+
if (rect.top >= containerRect.bottom) break;
|
|
52
|
+
anchorRow = row;
|
|
53
|
+
offsetTop = rect.top - containerRect.top;
|
|
54
|
+
rowHeight = Math.max(0, rect.bottom - rect.top);
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
const maxScroll = Math.max(1, Number(el.scrollHeight || 0) - Number(el.clientHeight || 0));
|
|
58
|
+
return {
|
|
59
|
+
atBottom,
|
|
60
|
+
messageId: String(anchorRow?.dataset?.messageId || '').trim() || null,
|
|
61
|
+
// Negative while the row's top is scrolled past the edge; scaled to the
|
|
62
|
+
// row's height so the same share of it stays hidden after a reflow.
|
|
63
|
+
offsetFraction: rowHeight > 0 ? offsetTop / rowHeight : 0,
|
|
64
|
+
ratio: Math.min(1, Math.max(0, Number(el.scrollTop || 0) / maxScroll)),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Moves the scroller so the captured position holds in the current layout.
|
|
70
|
+
* Bottom wins over the row anchor; a row that is no longer rendered falls
|
|
71
|
+
* back to the proportional position. Returns true when scrollTop changed.
|
|
72
|
+
*/
|
|
73
|
+
export function restoreTranscriptAnchor(el, anchor) {
|
|
74
|
+
if (!el || !anchor) return false;
|
|
75
|
+
const before = Number(el.scrollTop || 0);
|
|
76
|
+
if (anchor.atBottom) {
|
|
77
|
+
el.scrollTop = Number(el.scrollHeight || 0);
|
|
78
|
+
return Number(el.scrollTop || 0) !== before;
|
|
79
|
+
}
|
|
80
|
+
const messageId = String(anchor.messageId || '').trim();
|
|
81
|
+
const row = messageId
|
|
82
|
+
? transcriptRows(el).find((node) => String(node.dataset?.messageId || '').trim() === messageId)
|
|
83
|
+
: null;
|
|
84
|
+
if (row) {
|
|
85
|
+
const containerRect = el.getBoundingClientRect();
|
|
86
|
+
const rect = row.getBoundingClientRect();
|
|
87
|
+
const targetOffset = Number(anchor.offsetFraction || 0) * Math.max(0, rect.bottom - rect.top);
|
|
88
|
+
const delta = rect.top - containerRect.top - targetOffset;
|
|
89
|
+
if (Math.abs(delta) > 0.5) el.scrollTop = before + delta;
|
|
90
|
+
return Number(el.scrollTop || 0) !== before;
|
|
91
|
+
}
|
|
92
|
+
const maxScroll = Math.max(0, Number(el.scrollHeight || 0) - Number(el.clientHeight || 0));
|
|
93
|
+
el.scrollTop = Math.round(maxScroll * Number(anchor.ratio || 0));
|
|
94
|
+
return Number(el.scrollTop || 0) !== before;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const KEYBOARD_INPUT_TYPES = new Set(['', 'text', 'search', 'email', 'url', 'tel', 'number', 'password']);
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The focused field inside the transcript that has the on-screen keyboard
|
|
101
|
+
* open (a question card's reply box or form input), or null.
|
|
102
|
+
*/
|
|
103
|
+
export function focusedTranscriptTextField(el, active = globalThis.document?.activeElement) {
|
|
104
|
+
if (!el || !active || active === el) return null;
|
|
105
|
+
if (typeof el.contains !== 'function' || !el.contains(active)) return null;
|
|
106
|
+
const tag = String(active.tagName || '').toUpperCase();
|
|
107
|
+
if (tag === 'TEXTAREA') return active;
|
|
108
|
+
if (tag === 'INPUT') {
|
|
109
|
+
return KEYBOARD_INPUT_TYPES.has(String(active.type ?? '').toLowerCase()) ? active : null;
|
|
110
|
+
}
|
|
111
|
+
return active.isContentEditable === true ? active : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Scrolls the transcript just enough to show the field whole, top-aligned
|
|
116
|
+
* when it is taller than the viewport. Arithmetic on scrollTop rather than
|
|
117
|
+
* scrollIntoView, which would scroll the page root too. Returns true when
|
|
118
|
+
* scrollTop changed.
|
|
119
|
+
*/
|
|
120
|
+
export function revealFieldInTranscript(el, field, { marginPx = 8 } = {}) {
|
|
121
|
+
if (!el || typeof field?.getBoundingClientRect !== 'function') return false;
|
|
122
|
+
const before = Number(el.scrollTop || 0);
|
|
123
|
+
const container = el.getBoundingClientRect();
|
|
124
|
+
const rect = field.getBoundingClientRect();
|
|
125
|
+
const viewTop = container.top + marginPx;
|
|
126
|
+
const viewBottom = container.bottom - marginPx;
|
|
127
|
+
let delta = 0;
|
|
128
|
+
if (rect.top < viewTop || rect.bottom - rect.top > viewBottom - viewTop) {
|
|
129
|
+
delta = rect.top - viewTop;
|
|
130
|
+
} else if (rect.bottom > viewBottom) {
|
|
131
|
+
delta = rect.bottom - viewBottom;
|
|
132
|
+
}
|
|
133
|
+
if (Math.abs(delta) <= 0.5) return false;
|
|
134
|
+
el.scrollTop = before + delta;
|
|
135
|
+
return Number(el.scrollTop || 0) !== before;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Tracks the position across resize storms. Scroll events commit the
|
|
140
|
+
* reader's position; a resize re-applies the last committed position and
|
|
141
|
+
* opens a settle window during which scroll events are ignored — they are
|
|
142
|
+
* the browser's own anchoring and our re-pins, not the reader. Rotation on
|
|
143
|
+
* a phone resizes in several steps (browser chrome animates), so the window
|
|
144
|
+
* extends with every step and the position is re-applied once more when it
|
|
145
|
+
* closes.
|
|
146
|
+
*
|
|
147
|
+
* The browser's reveal of a focused field when the keyboard shrinks the
|
|
148
|
+
* viewport is one of those ignored scrolls, so every re-apply reveals the
|
|
149
|
+
* focused transcript field again itself.
|
|
150
|
+
*/
|
|
151
|
+
export function createTranscriptResizeKeeper({
|
|
152
|
+
getElement,
|
|
153
|
+
settleMs = 500,
|
|
154
|
+
setTimer = (fn, ms) => setTimeout(fn, ms),
|
|
155
|
+
clearTimer = (id) => clearTimeout(id),
|
|
156
|
+
capture = captureTranscriptAnchor,
|
|
157
|
+
restore = restoreTranscriptAnchor,
|
|
158
|
+
getFocusedField = focusedTranscriptTextField,
|
|
159
|
+
reveal = revealFieldInTranscript,
|
|
160
|
+
} = {}) {
|
|
161
|
+
let committed = null;
|
|
162
|
+
let settleTimer = null;
|
|
163
|
+
|
|
164
|
+
function element() {
|
|
165
|
+
try { return typeof getElement === 'function' ? getElement() : null; } catch { return null; }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function recordScroll() {
|
|
169
|
+
if (settleTimer !== null) return false;
|
|
170
|
+
const el = element();
|
|
171
|
+
if (!el) return false;
|
|
172
|
+
const next = capture(el);
|
|
173
|
+
if (next) committed = next;
|
|
174
|
+
return Boolean(next);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function apply(el) {
|
|
178
|
+
const restored = committed ? restore(el, committed) : false;
|
|
179
|
+
let field = null;
|
|
180
|
+
try { field = getFocusedField(el); } catch { field = null; }
|
|
181
|
+
const revealed = field ? reveal(el, field) : false;
|
|
182
|
+
return restored || revealed;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function endSettle() {
|
|
186
|
+
settleTimer = null;
|
|
187
|
+
const el = element();
|
|
188
|
+
if (el) apply(el);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function handleResize() {
|
|
192
|
+
const el = element();
|
|
193
|
+
if (!el) return false;
|
|
194
|
+
if (!committed) {
|
|
195
|
+
// Nothing committed yet (no scroll since load): the current position
|
|
196
|
+
// is the best description we have.
|
|
197
|
+
committed = capture(el);
|
|
198
|
+
}
|
|
199
|
+
if (settleTimer !== null) clearTimer(settleTimer);
|
|
200
|
+
settleTimer = setTimer(endSettle, settleMs);
|
|
201
|
+
return apply(el);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
recordScroll,
|
|
206
|
+
handleResize,
|
|
207
|
+
isSettling: () => settleTimer !== null,
|
|
208
|
+
committedAnchor: () => committed,
|
|
209
|
+
};
|
|
210
|
+
}
|