@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,4 +1,4 @@
|
|
|
1
|
-
import { BASE, TOKEN, authHeaders, updateWorkspaceRootHints, applyContextUsageBar, readContextUsageRatio, currentConvId, conversations, setCliOnline, setCloudflaredTunnelState, setActiveRuntimeSessionCount, setRuntimeSessionBindingCount, setContextIndicatorMode, setServerPlatform } from './store.js';
|
|
1
|
+
import { BASE, TOKEN, authHeaders, updateWorkspaceRootHints, applyContextUsageBar, readContextUsageRatio, currentConvId, conversations, setCliOnline, setCloudflaredTunnelState, setActiveRuntimeSessionCount, setRuntimeSessionBindingCount, setContextIndicatorMode, setServerPlatform, setUpdateState } from './store.js';
|
|
2
2
|
|
|
3
3
|
// fetch() has no default timeout. Without one, a request issued while the app is
|
|
4
4
|
// being backgrounded can hang indefinitely on a half-open mobile connection and
|
|
@@ -93,6 +93,7 @@ export async function verifyExistingSession(tokenCandidate = '') {
|
|
|
93
93
|
setActiveRuntimeSessionCount(payload?.activeRuntimeSessionCount);
|
|
94
94
|
setRuntimeSessionBindingCount(payload?.runtimeSessionBindingCount);
|
|
95
95
|
if (payload?.platform) setServerPlatform(payload.platform);
|
|
96
|
+
setUpdateState(payload?.update || null);
|
|
96
97
|
}
|
|
97
98
|
if (response.ok) noteFetchSuccess();
|
|
98
99
|
return {
|
|
@@ -150,6 +151,7 @@ export async function verifyToken(token) {
|
|
|
150
151
|
setActiveRuntimeSessionCount(payload?.activeRuntimeSessionCount);
|
|
151
152
|
setRuntimeSessionBindingCount(payload?.runtimeSessionBindingCount);
|
|
152
153
|
if (payload?.platform) setServerPlatform(payload.platform);
|
|
154
|
+
setUpdateState(payload?.update || null);
|
|
153
155
|
}
|
|
154
156
|
if (response.ok) noteFetchSuccess();
|
|
155
157
|
return {
|
|
@@ -179,6 +181,7 @@ export async function refreshWorkspaceRootHints() {
|
|
|
179
181
|
setActiveRuntimeSessionCount(status?.activeRuntimeSessionCount);
|
|
180
182
|
setRuntimeSessionBindingCount(status?.runtimeSessionBindingCount);
|
|
181
183
|
if (status?.platform) setServerPlatform(status.platform);
|
|
184
|
+
setUpdateState(status?.update || null);
|
|
182
185
|
}
|
|
183
186
|
return status;
|
|
184
187
|
}
|
|
@@ -411,10 +414,13 @@ export async function loadWindowsAutostartSetting() {
|
|
|
411
414
|
return apiFetch('/api/settings/windows-autostart');
|
|
412
415
|
}
|
|
413
416
|
|
|
414
|
-
export async function updateWindowsAutostartSetting(
|
|
417
|
+
export async function updateWindowsAutostartSetting(mode) {
|
|
418
|
+
// Mode string ('off' | 'signin' | 'boot'); a boolean still works for the
|
|
419
|
+
// pre-mode call sites and maps onto signin/off server-side.
|
|
420
|
+
const body = typeof mode === 'boolean' ? { enabled: mode } : { mode: String(mode) };
|
|
415
421
|
return apiFetch('/api/settings/windows-autostart', {
|
|
416
422
|
method: 'POST',
|
|
417
|
-
body: JSON.stringify(
|
|
423
|
+
body: JSON.stringify(body),
|
|
418
424
|
});
|
|
419
425
|
}
|
|
420
426
|
|
|
@@ -440,6 +446,61 @@ export async function updateBackgroundTaskTimeoutSetting(timeoutMinutes) {
|
|
|
440
446
|
});
|
|
441
447
|
}
|
|
442
448
|
|
|
449
|
+
export async function loadPwaAppNameSetting() {
|
|
450
|
+
return apiFetch('/api/settings/pwa-app-name');
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export async function updatePwaAppNameSetting(appName) {
|
|
454
|
+
return apiFetch('/api/settings/pwa-app-name', {
|
|
455
|
+
method: 'POST',
|
|
456
|
+
body: JSON.stringify({ appName: String(appName ?? '') }),
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export async function loadUpdateState() {
|
|
461
|
+
return apiFetch('/api/update/state');
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export async function setUpdateAutoCheck(enabled) {
|
|
465
|
+
return apiFetch('/api/update/auto-check', {
|
|
466
|
+
method: 'POST',
|
|
467
|
+
body: JSON.stringify({ enabled: enabled === true }),
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export async function checkForUpdateNow() {
|
|
472
|
+
return apiFetch('/api/update/check', { method: 'POST' });
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export async function dismissUpdate(body) {
|
|
476
|
+
return apiFetch('/api/update/dismiss', {
|
|
477
|
+
method: 'POST',
|
|
478
|
+
body: JSON.stringify(body || {}),
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export async function applyUpdate(version) {
|
|
483
|
+
return apiFetch('/api/update/apply', {
|
|
484
|
+
method: 'POST',
|
|
485
|
+
body: JSON.stringify({ version: String(version || '') }),
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export async function cancelUpdate() {
|
|
490
|
+
return apiFetch('/api/update/cancel', { method: 'POST' });
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export async function loadFeatureFlagSettings() {
|
|
494
|
+
return apiFetch('/api/settings/features');
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export async function updateFeatureFlagSetting(name, enabled) {
|
|
498
|
+
return apiFetch('/api/settings/features', {
|
|
499
|
+
method: 'POST',
|
|
500
|
+
body: JSON.stringify({ name: String(name || ''), enabled: enabled === true }),
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
443
504
|
export async function launchSessionWorker(sdkSessionId) {
|
|
444
505
|
const sessionId = String(sdkSessionId || '').trim();
|
|
445
506
|
if (!sessionId) return null;
|
|
@@ -594,10 +655,30 @@ export async function searchMessages(options = {}) {
|
|
|
594
655
|
return apiFetch(`/api/search/messages?${params.toString()}`);
|
|
595
656
|
}
|
|
596
657
|
|
|
658
|
+
// Raw fetch instead of apiFetch: a refused delete (the conversation is still
|
|
659
|
+
// working, or its session would not stop) must surface the server's reason.
|
|
660
|
+
// The long timeout covers stopping the session and deleting its CLI session.
|
|
597
661
|
export async function deleteConversation(id) {
|
|
598
662
|
const convId = String(id || '').trim();
|
|
599
|
-
if (!convId) return null;
|
|
600
|
-
|
|
663
|
+
if (!convId || !areNetworkRequestsEnabled()) return null;
|
|
664
|
+
const url = `/api/conversation/${encodeURIComponent(convId)}`;
|
|
665
|
+
try {
|
|
666
|
+
const response = await fetch(`${BASE}${url}`, {
|
|
667
|
+
signal: requestTimeoutSignal(LONG_REQUEST_TIMEOUT_MS),
|
|
668
|
+
method: 'DELETE',
|
|
669
|
+
headers: {
|
|
670
|
+
'Content-Type': 'application/json',
|
|
671
|
+
...authHeaders(),
|
|
672
|
+
},
|
|
673
|
+
});
|
|
674
|
+
const payload = await response.json().catch(() => null);
|
|
675
|
+
if (!response.ok) return { ...(payload || {}), ok: false, status: response.status };
|
|
676
|
+
noteFetchSuccess();
|
|
677
|
+
return payload || { ok: true };
|
|
678
|
+
} catch (error) {
|
|
679
|
+
noteFetchFailure(url, error);
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
601
682
|
}
|
|
602
683
|
|
|
603
684
|
export async function updateMessageShareVisibility(conversationId, messageId, hiddenFromShares) {
|
|
@@ -10,11 +10,23 @@ import { loadRelayQuestions as loadRelayQuestionsApi, answerRelayQuestion, answe
|
|
|
10
10
|
import { enqueueOutboxRequest, registerOutboxSync } from './sync-outbox.mjs';
|
|
11
11
|
import { renderLinkedPlainText } from './router.js';
|
|
12
12
|
import { schemaFieldsFromQuestion } from './question-schema-view.mjs';
|
|
13
|
+
import { composeMultiSelectAnswer, isMultiSelectQuestion, offersMultiSelectToggle } from './question-multi-select.mjs';
|
|
13
14
|
import { isChatInteractionHeld } from './selection-guard.mjs';
|
|
15
|
+
import { questionCardHeadLabel } from './conversation-provider-indicator.mjs';
|
|
14
16
|
|
|
15
17
|
let relayQuestionRenderHash = '';
|
|
16
18
|
let renderedRelayQuestionIds = new Set();
|
|
17
19
|
const relayQuestionStructuredDrafts = new Map();
|
|
20
|
+
// Ticked choices of a multi-select card (question id → Set of labels), kept
|
|
21
|
+
// across re-renders until the card is answered.
|
|
22
|
+
const relayQuestionMultiDrafts = new Map();
|
|
23
|
+
// The "Select several" switch position per card (question id → boolean) on
|
|
24
|
+
// cards that offer it (Copilot); absent = the provider's flag decides.
|
|
25
|
+
const relayQuestionMultiMode = new Map();
|
|
26
|
+
|
|
27
|
+
function multiModeFor(question) {
|
|
28
|
+
return question ? relayQuestionMultiMode.get(question.id) : undefined;
|
|
29
|
+
}
|
|
18
30
|
const RELAY_QUESTION_AUTO_SCROLL_THRESHOLD_PX = 80;
|
|
19
31
|
|
|
20
32
|
function distanceFromBottom(el) {
|
|
@@ -75,6 +87,8 @@ export function upsertRelayQuestion(question) {
|
|
|
75
87
|
relayQuestions.set(question.id, question);
|
|
76
88
|
updatePendingQuestionBanner();
|
|
77
89
|
window.renderConvList?.();
|
|
90
|
+
// A question opening or settling flips the composer's steering hold.
|
|
91
|
+
window.syncComposerButtonState?.();
|
|
78
92
|
renderRelayQuestions();
|
|
79
93
|
}
|
|
80
94
|
|
|
@@ -98,10 +112,19 @@ export async function loadRelayQuestions(conversationId) {
|
|
|
98
112
|
relayQuestionStructuredDrafts.delete(questionId);
|
|
99
113
|
}
|
|
100
114
|
}
|
|
115
|
+
// Multi-select ticks and the "Select several" switch position die with the
|
|
116
|
+
// card, whoever settled it (this device, another one, a timeout).
|
|
117
|
+
for (const draftMap of [relayQuestionMultiDrafts, relayQuestionMultiMode]) {
|
|
118
|
+
for (const questionId of draftMap.keys()) {
|
|
119
|
+
const question = next.get(questionId);
|
|
120
|
+
if (!question || question.status !== 'pending') draftMap.delete(questionId);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
101
123
|
relayQuestions.clear();
|
|
102
124
|
for (const [id, q] of next.entries()) relayQuestions.set(id, q);
|
|
103
125
|
updatePendingQuestionBanner();
|
|
104
126
|
window.renderConvList?.();
|
|
127
|
+
window.syncComposerButtonState?.();
|
|
105
128
|
|
|
106
129
|
if (!conversationId && pending.length) {
|
|
107
130
|
const latest = pending
|
|
@@ -212,6 +235,7 @@ export function renderRelayQuestions() {
|
|
|
212
235
|
answeredAt: q.answeredAt || '',
|
|
213
236
|
createdAt: q.createdAt || '',
|
|
214
237
|
choices: Array.isArray(q.choices) ? q.choices : [],
|
|
238
|
+
multiSelect: isMultiSelectQuestion(q, multiModeFor(q)),
|
|
215
239
|
schema: q.requestSchema || null,
|
|
216
240
|
}))
|
|
217
241
|
);
|
|
@@ -249,24 +273,41 @@ export function renderRelayQuestions() {
|
|
|
249
273
|
interactiveHtml = renderMultiFieldForm(question);
|
|
250
274
|
} else {
|
|
251
275
|
const choices = Array.isArray(question.choices) ? question.choices : [];
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
276
|
+
// A multi-select card (the provider said several may be picked) offers
|
|
277
|
+
// checkmarks and ONE Reply; ticking never answers by itself.
|
|
278
|
+
const multiSelect = isMultiSelectQuestion(question, multiModeFor(question));
|
|
279
|
+
const ticked = relayQuestionMultiDrafts.get(question.id) || new Set();
|
|
280
|
+
// Copilot cards: a switch between one-click answers and checkmarks,
|
|
281
|
+
// pre-set from the model's flag or the question's wording.
|
|
282
|
+
const toggleHtml = question.status === 'pending' && offersMultiSelectToggle(question)
|
|
283
|
+
? `<label class="relay-question-multi-toggle"><input type="checkbox" data-relay-focus-key="multi-toggle"${multiSelect ? ' checked' : ''} onchange="setRelayQuestionMultiMode('${question.id}', this.checked)"> Select several</label>`
|
|
260
284
|
: '';
|
|
285
|
+
let choiceHtml = '';
|
|
286
|
+
if (choices.length && question.status === 'pending' && multiSelect) {
|
|
287
|
+
choiceHtml = `<div class="relay-question-choices relay-question-multi" id="relay-question-multi-${question.id}">${
|
|
288
|
+
choices.map((choice) => `<label class="relay-question-choice relay-question-choice-check"><input type="checkbox" data-choice-value="${escHtml(choice)}" data-relay-focus-key="multi-${escHtml(choice)}"${ticked.has(choice) ? ' checked' : ''} onchange="onRelayQuestionMultiSelectChange('${question.id}', this.dataset.choiceValue, this.checked)"> ${escHtml(choice)}</label>`).join('')
|
|
289
|
+
}</div>`;
|
|
290
|
+
} else if (choices.length && question.status === 'pending') {
|
|
291
|
+
choiceHtml = `<div class="relay-question-choices">${
|
|
292
|
+
choices.map((choice) => `<button class="relay-question-choice" data-choice="${escHtml(choice)}" data-relay-focus-key="choice-${escHtml(choice)}" onclick="submitRelayQuestionChoice('${question.id}', this.dataset.choice)">${escHtml(choice)}</button>`).join('')
|
|
293
|
+
}</div>`;
|
|
294
|
+
} else if (choices.length) {
|
|
295
|
+
choiceHtml = `<div class="relay-question-choices">${
|
|
296
|
+
choices.map((choice) => `<button class="relay-question-choice" disabled>${escHtml(choice)}</button>`).join('')
|
|
297
|
+
}</div>`;
|
|
298
|
+
}
|
|
261
299
|
const showReplyControls = question.status === 'pending';
|
|
262
300
|
const draftText = String(relayQuestionDrafts.get(question.id) || '');
|
|
301
|
+
const replyPlaceholder = multiSelect
|
|
302
|
+
? 'Add another answer or a note (optional)… (Ctrl/Cmd+Enter to send)'
|
|
303
|
+
: 'Type a reply… (Ctrl/Cmd+Enter to send)';
|
|
263
304
|
const replyHtml = showReplyControls
|
|
264
305
|
? `<div class="relay-question-reply">
|
|
265
|
-
<textarea id="relay-question-input-${question.id}" placeholder="
|
|
266
|
-
<button class="relay-question-submit" onclick="submitRelayQuestionAnswer('${question.id}')"
|
|
306
|
+
<textarea id="relay-question-input-${question.id}" placeholder="${replyPlaceholder}" onkeydown="handleRelayQuestionKey(event, '${question.id}')" oninput="onRelayQuestionDraftInput('${question.id}', this.value)">${escHtml(draftText)}</textarea>
|
|
307
|
+
<button class="relay-question-submit" onclick="${multiSelect ? 'submitRelayQuestionMultiSelect' : 'submitRelayQuestionAnswer'}('${question.id}')">${multiSelect ? 'Reply with selection' : 'Reply'}</button>
|
|
267
308
|
</div>`
|
|
268
309
|
: '';
|
|
269
|
-
interactiveHtml = `${choiceHtml}${replyHtml}`;
|
|
310
|
+
interactiveHtml = `${choiceHtml}${toggleHtml}${replyHtml}`;
|
|
270
311
|
}
|
|
271
312
|
|
|
272
313
|
const answeredHtml = question.status === 'answered' && question.answer
|
|
@@ -278,7 +319,7 @@ export function renderRelayQuestions() {
|
|
|
278
319
|
|
|
279
320
|
wrapper.innerHTML = `
|
|
280
321
|
<div class="relay-question-card${question.status === 'timed_out' ? ' relay-question-timed-out' : ''}">
|
|
281
|
-
<div class="relay-question-head"
|
|
322
|
+
<div class="relay-question-head">${questionCardHeadLabel(conversations[question.conversationId])}${modeTag} · ${fmtDate(question.createdAt)}</div>
|
|
282
323
|
<div class="relay-question-body">${renderLinkedPlainText(question.prompt || '')}</div>
|
|
283
324
|
${contextHtml}
|
|
284
325
|
${answeredHtml}
|
|
@@ -467,6 +508,7 @@ export async function submitRelayStructuredAnswer(questionId) {
|
|
|
467
508
|
if (r.question) relayQuestions.set(questionId, r.question);
|
|
468
509
|
updatePendingQuestionBanner();
|
|
469
510
|
window.renderConvList?.();
|
|
511
|
+
window.syncComposerButtonState?.();
|
|
470
512
|
renderRelayQuestions();
|
|
471
513
|
window.showTransientRelayNotice?.(`✅ Answer received · Agent continuing…`, 7000);
|
|
472
514
|
} catch (e) {
|
|
@@ -480,6 +522,44 @@ export async function submitRelayQuestionChoice(questionId, choice) {
|
|
|
480
522
|
await submitRelayQuestionAnswer(questionId, choice);
|
|
481
523
|
}
|
|
482
524
|
|
|
525
|
+
/** Ticked labels of a multi-select card, in the card's own order. */
|
|
526
|
+
function tickedMultiSelectLabels(questionId) {
|
|
527
|
+
const container = document.getElementById(`relay-question-multi-${questionId}`);
|
|
528
|
+
if (!container) return [...(relayQuestionMultiDrafts.get(questionId) || [])];
|
|
529
|
+
return Array.from(container.querySelectorAll('input[type="checkbox"]:checked'))
|
|
530
|
+
.map((el) => String(el.getAttribute('data-choice-value') || ''));
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* The "Select several" switch: flip the card between one-click answers and
|
|
535
|
+
* checkmarks. Ticks made so far are kept, and the card re-renders in place.
|
|
536
|
+
*/
|
|
537
|
+
export function setRelayQuestionMultiMode(questionId, enabled) {
|
|
538
|
+
const id = String(questionId || '');
|
|
539
|
+
if (!id) return;
|
|
540
|
+
relayQuestionMultiMode.set(id, enabled === true);
|
|
541
|
+
renderRelayQuestions();
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export function onRelayQuestionMultiSelectChange(questionId, label, checked) {
|
|
545
|
+
const id = String(questionId || '');
|
|
546
|
+
const set = relayQuestionMultiDrafts.get(id) || new Set();
|
|
547
|
+
if (checked) set.add(String(label || ''));
|
|
548
|
+
else set.delete(String(label || ''));
|
|
549
|
+
relayQuestionMultiDrafts.set(id, set);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Answer a multi-select card: every ticked choice, plus whatever was typed as
|
|
554
|
+
* an extra answer, joined the way the provider's own UI would ("A, C").
|
|
555
|
+
*/
|
|
556
|
+
export async function submitRelayQuestionMultiSelect(questionId) {
|
|
557
|
+
const input = document.getElementById(`relay-question-input-${questionId}`);
|
|
558
|
+
const answer = composeMultiSelectAnswer(tickedMultiSelectLabels(questionId), input?.value || '');
|
|
559
|
+
if (!answer) return;
|
|
560
|
+
await submitRelayQuestionAnswer(questionId, answer);
|
|
561
|
+
}
|
|
562
|
+
|
|
483
563
|
// Offline fallback: park the answer in the durable outbox so Background Sync
|
|
484
564
|
// (or the next page load) delivers it. A replay against an already-answered
|
|
485
565
|
// or expired question gets a 4xx and is dropped, so this cannot double-answer.
|
|
@@ -499,7 +579,13 @@ async function enqueueRelayAnswerForSync(questionId, body) {
|
|
|
499
579
|
|
|
500
580
|
export async function submitRelayQuestionAnswer(questionId, presetAnswer = null) {
|
|
501
581
|
const input = document.getElementById(`relay-question-input-${questionId}`);
|
|
502
|
-
|
|
582
|
+
// Ctrl/Cmd+Enter in a multi-select card's text field sends the ticked
|
|
583
|
+
// choices along with the typed text, exactly like its Reply button.
|
|
584
|
+
const pendingQuestion = relayQuestions.get(questionId) || null;
|
|
585
|
+
const composed = presetAnswer == null && isMultiSelectQuestion(pendingQuestion, multiModeFor(pendingQuestion))
|
|
586
|
+
? composeMultiSelectAnswer(tickedMultiSelectLabels(questionId), input?.value || '')
|
|
587
|
+
: null;
|
|
588
|
+
const answer = String(presetAnswer != null ? presetAnswer : (composed ?? (input?.value || ''))).trim();
|
|
503
589
|
if (!answer) return;
|
|
504
590
|
|
|
505
591
|
const card = document.querySelector(`.relay-question-container[data-question-id="${questionId}"]`);
|
|
@@ -522,9 +608,12 @@ export async function submitRelayQuestionAnswer(questionId, presetAnswer = null)
|
|
|
522
608
|
}
|
|
523
609
|
relayQuestionDrafts.delete(questionId);
|
|
524
610
|
relayQuestionStructuredDrafts.delete(questionId);
|
|
611
|
+
relayQuestionMultiDrafts.delete(questionId);
|
|
612
|
+
relayQuestionMultiMode.delete(questionId);
|
|
525
613
|
relayQuestions.set(questionId, r.question);
|
|
526
614
|
updatePendingQuestionBanner();
|
|
527
615
|
window.renderConvList?.();
|
|
616
|
+
window.syncComposerButtonState?.();
|
|
528
617
|
renderRelayQuestions();
|
|
529
618
|
window.showTransientRelayNotice?.(`✅ Answer received: ${answer} · Agent continuing…`, 7000);
|
|
530
619
|
} catch (e) {
|
|
@@ -12,7 +12,9 @@ import {
|
|
|
12
12
|
workspaceRootPath,
|
|
13
13
|
showTransientRelayNotice,
|
|
14
14
|
getConversationCurrentWorkspaceRootPath,
|
|
15
|
+
IS_SHARED_VIEW,
|
|
15
16
|
} from './store.js';
|
|
17
|
+
import { openAnnotateEditor } from './annotate-editor.js';
|
|
16
18
|
import {
|
|
17
19
|
uploadAttachment,
|
|
18
20
|
loadWorkspaceFilePreview,
|
|
@@ -219,16 +221,20 @@ export function renderAttachmentPreview() {
|
|
|
219
221
|
if (!selectedAttachments.length) {
|
|
220
222
|
el.innerHTML = '';
|
|
221
223
|
el.classList.remove('visible');
|
|
222
|
-
window.
|
|
224
|
+
window.syncComposerButtonState?.();
|
|
223
225
|
syncComposerAttachmentWarning();
|
|
224
226
|
return;
|
|
225
227
|
}
|
|
226
228
|
|
|
227
229
|
el.innerHTML = selectedAttachments.map((att, idx) => {
|
|
228
230
|
const state = String(att?.uploadState || 'uploaded');
|
|
231
|
+
const annotatable = att.isImage && att.previewUrl && isAnnotatableImageType(att.type);
|
|
229
232
|
const thumb = att.isImage && att.previewUrl
|
|
230
|
-
? `<img src="${escHtml(att.previewUrl)}" alt="${escHtml(att.name)}">`
|
|
233
|
+
? `<img src="${escHtml(att.previewUrl)}" alt="${escHtml(att.name)}"${annotatable ? ` onclick="openAnnotateEditorForPending(${idx})" title="Annotate ${escHtml(att.name)}" style="cursor: pointer;"` : ''}>`
|
|
231
234
|
: `<div class="attachment-preview-meta" style="height:88px;display:flex;align-items:center;justify-content:center">📎</div>`;
|
|
235
|
+
const annotateBadge = annotatable
|
|
236
|
+
? `<span class="attachment-preview-annotate" aria-hidden="true">🖍️</span>`
|
|
237
|
+
: '';
|
|
232
238
|
const overlay = state === 'pending' || state === 'uploading'
|
|
233
239
|
? `<div class="attachment-preview-status attachment-preview-status-uploading" role="status" aria-label="Uploading ${escHtml(att.name)}"><span class="attachment-preview-spinner"></span></div>`
|
|
234
240
|
: state === 'error'
|
|
@@ -238,13 +244,14 @@ export function renderAttachmentPreview() {
|
|
|
238
244
|
<div class="attachment-preview-item attachment-preview-${escHtml(state)}">
|
|
239
245
|
<button class="attachment-preview-remove" onclick="removeAttachment(${idx})" title="Remove">×</button>
|
|
240
246
|
${thumb}
|
|
247
|
+
${annotateBadge}
|
|
241
248
|
${overlay}
|
|
242
249
|
<div class="attachment-preview-meta">${escHtml(att.name)}${att.size ? ` · ${formatBytes(att.size)}` : ''}</div>
|
|
243
250
|
</div>
|
|
244
251
|
`;
|
|
245
252
|
}).join('');
|
|
246
253
|
el.classList.add('visible');
|
|
247
|
-
window.
|
|
254
|
+
window.syncComposerButtonState?.();
|
|
248
255
|
syncComposerAttachmentWarning();
|
|
249
256
|
}
|
|
250
257
|
|
|
@@ -286,6 +293,41 @@ export function clearAttachments() {
|
|
|
286
293
|
renderAttachmentPreview();
|
|
287
294
|
}
|
|
288
295
|
|
|
296
|
+
function composerAttachmentSha(attachment) {
|
|
297
|
+
return String(attachment?.sha256 || attachment?.uploaded?.sha256 || '').trim().toLowerCase();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Removes the composer entries a send took with it and keeps anything added
|
|
302
|
+
* after that snapshot, uploading or not. Matches by identity first, then by
|
|
303
|
+
* content hash — switching away and back mid-send rebuilds the composer from
|
|
304
|
+
* the draft cache as new objects — and removes one entry per sent attachment,
|
|
305
|
+
* so a genuine second copy survives. Persisting what remains is the caller's
|
|
306
|
+
* job.
|
|
307
|
+
*/
|
|
308
|
+
export function removeComposerAttachments(sent = []) {
|
|
309
|
+
const pending = (Array.isArray(sent) ? sent : []).filter(Boolean);
|
|
310
|
+
if (!pending.length) return;
|
|
311
|
+
const removed = new Set();
|
|
312
|
+
const unmatched = [];
|
|
313
|
+
for (const att of pending) {
|
|
314
|
+
if (selectedAttachments.includes(att) && !removed.has(att)) removed.add(att);
|
|
315
|
+
else unmatched.push(att);
|
|
316
|
+
}
|
|
317
|
+
for (const att of unmatched) {
|
|
318
|
+
const sha = composerAttachmentSha(att);
|
|
319
|
+
if (!sha) continue;
|
|
320
|
+
const match = selectedAttachments.find((candidate) => !removed.has(candidate) && composerAttachmentSha(candidate) === sha);
|
|
321
|
+
if (match) removed.add(match);
|
|
322
|
+
}
|
|
323
|
+
if (!removed.size) return;
|
|
324
|
+
const kept = selectedAttachments.filter((att) => !removed.has(att));
|
|
325
|
+
for (const att of removed) releaseAttachmentPreviewUrl(att);
|
|
326
|
+
selectedAttachments.length = 0;
|
|
327
|
+
selectedAttachments.push(...kept);
|
|
328
|
+
renderAttachmentPreview();
|
|
329
|
+
}
|
|
330
|
+
|
|
289
331
|
/**
|
|
290
332
|
* Replaces composer attachments wholesale, used when hydrating a conversation's
|
|
291
333
|
* cached draft attachments. Object URLs from the outgoing set are released.
|
|
@@ -374,6 +416,86 @@ export function retryAttachmentUpload(idx) {
|
|
|
374
416
|
void startAttachmentUpload(attachment);
|
|
375
417
|
}
|
|
376
418
|
|
|
419
|
+
// ─── Marker annotations ───────────────────────────────────────────────────────
|
|
420
|
+
|
|
421
|
+
/** Animated GIFs would silently flatten to one frame, SVGs can't be served back. */
|
|
422
|
+
function isAnnotatableImageType(type) {
|
|
423
|
+
const normalized = String(type || '').trim().toLowerCase();
|
|
424
|
+
return normalized.startsWith('image/')
|
|
425
|
+
&& normalized !== 'image/gif'
|
|
426
|
+
&& normalized !== 'image/svg+xml';
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function fetchImageBlob(url) {
|
|
430
|
+
// Upload/generated-image content URLs are cookie-authenticated (the same way
|
|
431
|
+
// the <img> tags load them), so a plain same-origin fetch works.
|
|
432
|
+
const response = await fetch(url, { credentials: 'same-origin' });
|
|
433
|
+
if (!response.ok) throw new Error('Could not load the image');
|
|
434
|
+
return response.blob();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Swaps a pending composer attachment for its annotated copy. Keyed on the
|
|
439
|
+
* attachment object, not its index — the list can shift while the editor is
|
|
440
|
+
* open. Returns false (editor stays open) when the slot vanished.
|
|
441
|
+
*/
|
|
442
|
+
async function replacePendingAttachment(target, file) {
|
|
443
|
+
const prepared = await prepareIncomingFile(file, { source: 'annotate', now: new Date(), index: 0 });
|
|
444
|
+
// Resolve the slot AFTER the await: prepareIncomingFile can spend real time
|
|
445
|
+
// downscaling, and a draft re-hydration or conversation switch in that window
|
|
446
|
+
// must not make the splice land on an unrelated attachment.
|
|
447
|
+
const idx = selectedAttachments.indexOf(target);
|
|
448
|
+
if (idx === -1) {
|
|
449
|
+
releaseAttachmentPreviewUrl(prepared);
|
|
450
|
+
showTransientRelayNotice('That attachment is no longer in the composer.');
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
const [replaced] = selectedAttachments.splice(idx, 1, prepared);
|
|
454
|
+
releaseAttachmentPreviewUrl(replaced);
|
|
455
|
+
renderAttachmentPreview();
|
|
456
|
+
void startAttachmentUpload(prepared);
|
|
457
|
+
window.persistComposerAttachments?.();
|
|
458
|
+
return true;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export function openAnnotateEditorForPending(idx) {
|
|
462
|
+
const attachment = selectedAttachments[idx];
|
|
463
|
+
if (!attachment?.isImage || !isAnnotatableImageType(attachment.type)) return;
|
|
464
|
+
openAnnotateEditor({
|
|
465
|
+
name: attachment.name,
|
|
466
|
+
getBlob: async () => (attachment.file ? attachment.file : fetchImageBlob(attachment.previewUrl)),
|
|
467
|
+
onAccept: (file) => replacePendingAttachment(attachment, file),
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** True when the currently previewed lightbox item can host the 🖍️ button. */
|
|
472
|
+
export function filePreviewSupportsAnnotation() {
|
|
473
|
+
if (IS_SHARED_VIEW) return false;
|
|
474
|
+
const payload = filePreviewState?.payload;
|
|
475
|
+
return payload?.kind === 'image'
|
|
476
|
+
&& !!String(payload.rawUrl || '')
|
|
477
|
+
&& isAnnotatableImageType(payload.contentType);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export function openAnnotateEditorForPreview() {
|
|
481
|
+
if (!filePreviewSupportsAnnotation()) return;
|
|
482
|
+
const url = String(filePreviewState.payload.rawUrl || '');
|
|
483
|
+
const name = String(filePreviewState.payload.name || 'screenshot');
|
|
484
|
+
closeFilePreview();
|
|
485
|
+
openAnnotateEditor({
|
|
486
|
+
name,
|
|
487
|
+
getBlob: () => fetchImageBlob(url),
|
|
488
|
+
onAccept: async (file) => {
|
|
489
|
+
// The sent message is never mutated: the annotated copy joins the
|
|
490
|
+
// composer for the next message. ingestFiles enforces the attachment
|
|
491
|
+
// cap and shows its own over-cap notice; an empty result keeps the
|
|
492
|
+
// editor open so a slot can be freed and accept retried.
|
|
493
|
+
const added = await ingestFiles([file], { source: 'annotate' });
|
|
494
|
+
return added.length > 0;
|
|
495
|
+
},
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
377
499
|
/**
|
|
378
500
|
* Single ingestion path shared by the file picker, paste and drag-and-drop.
|
|
379
501
|
* Files are normalized, optionally downscaled, capped, and uploaded immediately
|
|
@@ -480,6 +602,53 @@ function updateFilePreviewUiState() {
|
|
|
480
602
|
const backBtn = document.getElementById('file-preview-back');
|
|
481
603
|
backBtn.hidden = filePreviewHistory.length === 0;
|
|
482
604
|
backBtn.disabled = filePreviewHistory.length === 0;
|
|
605
|
+
const annotateBtn = document.getElementById('file-preview-annotate-btn');
|
|
606
|
+
if (annotateBtn) annotateBtn.hidden = !filePreviewSupportsAnnotation();
|
|
607
|
+
const copyBtn = document.getElementById('file-preview-copy-btn');
|
|
608
|
+
if (copyBtn) copyBtn.hidden = payload?.kind !== 'image';
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Copy the previewed image to the clipboard as PNG. Re-encoding through a
|
|
613
|
+
* canvas is deliberate: the source may be JPEG/WebP and the async Clipboard
|
|
614
|
+
* API only reliably accepts image/png. Same-origin sources (the drive and
|
|
615
|
+
* workspace file routes, data: URIs) keep the canvas clean; a cross-origin
|
|
616
|
+
* image taints it and lands in the failure note instead of throwing.
|
|
617
|
+
*/
|
|
618
|
+
export async function copyFilePreviewImage() {
|
|
619
|
+
const btn = document.getElementById('file-preview-copy-btn');
|
|
620
|
+
const img = document.querySelector('#file-preview-body .file-preview-image img');
|
|
621
|
+
const flash = (text) => {
|
|
622
|
+
if (!btn) return;
|
|
623
|
+
btn.textContent = text;
|
|
624
|
+
setTimeout(() => { btn.textContent = 'Copy'; }, 1600);
|
|
625
|
+
};
|
|
626
|
+
if (!img?.src) return;
|
|
627
|
+
try {
|
|
628
|
+
if (!navigator.clipboard?.write || typeof ClipboardItem === 'undefined') {
|
|
629
|
+
throw new Error('Clipboard image copy is not supported in this browser');
|
|
630
|
+
}
|
|
631
|
+
const blob = await new Promise((resolve, reject) => {
|
|
632
|
+
const image = new Image();
|
|
633
|
+
image.onload = () => {
|
|
634
|
+
try {
|
|
635
|
+
const canvas = document.createElement('canvas');
|
|
636
|
+
canvas.width = image.naturalWidth || image.width;
|
|
637
|
+
canvas.height = image.naturalHeight || image.height;
|
|
638
|
+
canvas.getContext('2d').drawImage(image, 0, 0);
|
|
639
|
+
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('Could not encode image'))), 'image/png');
|
|
640
|
+
} catch (error) {
|
|
641
|
+
reject(error);
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
image.onerror = () => reject(new Error('Could not load image'));
|
|
645
|
+
image.src = img.src;
|
|
646
|
+
});
|
|
647
|
+
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
|
|
648
|
+
flash('Copied ✓');
|
|
649
|
+
} catch {
|
|
650
|
+
flash('Copy failed');
|
|
651
|
+
}
|
|
483
652
|
}
|
|
484
653
|
|
|
485
654
|
function snapshotFilePreviewState() {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Mirrors shared/background-task-timeout.mjs, which the browser cannot import
|
|
2
|
+
// (only server/public is served). shared/background-task-timeout.test.mjs
|
|
3
|
+
// asserts the two stay identical.
|
|
4
|
+
//
|
|
5
|
+
// The settings slider shows this until the relay's own value loads, and keeps
|
|
6
|
+
// showing it if that request fails, so it must be the default the relay
|
|
7
|
+
// enforces: 4 hours. 0 means no limit only when chosen explicitly.
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES = 240;
|