@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
|
@@ -43,6 +43,8 @@ function insertEmojiAtCaret(emoji) {
|
|
|
43
43
|
input.selectionEnd = nextCaret;
|
|
44
44
|
autoResize(input);
|
|
45
45
|
input.focus();
|
|
46
|
+
// Runs the composer's input pipeline, which saves the draft.
|
|
47
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
function handleEmojiChoice(emoji) {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { captureTranscriptAnchor, restoreTranscriptAnchor } from './transcript-viewport-anchor.mjs';
|
|
2
|
+
|
|
1
3
|
const FONT_SCALE_STORAGE_KEY = 'copilot_font_scale';
|
|
2
4
|
const FONT_SCALE_MIN = 0.5;
|
|
3
5
|
const FONT_SCALE_MAX = 1.5;
|
|
@@ -53,41 +55,6 @@ function normalizeFontScaleSelectValue(raw = '') {
|
|
|
53
55
|
return clampFontScale(numeric);
|
|
54
56
|
}
|
|
55
57
|
|
|
56
|
-
function getMessageViewportAnchor() {
|
|
57
|
-
const container = document.getElementById('messages');
|
|
58
|
-
if (!container) return null;
|
|
59
|
-
const containerRect = container.getBoundingClientRect();
|
|
60
|
-
const messages = Array.from(container.querySelectorAll('.msg[data-message-id]'));
|
|
61
|
-
for (const message of messages) {
|
|
62
|
-
const rect = message.getBoundingClientRect();
|
|
63
|
-
if (rect.bottom <= containerRect.top || rect.top >= containerRect.bottom) continue;
|
|
64
|
-
const messageId = String(message.dataset.messageId || '').trim();
|
|
65
|
-
if (!messageId) continue;
|
|
66
|
-
return {
|
|
67
|
-
messageId,
|
|
68
|
-
offsetTop: rect.top - containerRect.top,
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
return null;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function restoreMessageViewportAnchor(anchor) {
|
|
75
|
-
if (!anchor?.messageId) return;
|
|
76
|
-
const container = document.getElementById('messages');
|
|
77
|
-
if (!container) return;
|
|
78
|
-
const messageId = String(anchor.messageId || '').trim();
|
|
79
|
-
if (!messageId) return;
|
|
80
|
-
const message = Array.from(container.querySelectorAll('.msg[data-message-id]'))
|
|
81
|
-
.find((node) => String(node?.dataset?.messageId || '').trim() === messageId);
|
|
82
|
-
if (!message) return;
|
|
83
|
-
const containerRect = container.getBoundingClientRect();
|
|
84
|
-
const rect = message.getBoundingClientRect();
|
|
85
|
-
const delta = rect.top - containerRect.top - Number(anchor.offsetTop || 0);
|
|
86
|
-
if (Math.abs(delta) > 0.5) {
|
|
87
|
-
container.scrollTop += delta;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
58
|
export function syncFontScaleSelect() {
|
|
92
59
|
const select = document.getElementById('font-scale-select');
|
|
93
60
|
if (!select) return;
|
|
@@ -117,7 +84,7 @@ export function setFontScale(nextScale, { persist = true, preserveMessageAnchor
|
|
|
117
84
|
syncFontScaleSelect();
|
|
118
85
|
return normalized;
|
|
119
86
|
}
|
|
120
|
-
const anchor = preserveMessageAnchor ?
|
|
87
|
+
const anchor = preserveMessageAnchor ? captureTranscriptAnchor(document.getElementById('messages')) : null;
|
|
121
88
|
fontScaleValue = normalized;
|
|
122
89
|
document.documentElement.style.setProperty('--font-scale', normalized.toFixed(4));
|
|
123
90
|
document.documentElement.style.setProperty('--font-scale-percent', `${Math.round(normalized * 100)}%`);
|
|
@@ -125,7 +92,7 @@ export function setFontScale(nextScale, { persist = true, preserveMessageAnchor
|
|
|
125
92
|
syncFontScaleSelect();
|
|
126
93
|
if (anchor) {
|
|
127
94
|
requestAnimationFrame(() => {
|
|
128
|
-
|
|
95
|
+
restoreTranscriptAnchor(document.getElementById('messages'), anchor);
|
|
129
96
|
});
|
|
130
97
|
}
|
|
131
98
|
return normalized;
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// Pending host suspend / relay restart, as every device sees it: an app-wide
|
|
2
|
+
// banner with a countdown and Cancel, plus the chat-actions menu labels.
|
|
3
|
+
// State arrives over the socket (`host_suspend_state`, `relay_shutdown_state`)
|
|
4
|
+
// and is also re-read from GET /api/status polls, so a client that missed an
|
|
5
|
+
// event still catches up. The banner ignores the per-device "Show Suspend
|
|
6
|
+
// host action" setting on purpose: a device with the action hidden must still
|
|
7
|
+
// be able to see and cancel a queued suspend.
|
|
8
|
+
|
|
9
|
+
import { escHtml, showTransientRelayNotice } from './store.js';
|
|
10
|
+
import { cancelHostSuspend, cancelRelayRestart } from './api-client.js';
|
|
11
|
+
|
|
12
|
+
const BANNER_ID = 'pending-action-banner';
|
|
13
|
+
const SUSPEND_MENU_ID = 'chat-menu-suspend-host';
|
|
14
|
+
const RESTART_MENU_ID = 'chat-menu-restart-relay';
|
|
15
|
+
const SUSPEND_MENU_LABEL = '💤 Suspend host';
|
|
16
|
+
const SUSPEND_MENU_PENDING_LABEL = '💤 Suspend pending…';
|
|
17
|
+
const RESTART_MENU_LABEL = '🌄 Restart web relay';
|
|
18
|
+
const RESTART_MENU_PENDING_LABEL = '🌄 Restart pending…';
|
|
19
|
+
|
|
20
|
+
let hostSuspendState = null;
|
|
21
|
+
let relayShutdownState = null;
|
|
22
|
+
let countdownTimer = null;
|
|
23
|
+
let cancelInFlight = false;
|
|
24
|
+
|
|
25
|
+
export function getHostSuspendState() {
|
|
26
|
+
return hostSuspendState;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getRelayShutdownState() {
|
|
30
|
+
return relayShutdownState;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isHostSuspendPending(state = hostSuspendState) {
|
|
34
|
+
const status = String(state?.status || '').trim();
|
|
35
|
+
return status === 'queued' || status === 'countdown' || status === 'suspending';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isRelayShutdownPending(state = relayShutdownState) {
|
|
39
|
+
const status = String(state?.status || '').trim();
|
|
40
|
+
return status === 'queued' || status === 'shutting_down';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** "0:27" style remaining time from a fireAt ISO string; null when not counting. */
|
|
44
|
+
export function formatCountdown(fireAt, nowMs = Date.now()) {
|
|
45
|
+
const target = Date.parse(String(fireAt || ''));
|
|
46
|
+
if (!Number.isFinite(target)) return null;
|
|
47
|
+
const remaining = Math.max(0, Math.ceil((target - nowMs) / 1000));
|
|
48
|
+
const minutes = Math.floor(remaining / 60);
|
|
49
|
+
const seconds = remaining % 60;
|
|
50
|
+
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One human line per blocker, e.g. "sidebar polish — 2 background agents: …". */
|
|
54
|
+
export function describeHostSuspendBlocker(blocker) {
|
|
55
|
+
if (!blocker || typeof blocker !== 'object') return '';
|
|
56
|
+
const title = String(blocker.title || '').trim();
|
|
57
|
+
const detail = String(blocker.detail || '').trim();
|
|
58
|
+
switch (String(blocker.kind || '')) {
|
|
59
|
+
case 'turn':
|
|
60
|
+
return `${title || 'a session'} — ${detail || 'turn running'}`;
|
|
61
|
+
case 'background':
|
|
62
|
+
return `${title || 'a session'} — ${detail || 'background agents running'}`;
|
|
63
|
+
case 'ci':
|
|
64
|
+
return `CI ${title ? `in ${title}` : ''} — ${detail || 'runs open'}`.replace(/\s+—/, ' —');
|
|
65
|
+
case 'ci-unknown':
|
|
66
|
+
return detail || `CI state of ${title || 'a repository'} unknown`;
|
|
67
|
+
case 'ci-checking':
|
|
68
|
+
return detail || 'Checking CI state';
|
|
69
|
+
case 'error':
|
|
70
|
+
return detail || 'Activity check failed';
|
|
71
|
+
default:
|
|
72
|
+
return [title, detail].filter(Boolean).join(' — ');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Short banner summary: first two blockers, "+N more". */
|
|
77
|
+
export function summarizeHostSuspendBlockers(blockers) {
|
|
78
|
+
const list = (Array.isArray(blockers) ? blockers : []).map(describeHostSuspendBlocker).filter(Boolean);
|
|
79
|
+
if (!list.length) return '';
|
|
80
|
+
const shown = list.slice(0, 2);
|
|
81
|
+
const rest = list.length - shown.length;
|
|
82
|
+
return shown.join('; ') + (rest > 0 ? `; +${rest} more` : '');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function hostSuspendBannerText(state, nowMs = Date.now()) {
|
|
86
|
+
if (!isHostSuspendPending(state)) return '';
|
|
87
|
+
const status = String(state.status || '');
|
|
88
|
+
if (status === 'suspending') return '💤 Suspending host now…';
|
|
89
|
+
if (status === 'countdown') {
|
|
90
|
+
const countdown = formatCountdown(state.fireAt, nowMs);
|
|
91
|
+
return countdown ? `💤 Everything is idle — suspending host in ${countdown}` : '💤 Everything is idle — suspending host shortly';
|
|
92
|
+
}
|
|
93
|
+
const summary = summarizeHostSuspendBlockers(state.blockers);
|
|
94
|
+
return summary
|
|
95
|
+
? `💤 Suspend queued — waiting for: ${summary}`
|
|
96
|
+
: '💤 Suspend queued — waiting for running agents to finish';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function relayShutdownBannerText(state) {
|
|
100
|
+
if (!isRelayShutdownPending(state)) return '';
|
|
101
|
+
const action = state.action === 'restart' || state.restart ? 'restart' : 'shutdown';
|
|
102
|
+
const icon = action === 'restart' ? '🌄' : '⏻';
|
|
103
|
+
if (String(state.status || '') === 'shutting_down') {
|
|
104
|
+
return `${icon} Relay ${action === 'restart' ? 'restarting' : 'shutting down'} now…`;
|
|
105
|
+
}
|
|
106
|
+
const queue = state.queue || {};
|
|
107
|
+
const turns = Number(queue.pendingCount || 0) + Number(queue.processingCount || 0) + Number(queue.parkedCount || 0);
|
|
108
|
+
const waiting = turns > 0 ? `waiting for ${turns} turn${turns === 1 ? '' : 's'} to finish` : 'queue is idle, going down now';
|
|
109
|
+
return `${icon} Relay ${action} queued — ${waiting}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function ensureBanner() {
|
|
113
|
+
let banner = document.getElementById(BANNER_ID);
|
|
114
|
+
if (banner) return banner;
|
|
115
|
+
banner = document.createElement('div');
|
|
116
|
+
banner.id = BANNER_ID;
|
|
117
|
+
banner.setAttribute('role', 'status');
|
|
118
|
+
banner.setAttribute('aria-live', 'polite');
|
|
119
|
+
document.body?.appendChild(banner);
|
|
120
|
+
return banner;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function bannerRow({ text, cancelLabel, onCancel, key }) {
|
|
124
|
+
const row = document.createElement('div');
|
|
125
|
+
row.className = 'pending-action-row';
|
|
126
|
+
row.dataset.pending = key;
|
|
127
|
+
const label = document.createElement('span');
|
|
128
|
+
label.className = 'pending-action-text';
|
|
129
|
+
label.textContent = text;
|
|
130
|
+
row.appendChild(label);
|
|
131
|
+
if (cancelLabel && typeof onCancel === 'function') {
|
|
132
|
+
const btn = document.createElement('button');
|
|
133
|
+
btn.type = 'button';
|
|
134
|
+
btn.className = 'relay-toast-action pending-action-cancel';
|
|
135
|
+
btn.textContent = cancelLabel;
|
|
136
|
+
btn.disabled = cancelInFlight;
|
|
137
|
+
btn.addEventListener('click', (event) => {
|
|
138
|
+
event.preventDefault();
|
|
139
|
+
event.stopPropagation();
|
|
140
|
+
void onCancel();
|
|
141
|
+
});
|
|
142
|
+
row.appendChild(btn);
|
|
143
|
+
}
|
|
144
|
+
return row;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function runCancelHostSuspend() {
|
|
148
|
+
if (cancelInFlight) return;
|
|
149
|
+
cancelInFlight = true;
|
|
150
|
+
renderPendingActionBanner();
|
|
151
|
+
try {
|
|
152
|
+
const result = await cancelHostSuspend();
|
|
153
|
+
if (!result?.ok) {
|
|
154
|
+
showTransientRelayNotice('Failed to cancel the queued suspend.', 5000);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (result.state) applyHostSuspendState(result.state);
|
|
158
|
+
showTransientRelayNotice(result.cancelled ? 'Queued host suspend cancelled.' : 'No host suspend was pending.', 4000);
|
|
159
|
+
} finally {
|
|
160
|
+
cancelInFlight = false;
|
|
161
|
+
renderPendingActionBanner();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function runCancelRelayShutdown() {
|
|
166
|
+
if (cancelInFlight) return;
|
|
167
|
+
cancelInFlight = true;
|
|
168
|
+
renderPendingActionBanner();
|
|
169
|
+
try {
|
|
170
|
+
const result = await cancelRelayRestart();
|
|
171
|
+
if (!result?.ok) {
|
|
172
|
+
showTransientRelayNotice('Failed to cancel the queued relay restart.', 5000);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
applyRelayShutdownState(result);
|
|
176
|
+
showTransientRelayNotice(result.cancelled ? 'Queued relay restart cancelled.' : 'No relay restart was pending.', 4000);
|
|
177
|
+
} finally {
|
|
178
|
+
cancelInFlight = false;
|
|
179
|
+
renderPendingActionBanner();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function syncCountdownTimer() {
|
|
184
|
+
const needsTicker = isHostSuspendPending() && String(hostSuspendState?.status || '') === 'countdown' && hostSuspendState?.fireAt;
|
|
185
|
+
if (needsTicker && !countdownTimer) {
|
|
186
|
+
countdownTimer = setInterval(() => renderPendingActionBanner(), 1000);
|
|
187
|
+
} else if (!needsTicker && countdownTimer) {
|
|
188
|
+
clearInterval(countdownTimer);
|
|
189
|
+
countdownTimer = null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function renderPendingActionBanner() {
|
|
194
|
+
if (typeof document === 'undefined') return;
|
|
195
|
+
const rows = [];
|
|
196
|
+
if (isHostSuspendPending()) {
|
|
197
|
+
rows.push(bannerRow({
|
|
198
|
+
key: 'host-suspend',
|
|
199
|
+
text: hostSuspendBannerText(hostSuspendState),
|
|
200
|
+
cancelLabel: String(hostSuspendState?.status || '') === 'suspending' ? '' : 'Cancel',
|
|
201
|
+
onCancel: runCancelHostSuspend,
|
|
202
|
+
}));
|
|
203
|
+
}
|
|
204
|
+
if (isRelayShutdownPending()) {
|
|
205
|
+
const restarting = String(relayShutdownState?.status || '') === 'shutting_down';
|
|
206
|
+
rows.push(bannerRow({
|
|
207
|
+
key: 'relay-shutdown',
|
|
208
|
+
text: relayShutdownBannerText(relayShutdownState),
|
|
209
|
+
cancelLabel: restarting ? '' : 'Cancel',
|
|
210
|
+
onCancel: runCancelRelayShutdown,
|
|
211
|
+
}));
|
|
212
|
+
}
|
|
213
|
+
const banner = document.getElementById(BANNER_ID) || (rows.length ? ensureBanner() : null);
|
|
214
|
+
if (!banner) return;
|
|
215
|
+
banner.replaceChildren(...rows);
|
|
216
|
+
banner.classList.toggle('visible', rows.length > 0);
|
|
217
|
+
banner.hidden = rows.length === 0;
|
|
218
|
+
syncMenuLabels();
|
|
219
|
+
syncCountdownTimer();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function syncMenuLabels() {
|
|
223
|
+
const suspendBtn = document.getElementById(SUSPEND_MENU_ID);
|
|
224
|
+
if (suspendBtn) suspendBtn.textContent = isHostSuspendPending() ? SUSPEND_MENU_PENDING_LABEL : SUSPEND_MENU_LABEL;
|
|
225
|
+
const restartBtn = document.getElementById(RESTART_MENU_ID);
|
|
226
|
+
if (restartBtn) restartBtn.textContent = isRelayShutdownPending() ? RESTART_MENU_PENDING_LABEL : RESTART_MENU_LABEL;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function applyHostSuspendState(state) {
|
|
230
|
+
const previous = hostSuspendState;
|
|
231
|
+
hostSuspendState = state && typeof state === 'object' ? state : null;
|
|
232
|
+
if (isHostSuspendPending(previous) && !isHostSuspendPending(hostSuspendState) && hostSuspendState?.lastError) {
|
|
233
|
+
showTransientRelayNotice(`Host suspend failed: ${hostSuspendState.lastError}`, 8000);
|
|
234
|
+
}
|
|
235
|
+
renderPendingActionBanner();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function applyRelayShutdownState(state) {
|
|
239
|
+
relayShutdownState = state && typeof state === 'object' ? state : null;
|
|
240
|
+
renderPendingActionBanner();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** GET /api/status carries both states; the poll keeps late clients honest. */
|
|
244
|
+
export function applyPendingActionsFromStatus(status) {
|
|
245
|
+
if (!status || typeof status !== 'object') return;
|
|
246
|
+
if ('hostSuspend' in status) hostSuspendState = status.hostSuspend || null;
|
|
247
|
+
if ('relayShutdown' in status) {
|
|
248
|
+
const next = status.relayShutdown ? { ...status.relayShutdown } : null;
|
|
249
|
+
if (next && !next.queue) {
|
|
250
|
+
next.queue = {
|
|
251
|
+
pendingCount: Number(status.pendingCount || 0),
|
|
252
|
+
processingCount: Number(status.processingCount || 0),
|
|
253
|
+
parkedCount: Number(status.parkedCount || 0),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
relayShutdownState = next;
|
|
257
|
+
}
|
|
258
|
+
renderPendingActionBanner();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function initHostSuspendUi() {
|
|
262
|
+
renderPendingActionBanner();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function __resetHostSuspendUiForTests() {
|
|
266
|
+
hostSuspendState = null;
|
|
267
|
+
relayShutdownState = null;
|
|
268
|
+
cancelInFlight = false;
|
|
269
|
+
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
|
|
270
|
+
document.getElementById(BANNER_ID)?.remove();
|
|
271
|
+
syncMenuLabels();
|
|
272
|
+
}
|
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
loadGrokSettings,
|
|
37
37
|
loadOpenAISettings,
|
|
38
38
|
} from './api-client.js';
|
|
39
|
-
import { renderMessages, restoreInFlightThinking, focusConversationMessageById, flushConversationDraft, hydrateConversationDraft } from './conversation-view.js';
|
|
39
|
+
import { renderMessages, restoreInFlightThinking, focusConversationMessageById, flushConversationDraft, hydrateConversationDraft, beginConversationDraftSwitch } from './conversation-view.js';
|
|
40
40
|
import { setBackgroundTasksConversation, setConversationBackgroundTasks } from './background-tasks-view.mjs';
|
|
41
41
|
import { mergeConversationPreviews } from './preview-cards.mjs';
|
|
42
42
|
import { loadRelayQuestions, getPendingQuestionCountsByConversation } from './ask-user-view.js';
|
|
@@ -58,6 +58,13 @@ import {
|
|
|
58
58
|
conversationProviderIndicatorLabel,
|
|
59
59
|
} from './conversation-provider-indicator.mjs';
|
|
60
60
|
import { leaveStatusView } from './status-view.mjs';
|
|
61
|
+
import { renderConversationOriginMarkerHtml } from './remote-relay-origin-view.mjs';
|
|
62
|
+
import {
|
|
63
|
+
normalizeConversationFilter,
|
|
64
|
+
filterConversations,
|
|
65
|
+
drainRemainingPages,
|
|
66
|
+
describeFilterMatchCount,
|
|
67
|
+
} from './conversation-list-filter.mjs';
|
|
61
68
|
|
|
62
69
|
const PROCESSING_DOT_FRAMES = [' ', '. ', '.. ', '...'];
|
|
63
70
|
const PROCESSING_DOT_INTERVAL_MS = 1000;
|
|
@@ -74,9 +81,13 @@ const CLAUDE_LONG_CONTEXT_PATTERN = /\[1m\]$/i;
|
|
|
74
81
|
const OPENAI_IMAGE_SIZE_STORAGE_KEY = 'copilot_openai_image_size';
|
|
75
82
|
const NEW_CHAT_CWD_STORAGE_KEY = 'copilot_new_chat_cwd';
|
|
76
83
|
const NEW_CHAT_CUSTOM_CWD_VALUE = '__custom__';
|
|
84
|
+
const CONV_FILTER_DEBOUNCE_MS = 220;
|
|
77
85
|
let processingDotFrame = 0;
|
|
78
86
|
let processingDotTimer = null;
|
|
79
87
|
let lastConvListHtml = '';
|
|
88
|
+
let conversationListFilterText = '';
|
|
89
|
+
let conversationFilterDrainActive = false;
|
|
90
|
+
let conversationFilterGeneration = 0;
|
|
80
91
|
let openConversationVersion = 0;
|
|
81
92
|
let newConversationInFlight = false;
|
|
82
93
|
|
|
@@ -273,6 +284,11 @@ export async function refreshConversations(options = {}) {
|
|
|
273
284
|
});
|
|
274
285
|
renderConvList();
|
|
275
286
|
updateCompactButton();
|
|
287
|
+
// A reset bumps the loader version, which aborts any in-flight filter drain;
|
|
288
|
+
// restart it so an active filter keeps covering the unloaded pages.
|
|
289
|
+
if (normalizeConversationFilter(conversationListFilterText)) {
|
|
290
|
+
void drainConversationListForFilter();
|
|
291
|
+
}
|
|
276
292
|
}
|
|
277
293
|
|
|
278
294
|
export function renderConvList() {
|
|
@@ -282,9 +298,19 @@ export function renderConvList() {
|
|
|
282
298
|
if (updatedAtDelta !== 0) return updatedAtDelta;
|
|
283
299
|
return String(b?.id || '').trim().localeCompare(String(a?.id || '').trim());
|
|
284
300
|
});
|
|
301
|
+
const activeFilter = normalizeConversationFilter(conversationListFilterText);
|
|
302
|
+
const visible = activeFilter ? filterConversations(sorted, activeFilter) : sorted;
|
|
285
303
|
const pendingByConversation = getPendingQuestionCountsByConversation();
|
|
286
304
|
let hasProcessingConversation = false;
|
|
287
305
|
const footerHtml = (() => {
|
|
306
|
+
if (activeFilter) {
|
|
307
|
+
// While a filter is active the drain loads older pages by itself; a
|
|
308
|
+
// "scroll for more" hint would point at a gesture that does nothing, and
|
|
309
|
+
// "Searching…" must only show while something is actually loading.
|
|
310
|
+
return (conversationFilterDrainActive || conversationListPaginationState.isLoading)
|
|
311
|
+
? '<div class="conv-list-footer">Searching older conversations…</div>'
|
|
312
|
+
: '';
|
|
313
|
+
}
|
|
288
314
|
if (conversationListPaginationState.isLoading) {
|
|
289
315
|
return '<div class="conv-list-footer">Loading older conversations…</div>';
|
|
290
316
|
}
|
|
@@ -293,10 +319,14 @@ export function renderConvList() {
|
|
|
293
319
|
}
|
|
294
320
|
return '';
|
|
295
321
|
})();
|
|
296
|
-
if (
|
|
322
|
+
if (visible.length === 0) {
|
|
297
323
|
ensureProcessingDotTimer(false);
|
|
298
|
-
|
|
324
|
+
const emptyText = activeFilter && sorted.length > 0
|
|
325
|
+
? 'No conversations match your filter'
|
|
326
|
+
: 'No conversations yet';
|
|
327
|
+
list.innerHTML = `<div style="padding:12px;color:var(--muted);font-size:0.85rem;text-align:center">${emptyText}</div>${footerHtml}`;
|
|
299
328
|
lastConvListHtml = '';
|
|
329
|
+
scheduleConversationListBoundaryCheck();
|
|
300
330
|
return;
|
|
301
331
|
}
|
|
302
332
|
const conversationView = (conversation) => {
|
|
@@ -312,7 +342,7 @@ export function renderConvList() {
|
|
|
312
342
|
if (processing) hasProcessingConversation = true;
|
|
313
343
|
return { visualState, processing };
|
|
314
344
|
};
|
|
315
|
-
const listHtml = `${
|
|
345
|
+
const listHtml = `${visible.map((c) => {
|
|
316
346
|
const view = conversationView(c);
|
|
317
347
|
const processingDots = view.processing ? PROCESSING_DOT_FRAMES[processingDotFrame] : '';
|
|
318
348
|
const providerIndicatorLabel = conversationProviderIndicatorLabel(c);
|
|
@@ -320,10 +350,12 @@ export function renderConvList() {
|
|
|
320
350
|
const providerIndicatorHtml = providerIndicatorLabel
|
|
321
351
|
? `<span class="conv-provider-indicator"${providerIndicatorKey ? ` data-provider="${providerIndicatorKey}"` : ''}>${providerIndicatorLabel}</span>`
|
|
322
352
|
: '';
|
|
353
|
+
// "↗ win-test": another relay's agent created this conversation.
|
|
354
|
+
const originMarkerHtml = renderConversationOriginMarkerHtml(c);
|
|
323
355
|
return `
|
|
324
356
|
<div class="conv-item worker-ui-${view.visualState}${c.id === currentConvId ? ' active' : ''}" onclick="openConversation('${c.id}')">
|
|
325
357
|
<div class="conv-title">${escHtml(c.title)}${processingDots ? `<span class="conv-processing-dots">${escHtml(` ${processingDots}`)}</span>` : ''}${c.archived ? ' <span style="font-size:0.68rem;color:var(--muted)">(archived)</span>' : ''}${pendingByConversation[c.id] ? ` <span class="conv-open-questions">${pendingByConversation[c.id]} open</span>` : ''}</div>
|
|
326
|
-
<div class="conv-meta"><span class="conv-meta-primary">${fmtDate(c.updatedAt)} · ${c.messageCount} msg${c.messageCount !== 1 ? 's' : ''}</span>${providerIndicatorHtml}</div>
|
|
358
|
+
<div class="conv-meta"><span class="conv-meta-primary">${fmtDate(c.updatedAt)} · ${c.messageCount} msg${c.messageCount !== 1 ? 's' : ''}</span>${originMarkerHtml}${providerIndicatorHtml}</div>
|
|
327
359
|
<button class="conv-delete" onclick="deleteConv(event,'${c.id}')" title="Delete">🗑</button>
|
|
328
360
|
</div>`;
|
|
329
361
|
}).join('')}${footerHtml}`;
|
|
@@ -427,6 +459,7 @@ export async function openConversation(id, options = {}) {
|
|
|
427
459
|
await flushConversationDraft(previousConversationId);
|
|
428
460
|
window.clearImageEditTarget?.();
|
|
429
461
|
}
|
|
462
|
+
const switchingConversation = !!nextConversationId && previousConversationId !== nextConversationId;
|
|
430
463
|
const capturedVersion = ++openConversationVersion;
|
|
431
464
|
setCurrentConv(id);
|
|
432
465
|
if (repoBrowserState.activeRoot === 'workspace') {
|
|
@@ -440,7 +473,13 @@ export async function openConversation(id, options = {}) {
|
|
|
440
473
|
repoBrowserState.error = '';
|
|
441
474
|
}
|
|
442
475
|
closeSidebar();
|
|
443
|
-
|
|
476
|
+
// Reopening the conversation on screen (session bind, clicking it again, a
|
|
477
|
+
// push or search jump) must leave its composer alone: emptied attachments
|
|
478
|
+
// would read as a user edit and be saved, dropping them from the draft.
|
|
479
|
+
if (switchingConversation) {
|
|
480
|
+
clearAttachments();
|
|
481
|
+
beginConversationDraftSwitch(nextConversationId);
|
|
482
|
+
}
|
|
444
483
|
document.getElementById('chat-title').textContent = conversations[id]?.title || id;
|
|
445
484
|
if (didLeaveStatusView) {
|
|
446
485
|
restoreInFlightThinking(null);
|
|
@@ -1145,11 +1184,115 @@ export function initConversationListLazyLoading() {
|
|
|
1145
1184
|
scheduleConversationListBoundaryCheck();
|
|
1146
1185
|
}
|
|
1147
1186
|
|
|
1187
|
+
// A title filter has to search conversations that only exist on unloaded pages,
|
|
1188
|
+
// so an active filter drains the remaining pages in the background. Each page
|
|
1189
|
+
// re-renders via applyPage, so matches appear as they load.
|
|
1190
|
+
// A filter change aborts the running drain and starts a fresh one, so the new
|
|
1191
|
+
// filter gets a full retry budget.
|
|
1192
|
+
async function drainConversationListForFilter() {
|
|
1193
|
+
if (conversationFilterDrainActive) return;
|
|
1194
|
+
conversationFilterDrainActive = true;
|
|
1195
|
+
const generation = conversationFilterGeneration;
|
|
1196
|
+
renderConvList();
|
|
1197
|
+
try {
|
|
1198
|
+
await drainRemainingPages(
|
|
1199
|
+
conversationListLoader,
|
|
1200
|
+
() => generation === conversationFilterGeneration
|
|
1201
|
+
&& !!normalizeConversationFilter(conversationListFilterText),
|
|
1202
|
+
);
|
|
1203
|
+
} finally {
|
|
1204
|
+
conversationFilterDrainActive = false;
|
|
1205
|
+
const filterActive = !!normalizeConversationFilter(conversationListFilterText);
|
|
1206
|
+
if (filterActive && generation !== conversationFilterGeneration) {
|
|
1207
|
+
void drainConversationListForFilter();
|
|
1208
|
+
} else {
|
|
1209
|
+
// The "Searching older conversations…" footer keys off the drain flag.
|
|
1210
|
+
renderConvList();
|
|
1211
|
+
if (filterActive) announceConversationFilterResults();
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// Announced only when the (debounced) filter applies and when its drain ends,
|
|
1217
|
+
// not on every page render, so screen readers are not flooded while typing.
|
|
1218
|
+
// Unchanged text is not rewritten: refresh-triggered drains (session bind,
|
|
1219
|
+
// reconnect) must not re-announce without a user action.
|
|
1220
|
+
function announceConversationFilterResults() {
|
|
1221
|
+
const status = document.getElementById('conv-filter-status');
|
|
1222
|
+
if (!status) return;
|
|
1223
|
+
const activeFilter = normalizeConversationFilter(conversationListFilterText);
|
|
1224
|
+
let text = '';
|
|
1225
|
+
if (activeFilter) {
|
|
1226
|
+
const count = filterConversations(Object.values(conversations), activeFilter).length;
|
|
1227
|
+
if (!conversationListLoader.getState().hasMore) {
|
|
1228
|
+
text = describeFilterMatchCount(count);
|
|
1229
|
+
} else if (conversationFilterDrainActive) {
|
|
1230
|
+
text = `${describeFilterMatchCount(count)} so far, searching older conversations`;
|
|
1231
|
+
} else {
|
|
1232
|
+
// The drain gave up with pages left, so the search is not complete.
|
|
1233
|
+
text = describeFilterMatchCount(count, { loadedOnly: true });
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
if (status.textContent !== text) status.textContent = text;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
function setConversationListFilter(text) {
|
|
1240
|
+
const next = String(text ?? '');
|
|
1241
|
+
if (next === conversationListFilterText) return;
|
|
1242
|
+
conversationListFilterText = next;
|
|
1243
|
+
conversationFilterGeneration += 1;
|
|
1244
|
+
const list = getConversationListElement();
|
|
1245
|
+
if (list) list.scrollTop = 0;
|
|
1246
|
+
renderConvList();
|
|
1247
|
+
if (normalizeConversationFilter(next)) void drainConversationListForFilter();
|
|
1248
|
+
announceConversationFilterResults();
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
export function initConversationFilter() {
|
|
1252
|
+
const input = document.getElementById('conv-filter-input');
|
|
1253
|
+
const clearButton = document.getElementById('conv-filter-clear');
|
|
1254
|
+
if (!input || input.dataset.filterBound === '1') return;
|
|
1255
|
+
input.dataset.filterBound = '1';
|
|
1256
|
+
let debounceTimer = null;
|
|
1257
|
+
const syncClearButton = () => {
|
|
1258
|
+
if (clearButton) clearButton.hidden = input.value === '';
|
|
1259
|
+
};
|
|
1260
|
+
const applyFilterNow = () => {
|
|
1261
|
+
if (debounceTimer) {
|
|
1262
|
+
clearTimeout(debounceTimer);
|
|
1263
|
+
debounceTimer = null;
|
|
1264
|
+
}
|
|
1265
|
+
syncClearButton();
|
|
1266
|
+
setConversationListFilter(input.value);
|
|
1267
|
+
};
|
|
1268
|
+
input.addEventListener('input', () => {
|
|
1269
|
+
syncClearButton();
|
|
1270
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
1271
|
+
debounceTimer = setTimeout(applyFilterNow, CONV_FILTER_DEBOUNCE_MS);
|
|
1272
|
+
});
|
|
1273
|
+
input.addEventListener('keydown', (event) => {
|
|
1274
|
+
if (event.key !== 'Escape' || input.value === '') return;
|
|
1275
|
+
event.stopPropagation();
|
|
1276
|
+
input.value = '';
|
|
1277
|
+
applyFilterNow();
|
|
1278
|
+
});
|
|
1279
|
+
clearButton?.addEventListener('click', () => {
|
|
1280
|
+
input.value = '';
|
|
1281
|
+
applyFilterNow();
|
|
1282
|
+
input.focus();
|
|
1283
|
+
});
|
|
1284
|
+
syncClearButton();
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1148
1287
|
export async function deleteConv(e, id) {
|
|
1149
1288
|
e.stopPropagation();
|
|
1150
1289
|
if (!confirm('Delete this conversation?')) return;
|
|
1151
1290
|
const result = await deleteConversationApi(id);
|
|
1152
1291
|
if (!result) return;
|
|
1292
|
+
if (result.ok === false) {
|
|
1293
|
+
showTransientRelayNotice(String(result.message || '').trim() || 'This conversation could not be deleted.', 6000);
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1153
1296
|
delete conversations[id];
|
|
1154
1297
|
renderConvList();
|
|
1155
1298
|
if (currentConvId === id) {
|