dextunnel 0.1.0
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/LICENSE +211 -0
- package/README.md +112 -0
- package/SECURITY.md +27 -0
- package/SUPPORT.md +43 -0
- package/package.json +44 -0
- package/public/client-shared.js +1831 -0
- package/public/favicon.svg +11 -0
- package/public/host.html +29 -0
- package/public/host.js +2079 -0
- package/public/index.html +28 -0
- package/public/index.js +98 -0
- package/public/live-bridge-lifecycle.js +258 -0
- package/public/live-bridge-retry-state.js +61 -0
- package/public/live-selection-intent.js +79 -0
- package/public/remote-operator-state.js +316 -0
- package/public/remote.html +167 -0
- package/public/remote.js +3967 -0
- package/public/styles.css +2793 -0
- package/public/surface-view-state.js +89 -0
- package/public/voice-dictation.js +45 -0
- package/src/bin/desktop-rehydration-smoke.mjs +111 -0
- package/src/bin/dextunnel.mjs +41 -0
- package/src/bin/doctor.mjs +48 -0
- package/src/bin/launch-attest.mjs +39 -0
- package/src/bin/launch-status.mjs +49 -0
- package/src/bin/mobile-link-proxy.mjs +221 -0
- package/src/bin/mobile-proof.mjs +164 -0
- package/src/bin/mobile-transport-smoke.mjs +200 -0
- package/src/bin/probe-codex-app-server-write.mjs +36 -0
- package/src/bin/probe-codex-app-server.mjs +30 -0
- package/src/lib/agent-room-context.mjs +54 -0
- package/src/lib/agent-room-runtime.mjs +355 -0
- package/src/lib/agent-room-service.mjs +335 -0
- package/src/lib/agent-room-state.mjs +406 -0
- package/src/lib/agent-room-store.mjs +71 -0
- package/src/lib/agent-room-text.mjs +48 -0
- package/src/lib/app-server-contract.mjs +66 -0
- package/src/lib/app-server-runtime.mjs +60 -0
- package/src/lib/attachment-service.mjs +119 -0
- package/src/lib/bridge-api-handler.mjs +719 -0
- package/src/lib/bridge-runtime-lifecycle.mjs +51 -0
- package/src/lib/bridge-status-builder.mjs +60 -0
- package/src/lib/codex-app-server-client.mjs +1511 -0
- package/src/lib/companion-state.mjs +453 -0
- package/src/lib/control-lease-service.mjs +180 -0
- package/src/lib/debug-harness-service.mjs +173 -0
- package/src/lib/desktop-integration.mjs +146 -0
- package/src/lib/desktop-rehydration-smoke.mjs +269 -0
- package/src/lib/dextunnel-cli.mjs +122 -0
- package/src/lib/discovery-docs.mjs +1321 -0
- package/src/lib/fake-codex-app-server-bridge.mjs +340 -0
- package/src/lib/install-preflight.mjs +373 -0
- package/src/lib/interaction-resolution-service.mjs +185 -0
- package/src/lib/interaction-state.mjs +360 -0
- package/src/lib/launch-release-bar.mjs +158 -0
- package/src/lib/live-control-state.mjs +107 -0
- package/src/lib/live-payload-builder.mjs +298 -0
- package/src/lib/live-selection-transition-state.mjs +49 -0
- package/src/lib/live-transcript-state.mjs +549 -0
- package/src/lib/mobile-network-profile.mjs +39 -0
- package/src/lib/mock-codex-adapter.mjs +62 -0
- package/src/lib/operator-diagnostics.mjs +82 -0
- package/src/lib/repo-changes-service.mjs +527 -0
- package/src/lib/runtime-config.mjs +106 -0
- package/src/lib/selection-state-service.mjs +214 -0
- package/src/lib/session-store.mjs +355 -0
- package/src/lib/shared-room-state.mjs +473 -0
- package/src/lib/shared-selection-state.mjs +40 -0
- package/src/lib/sse-hub.mjs +35 -0
- package/src/lib/static-surface-service.mjs +71 -0
- package/src/lib/surface-access.mjs +189 -0
- package/src/lib/surface-presence-service.mjs +118 -0
- package/src/lib/surface-request-guard.mjs +52 -0
- package/src/lib/thread-sync-state.mjs +536 -0
- package/src/lib/watcher-lifecycle.mjs +287 -0
- package/src/server.mjs +1446 -0
package/public/remote.js
ADDED
|
@@ -0,0 +1,3967 @@
|
|
|
1
|
+
import {
|
|
2
|
+
canSteerReply as canSteerReplyState,
|
|
3
|
+
canQueueReply as canQueueReplyState,
|
|
4
|
+
cloneReplyAttachments,
|
|
5
|
+
composeBlockedReason as composeBlockedReasonState,
|
|
6
|
+
controlClaimRequired,
|
|
7
|
+
controlEventStatus as controlEventStatusState,
|
|
8
|
+
controlBlockedReason as controlBlockedReasonState,
|
|
9
|
+
controlReleaseFeedback as controlReleaseFeedbackState,
|
|
10
|
+
createQueuedReply,
|
|
11
|
+
defaultComposerStatus,
|
|
12
|
+
queueSummary as queueSummaryState,
|
|
13
|
+
scopedThreadStorageKey,
|
|
14
|
+
sendBlockedReason as sendBlockedReasonState,
|
|
15
|
+
sessionBlockedReason as sessionBlockedReasonState,
|
|
16
|
+
shouldFlushQueuedReplies,
|
|
17
|
+
threadBusy as threadBusyState
|
|
18
|
+
} from "./remote-operator-state.js";
|
|
19
|
+
import {
|
|
20
|
+
createSelectionIntent,
|
|
21
|
+
reconcileSelectionIntent,
|
|
22
|
+
selectionIntentMessage,
|
|
23
|
+
selectionIntentTitle
|
|
24
|
+
} from "./live-selection-intent.js";
|
|
25
|
+
import {
|
|
26
|
+
composeDictationDraft,
|
|
27
|
+
getSpeechRecognitionCtor,
|
|
28
|
+
speechRecognitionErrorMessage
|
|
29
|
+
} from "./voice-dictation.js";
|
|
30
|
+
import {
|
|
31
|
+
createLiveBridgeLifecycle,
|
|
32
|
+
createLiveBridgeLifecycleState
|
|
33
|
+
} from "./live-bridge-lifecycle.js";
|
|
34
|
+
import { createSurfaceViewState } from "./surface-view-state.js";
|
|
35
|
+
|
|
36
|
+
import {
|
|
37
|
+
createRequestError,
|
|
38
|
+
currentSurfaceTranscript,
|
|
39
|
+
describeOperatorDiagnostics,
|
|
40
|
+
describeRemoteScopeNote,
|
|
41
|
+
clearHtmlRenderState,
|
|
42
|
+
compareEntryChronology,
|
|
43
|
+
compareEntryChronologyDesc,
|
|
44
|
+
describeThreadState,
|
|
45
|
+
entryDedupKey,
|
|
46
|
+
entryKey,
|
|
47
|
+
escapeHtml,
|
|
48
|
+
formatBusyMarqueeText,
|
|
49
|
+
formatSessionTimestamp,
|
|
50
|
+
formatRecoveryDuration,
|
|
51
|
+
formatSurfaceAttachmentSummary,
|
|
52
|
+
formatTimestamp,
|
|
53
|
+
getSurfaceBootstrap,
|
|
54
|
+
groupThreadsByProject,
|
|
55
|
+
humanize,
|
|
56
|
+
isConversationEntry,
|
|
57
|
+
isSystemNoticeEntry,
|
|
58
|
+
mergeSurfaceAttachments,
|
|
59
|
+
projectLabel,
|
|
60
|
+
reconcileRenderedList,
|
|
61
|
+
renderChangeCard,
|
|
62
|
+
isAdvisoryEntry,
|
|
63
|
+
setHtmlIfChanged,
|
|
64
|
+
setPanelHidden,
|
|
65
|
+
renderTranscriptCard,
|
|
66
|
+
shortThreadId,
|
|
67
|
+
stableSurfaceClientId,
|
|
68
|
+
threadDisplayTitle,
|
|
69
|
+
shouldHideTranscriptEntry,
|
|
70
|
+
startTicker,
|
|
71
|
+
withSurfaceHeaders,
|
|
72
|
+
withSurfaceTokenUrl
|
|
73
|
+
} from "./client-shared.js";
|
|
74
|
+
|
|
75
|
+
const liveStateUrl = "/api/codex-app-server/live-state";
|
|
76
|
+
const refreshUrl = "/api/codex-app-server/refresh";
|
|
77
|
+
const selectionUrl = "/api/codex-app-server/selection";
|
|
78
|
+
const controlUrl = "/api/codex-app-server/control";
|
|
79
|
+
const companionUrl = "/api/codex-app-server/companion";
|
|
80
|
+
const interactionUrl = "/api/codex-app-server/interaction";
|
|
81
|
+
const turnUrl = "/api/codex-app-server/turn";
|
|
82
|
+
const changesUrl = "/api/codex-app-server/changes";
|
|
83
|
+
const presenceUrl = "/api/codex-app-server/presence";
|
|
84
|
+
const transcriptHistoryUrl = "/api/codex-app-server/transcript-history";
|
|
85
|
+
const stateUrl = "/api/state";
|
|
86
|
+
const FALLBACK_REFRESH_INTERVAL_MS = 6000;
|
|
87
|
+
const FALLBACK_REFRESH_STALE_MS = 14000;
|
|
88
|
+
const CONTROL_RENEW_INTERVAL_MS = 30000;
|
|
89
|
+
const CONTROL_RENEW_ACTIVE_WINDOW_MS = 90000;
|
|
90
|
+
const PRESENCE_HEARTBEAT_INTERVAL_MS = 12000;
|
|
91
|
+
const STREAM_RECOVERY_BASE_MS = 700;
|
|
92
|
+
const STREAM_RECOVERY_MAX_MS = 5000;
|
|
93
|
+
const BOOTSTRAP_RETRY_BASE_MS = 900;
|
|
94
|
+
const BOOTSTRAP_RETRY_MAX_MS = 6000;
|
|
95
|
+
const ROOM_STATUS_SETTLE_MS = 300;
|
|
96
|
+
const TRANSCRIPT_HISTORY_PAGE_SIZE = 40;
|
|
97
|
+
const TRANSCRIPT_HISTORY_BOTTOM_THRESHOLD_PX = 220;
|
|
98
|
+
const TRANSCRIPT_HISTORY_RESUME_SCROLL_DELTA_PX = 28;
|
|
99
|
+
const FEED_STICKY_TOP_THRESHOLD_PX = 96;
|
|
100
|
+
const SIDEBAR_MOBILE_BREAKPOINT_PX = 1180;
|
|
101
|
+
const DRAFT_STORAGE_PREFIX = "dextunnel:draft:";
|
|
102
|
+
const QUEUE_STORAGE_PREFIX = "dextunnel:queue:";
|
|
103
|
+
const IOS_FOCUS_PLATFORM_REGEX = /iPad|iPhone|iPod/;
|
|
104
|
+
const IOS_VIEWPORT_MAX_SCALE_CONTENT =
|
|
105
|
+
"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover";
|
|
106
|
+
const surfaceBootstrap = getSurfaceBootstrap("remote");
|
|
107
|
+
const surfaceAuthClientId = surfaceBootstrap.clientId;
|
|
108
|
+
|
|
109
|
+
let currentSnapshot = null;
|
|
110
|
+
let currentLiveState = null;
|
|
111
|
+
let renderedThreadId = null;
|
|
112
|
+
let hasRenderedOnce = false;
|
|
113
|
+
let isComposerOpen = false;
|
|
114
|
+
let isSendingReply = false;
|
|
115
|
+
let pendingOutgoingText = "";
|
|
116
|
+
let pendingOutgoingAttachments = [];
|
|
117
|
+
let pendingAttachments = [];
|
|
118
|
+
let activeDictation = null;
|
|
119
|
+
let dictationBaseText = "";
|
|
120
|
+
let dictationCommittedText = "";
|
|
121
|
+
let dictationError = "";
|
|
122
|
+
let dictationInterimText = "";
|
|
123
|
+
let isDictationPressActive = false;
|
|
124
|
+
let dictationPointerId = null;
|
|
125
|
+
let suppressNextDictationClick = false;
|
|
126
|
+
let composerStatus = "Ready";
|
|
127
|
+
let composerStatusTone = "neutral";
|
|
128
|
+
let isDictating = false;
|
|
129
|
+
let lastLiveActivityAt = 0;
|
|
130
|
+
let transientUiNotice = null;
|
|
131
|
+
let transientUiNoticeTimer = null;
|
|
132
|
+
let lastHandledControlEventId = null;
|
|
133
|
+
let companionActionState = null;
|
|
134
|
+
let manualAdvisorAction = "";
|
|
135
|
+
let stagedCompanionWakeKey = "";
|
|
136
|
+
let controlRenewPromise = null;
|
|
137
|
+
let presenceSyncPromise = null;
|
|
138
|
+
let presenceSyncTimer = null;
|
|
139
|
+
let lastPresenceSignature = "";
|
|
140
|
+
let lastPresenceSyncAt = 0;
|
|
141
|
+
let currentChanges = null;
|
|
142
|
+
let changesRefreshPromise = null;
|
|
143
|
+
let changesRefreshTimer = null;
|
|
144
|
+
let queueFlushTimer = null;
|
|
145
|
+
let queueFlushPromise = null;
|
|
146
|
+
let streamIssueStartedAt = 0;
|
|
147
|
+
let actionHandoffState = null;
|
|
148
|
+
let actionHandoffTimer = null;
|
|
149
|
+
let queuedReplySequence = 0;
|
|
150
|
+
const surfaceClientId = stableSurfaceClientId("remote");
|
|
151
|
+
const surfaceViewState = createSurfaceViewState({
|
|
152
|
+
defaults: {
|
|
153
|
+
sidebarMode: "expanded",
|
|
154
|
+
filters: {
|
|
155
|
+
changes: true,
|
|
156
|
+
thread: true,
|
|
157
|
+
updates: true,
|
|
158
|
+
tools: true
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
scopeId: surfaceClientId,
|
|
162
|
+
surface: "remote"
|
|
163
|
+
});
|
|
164
|
+
const expandedFeedSections = new Set();
|
|
165
|
+
let lastUserIntentAt = Date.now();
|
|
166
|
+
let draftThreadId = null;
|
|
167
|
+
let selectionIntent = null;
|
|
168
|
+
let selectionRequestVersion = 0;
|
|
169
|
+
let roomStatusHoldThreadId = "";
|
|
170
|
+
let roomStatusHoldUntil = 0;
|
|
171
|
+
let pendingScrollToLatest = false;
|
|
172
|
+
let lastRenderedFeedTopKey = null;
|
|
173
|
+
let expandAllCards = false;
|
|
174
|
+
let sidebarExpanded =
|
|
175
|
+
typeof globalThis !== "undefined" && globalThis.innerWidth <= SIDEBAR_MOBILE_BREAKPOINT_PX
|
|
176
|
+
? false
|
|
177
|
+
: surfaceViewState.loadSidebarMode() !== "collapsed";
|
|
178
|
+
const expandedEntryKeys = new Set();
|
|
179
|
+
const seenCardKeys = new Set();
|
|
180
|
+
const queuedRepliesByThreadId = new Map();
|
|
181
|
+
const transcriptHistoryByThreadId = new Map();
|
|
182
|
+
const bridgeState = createLiveBridgeLifecycleState({
|
|
183
|
+
bootstrapRetryBaseMs: BOOTSTRAP_RETRY_BASE_MS,
|
|
184
|
+
streamRecoveryBaseMs: STREAM_RECOVERY_BASE_MS
|
|
185
|
+
});
|
|
186
|
+
const feedFilters = surfaceViewState.loadFilters();
|
|
187
|
+
const uiState = {
|
|
188
|
+
booting: true,
|
|
189
|
+
controlling: false,
|
|
190
|
+
loadingChanges: false,
|
|
191
|
+
refreshing: false,
|
|
192
|
+
selecting: false,
|
|
193
|
+
submittingAction: false
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const nodes = {
|
|
197
|
+
actionButtons: document.querySelector("#remote-action-buttons"),
|
|
198
|
+
actionCancel: document.querySelector("#remote-action-cancel"),
|
|
199
|
+
actionCard: document.querySelector("#remote-action-card"),
|
|
200
|
+
actionControlButton: document.querySelector("#remote-action-control-button"),
|
|
201
|
+
actionControlGate: document.querySelector("#remote-action-control-gate"),
|
|
202
|
+
actionForm: document.querySelector("#remote-action-form"),
|
|
203
|
+
actionKind: document.querySelector("#remote-action-kind"),
|
|
204
|
+
actionPanel: document.querySelector("#remote-action-panel"),
|
|
205
|
+
actionQuestions: document.querySelector("#remote-action-questions"),
|
|
206
|
+
approveSessionButton: document.querySelector("#approve-session-button"),
|
|
207
|
+
actionSubmit: document.querySelector("#remote-action-submit"),
|
|
208
|
+
actionTitle: document.querySelector("#remote-action-title"),
|
|
209
|
+
approveButton: document.querySelector("#approve-button"),
|
|
210
|
+
attachmentList: document.querySelector("#attachment-list"),
|
|
211
|
+
dictationButton: document.querySelector("#dictation-button"),
|
|
212
|
+
dictationButtonLabel: document.querySelector("#dictation-button-label"),
|
|
213
|
+
dictationButtonMeta: document.querySelector("#dictation-button-meta"),
|
|
214
|
+
dictationIndicator: document.querySelector("#dictation-indicator"),
|
|
215
|
+
dictationIndicatorText: document.querySelector("#dictation-indicator-text"),
|
|
216
|
+
clearQueueButton: document.querySelector("#clear-queue-button"),
|
|
217
|
+
composerCloseButton: document.querySelector("#composer-close-button"),
|
|
218
|
+
composerForm: document.querySelector("#composer-form"),
|
|
219
|
+
composerControlButton: document.querySelector("#composer-control-button"),
|
|
220
|
+
composerQueueList: document.querySelector("#composer-queue-list"),
|
|
221
|
+
composerQueueShell: document.querySelector("#composer-queue-shell"),
|
|
222
|
+
composerScopeNote: document.querySelector("#composer-scope-note"),
|
|
223
|
+
composerShell: document.querySelector("#composer-shell"),
|
|
224
|
+
composerStatus: document.querySelector("#composer-status"),
|
|
225
|
+
composerSyncNote: document.querySelector("#composer-sync-note"),
|
|
226
|
+
composerTarget: document.querySelector("#composer-target"),
|
|
227
|
+
controlToggleButton: document.querySelector("#control-toggle-button"),
|
|
228
|
+
declineButton: document.querySelector("#decline-button"),
|
|
229
|
+
expandAllButton: document.querySelector("#expand-all-button"),
|
|
230
|
+
feed: document.querySelector("#remote-feed"),
|
|
231
|
+
companionSummonButtons: Array.from(document.querySelectorAll("[data-companion-summon]")),
|
|
232
|
+
filterButtons: Array.from(document.querySelectorAll("[data-filter]")),
|
|
233
|
+
marquee: document.querySelector("#remote-marquee"),
|
|
234
|
+
operatorDiagnostics: document.querySelector("#remote-operator-diagnostics"),
|
|
235
|
+
refreshButton: document.querySelector("#refresh-button"),
|
|
236
|
+
remoteScopeNote: document.querySelector("#remote-scope-note"),
|
|
237
|
+
remoteWindow: document.querySelector("#remote-window"),
|
|
238
|
+
statusPanel: document.querySelector("#remote-status-panel"),
|
|
239
|
+
sidebar: document.querySelector("#remote-sidebar"),
|
|
240
|
+
sidebarGroups: document.querySelector("#remote-sidebar-groups"),
|
|
241
|
+
sidebarOverlay: document.querySelector("#remote-sidebar-overlay"),
|
|
242
|
+
sidebarToggleButton: document.querySelector("#sidebar-toggle-button"),
|
|
243
|
+
remoteTarget: document.querySelector("#remote-target"),
|
|
244
|
+
remoteTitle: document.querySelector("#remote-title"),
|
|
245
|
+
uiStatus: document.querySelector("#remote-ui-status"),
|
|
246
|
+
replyImageInput: document.querySelector("#reply-image-input"),
|
|
247
|
+
replyText: document.querySelector("#reply-text"),
|
|
248
|
+
replyToggleButton: document.querySelector("#reply-toggle-button"),
|
|
249
|
+
queueReplyButton: document.querySelector("#queue-reply-button"),
|
|
250
|
+
sendReplyButton: document.querySelector("#send-reply-button")
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
function isIosTouchDevice() {
|
|
254
|
+
if (typeof navigator === "undefined") {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (IOS_FOCUS_PLATFORM_REGEX.test(navigator.userAgent || "")) {
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return navigator.platform === "MacIntel" && Number(navigator.maxTouchPoints || 0) > 1;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function configureIosViewport() {
|
|
266
|
+
if (!isIosTouchDevice()) {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const viewportMeta = document.querySelector('meta[name="viewport"]');
|
|
271
|
+
if (!viewportMeta) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
viewportMeta.setAttribute("content", IOS_VIEWPORT_MAX_SCALE_CONTENT);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function focusReplyTextAtEnd() {
|
|
279
|
+
if (!nodes.replyText) {
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const applyFocus = () => {
|
|
284
|
+
try {
|
|
285
|
+
nodes.replyText.focus({ preventScroll: true });
|
|
286
|
+
} catch {
|
|
287
|
+
nodes.replyText.focus();
|
|
288
|
+
}
|
|
289
|
+
const end = nodes.replyText.value.length;
|
|
290
|
+
if (typeof nodes.replyText.setSelectionRange === "function") {
|
|
291
|
+
nodes.replyText.setSelectionRange(end, end);
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
if (isIosTouchDevice()) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
window.setTimeout(applyFocus, 0);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
configureIosViewport();
|
|
303
|
+
|
|
304
|
+
const marqueeTicker = startTicker(nodes.marquee, [
|
|
305
|
+
"initializing session bridge...",
|
|
306
|
+
"tailing live codex events...",
|
|
307
|
+
"arming remote reply path..."
|
|
308
|
+
]);
|
|
309
|
+
|
|
310
|
+
function setComposerStatus(message, tone = "neutral") {
|
|
311
|
+
composerStatus = message;
|
|
312
|
+
composerStatusTone = tone;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function speechRecognitionSupported() {
|
|
316
|
+
return Boolean(getSpeechRecognitionCtor(window));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function dictationUiModel() {
|
|
320
|
+
const supported = speechRecognitionSupported();
|
|
321
|
+
if (!supported) {
|
|
322
|
+
return {
|
|
323
|
+
indicatorText: "Voice unavailable",
|
|
324
|
+
label: "Voice unavailable",
|
|
325
|
+
live: false,
|
|
326
|
+
meta: "Browser unsupported"
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (isDictating) {
|
|
331
|
+
return {
|
|
332
|
+
indicatorText: isDictationPressActive ? "Release to stop" : "Listening live",
|
|
333
|
+
label: isDictationPressActive ? "Release to stop" : "Stop voice",
|
|
334
|
+
live: true,
|
|
335
|
+
meta: dictationInterimText ? "Capturing speech" : "Listening"
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
indicatorText: "Hold to talk",
|
|
341
|
+
label: "Dictate",
|
|
342
|
+
live: false,
|
|
343
|
+
meta: "Hold to talk"
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function hasPointerSupport() {
|
|
348
|
+
return typeof window !== "undefined" && "PointerEvent" in window;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function dictationDraft() {
|
|
352
|
+
return composeDictationDraft({
|
|
353
|
+
baseText: dictationBaseText,
|
|
354
|
+
committedText: dictationCommittedText,
|
|
355
|
+
interimText: dictationInterimText
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function applyDictationDraft({ persist = false } = {}) {
|
|
360
|
+
const nextDraft = dictationDraft();
|
|
361
|
+
if (nodes.replyText.value !== nextDraft) {
|
|
362
|
+
nodes.replyText.value = nextDraft;
|
|
363
|
+
}
|
|
364
|
+
if (persist) {
|
|
365
|
+
persistDraft(draftThreadId || currentThreadId(), nextDraft);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function clearDictationState() {
|
|
370
|
+
activeDictation = null;
|
|
371
|
+
dictationBaseText = "";
|
|
372
|
+
dictationCommittedText = "";
|
|
373
|
+
dictationError = "";
|
|
374
|
+
dictationInterimText = "";
|
|
375
|
+
isDictationPressActive = false;
|
|
376
|
+
dictationPointerId = null;
|
|
377
|
+
isDictating = false;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function stopDictation() {
|
|
381
|
+
if (!activeDictation) {
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
try {
|
|
386
|
+
activeDictation.stop();
|
|
387
|
+
} catch {
|
|
388
|
+
clearDictationState();
|
|
389
|
+
setComposerStatus("Voice memo cancelled.");
|
|
390
|
+
render();
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function beginPressDictation(pointerId = null) {
|
|
395
|
+
if (isSendingReply || uiState.selecting || uiState.controlling) {
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const blockedReason = composeBlockedReason();
|
|
400
|
+
if (blockedReason) {
|
|
401
|
+
setComposerStatus(blockedReason, "error");
|
|
402
|
+
render();
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
isDictationPressActive = true;
|
|
407
|
+
dictationPointerId = pointerId;
|
|
408
|
+
suppressNextDictationClick = true;
|
|
409
|
+
|
|
410
|
+
if (!isDictating) {
|
|
411
|
+
try {
|
|
412
|
+
startDictation();
|
|
413
|
+
} catch (error) {
|
|
414
|
+
isDictationPressActive = false;
|
|
415
|
+
dictationPointerId = null;
|
|
416
|
+
setComposerStatus(error.message, "error");
|
|
417
|
+
render();
|
|
418
|
+
}
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
render();
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function endPressDictation(pointerId = null) {
|
|
426
|
+
if (!isDictationPressActive) {
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (pointerId !== null && dictationPointerId !== null && pointerId !== dictationPointerId) {
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
isDictationPressActive = false;
|
|
435
|
+
dictationPointerId = null;
|
|
436
|
+
if (isDictating) {
|
|
437
|
+
stopDictation();
|
|
438
|
+
} else {
|
|
439
|
+
render();
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
window.setTimeout(() => {
|
|
443
|
+
suppressNextDictationClick = false;
|
|
444
|
+
}, 0);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function finishDictation() {
|
|
448
|
+
const finalDraft = dictationDraft();
|
|
449
|
+
const hadTranscript = Boolean(finalDraft.trim());
|
|
450
|
+
const errorMessage = dictationError;
|
|
451
|
+
|
|
452
|
+
applyDictationDraft({ persist: true });
|
|
453
|
+
clearDictationState();
|
|
454
|
+
|
|
455
|
+
if (errorMessage) {
|
|
456
|
+
setComposerStatus(errorMessage, errorMessage === "Voice memo cancelled." ? "neutral" : "error");
|
|
457
|
+
} else if (hadTranscript) {
|
|
458
|
+
setComposerStatus("Voice memo ready. Queue or steer it.", "success");
|
|
459
|
+
scheduleComposerStatusReset(2400);
|
|
460
|
+
} else {
|
|
461
|
+
setComposerStatus("Voice memo cancelled.");
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function startDictation() {
|
|
466
|
+
const SpeechRecognitionCtor = getSpeechRecognitionCtor(window);
|
|
467
|
+
if (!SpeechRecognitionCtor) {
|
|
468
|
+
throw new Error("Voice memo is not available in this browser.");
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const blockedReason = composeBlockedReason();
|
|
472
|
+
if (blockedReason) {
|
|
473
|
+
throw new Error(blockedReason);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (!currentThreadId()) {
|
|
477
|
+
throw new Error("Select a session before dictating.");
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const recognition = new SpeechRecognitionCtor();
|
|
481
|
+
dictationBaseText = nodes.replyText.value.trimEnd();
|
|
482
|
+
dictationCommittedText = "";
|
|
483
|
+
dictationError = "";
|
|
484
|
+
dictationInterimText = "";
|
|
485
|
+
activeDictation = recognition;
|
|
486
|
+
|
|
487
|
+
if (!isComposerOpen) {
|
|
488
|
+
isComposerOpen = true;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
recognition.continuous = true;
|
|
492
|
+
recognition.interimResults = true;
|
|
493
|
+
recognition.maxAlternatives = 1;
|
|
494
|
+
recognition.lang = navigator.language || "en-US";
|
|
495
|
+
|
|
496
|
+
recognition.addEventListener("start", () => {
|
|
497
|
+
isDictating = true;
|
|
498
|
+
setComposerStatus("Listening...", "sending");
|
|
499
|
+
render();
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
recognition.addEventListener("result", (event) => {
|
|
503
|
+
const finals = [];
|
|
504
|
+
let interim = "";
|
|
505
|
+
|
|
506
|
+
for (let index = 0; index < event.results.length; index += 1) {
|
|
507
|
+
const result = event.results[index];
|
|
508
|
+
const transcript = String(result?.[0]?.transcript || "")
|
|
509
|
+
.replace(/\s+/g, " ")
|
|
510
|
+
.trim();
|
|
511
|
+
if (!transcript) {
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (result.isFinal) {
|
|
516
|
+
finals.push(transcript);
|
|
517
|
+
} else {
|
|
518
|
+
interim = transcript;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
dictationCommittedText = finals.join(" ");
|
|
523
|
+
dictationInterimText = interim;
|
|
524
|
+
applyDictationDraft();
|
|
525
|
+
setComposerStatus(interim ? "Listening..." : "Heard you. Tap stop when you're done.", "sending");
|
|
526
|
+
render();
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
recognition.addEventListener("error", (event) => {
|
|
530
|
+
dictationError = speechRecognitionErrorMessage(event.error);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
recognition.addEventListener("end", () => {
|
|
534
|
+
finishDictation();
|
|
535
|
+
render();
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
try {
|
|
539
|
+
recognition.start();
|
|
540
|
+
} catch (error) {
|
|
541
|
+
clearDictationState();
|
|
542
|
+
throw new Error(error?.message || "Voice memo could not start.");
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function settleSelectionIntent() {
|
|
547
|
+
if (!selectionIntent) {
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const result = reconcileSelectionIntent(selectionIntent, currentLiveState);
|
|
552
|
+
selectionIntent = result.intent;
|
|
553
|
+
if (result.settled) {
|
|
554
|
+
uiState.selecting = false;
|
|
555
|
+
noteRoomStatusHold(selectedThreadIdFromState(currentLiveState));
|
|
556
|
+
}
|
|
557
|
+
return result.settled;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function selectedThreadIdFromState(state = null) {
|
|
561
|
+
return String(state?.selectedThreadId || state?.selectedThreadSnapshot?.thread?.id || "").trim();
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function noteRoomStatusHold(threadId, holdMs = ROOM_STATUS_SETTLE_MS) {
|
|
565
|
+
const normalizedThreadId = String(threadId || "").trim();
|
|
566
|
+
if (!normalizedThreadId) {
|
|
567
|
+
roomStatusHoldThreadId = "";
|
|
568
|
+
roomStatusHoldUntil = 0;
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
roomStatusHoldThreadId = normalizedThreadId;
|
|
573
|
+
roomStatusHoldUntil = Date.now() + holdMs;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function syncRoomStatusHold(previousState = null, nextState = null) {
|
|
577
|
+
const previousThreadId = selectedThreadIdFromState(previousState);
|
|
578
|
+
const nextThreadId = selectedThreadIdFromState(nextState);
|
|
579
|
+
|
|
580
|
+
if (!nextThreadId) {
|
|
581
|
+
roomStatusHoldThreadId = "";
|
|
582
|
+
roomStatusHoldUntil = 0;
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (nextThreadId !== previousThreadId) {
|
|
587
|
+
noteRoomStatusHold(nextThreadId);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const previousWatcherConnected = Boolean(previousState?.status?.watcherConnected);
|
|
592
|
+
const nextWatcherConnected = Boolean(nextState?.status?.watcherConnected);
|
|
593
|
+
if (previousWatcherConnected && !nextWatcherConnected) {
|
|
594
|
+
noteRoomStatusHold(nextThreadId);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (roomStatusHoldThreadId && roomStatusHoldThreadId !== nextThreadId) {
|
|
599
|
+
roomStatusHoldThreadId = "";
|
|
600
|
+
roomStatusHoldUntil = 0;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function roomStatusPending(thread = null, status = null, snapshot = null) {
|
|
605
|
+
const threadId = String(thread?.id || "").trim();
|
|
606
|
+
if (!threadId) {
|
|
607
|
+
return false;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if (Boolean(snapshot?.transcriptHydrating)) {
|
|
611
|
+
return true;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (!Boolean(status?.watcherConnected)) {
|
|
615
|
+
return true;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return roomStatusHoldThreadId === threadId && Date.now() < roomStatusHoldUntil;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function draftStorageKey(threadId) {
|
|
622
|
+
return scopedThreadStorageKey({
|
|
623
|
+
prefix: DRAFT_STORAGE_PREFIX,
|
|
624
|
+
scopeId: surfaceClientId,
|
|
625
|
+
threadId
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function queueStorageKey(threadId) {
|
|
630
|
+
return scopedThreadStorageKey({
|
|
631
|
+
prefix: QUEUE_STORAGE_PREFIX,
|
|
632
|
+
scopeId: surfaceClientId,
|
|
633
|
+
threadId
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function legacyDraftStorageKey(threadId) {
|
|
638
|
+
return `${DRAFT_STORAGE_PREFIX}${threadId}`;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function legacyQueueStorageKey(threadId) {
|
|
642
|
+
return `${QUEUE_STORAGE_PREFIX}${threadId}`;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function loadPersistedDraft(threadId) {
|
|
646
|
+
if (!threadId) {
|
|
647
|
+
return "";
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
try {
|
|
651
|
+
const raw =
|
|
652
|
+
window.localStorage.getItem(draftStorageKey(threadId)) ||
|
|
653
|
+
window.localStorage.getItem(legacyDraftStorageKey(threadId));
|
|
654
|
+
if (!raw) {
|
|
655
|
+
return "";
|
|
656
|
+
}
|
|
657
|
+
const parsed = JSON.parse(raw);
|
|
658
|
+
return typeof parsed?.text === "string" ? parsed.text : "";
|
|
659
|
+
} catch {
|
|
660
|
+
return "";
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function loadPersistedQueue(threadId) {
|
|
665
|
+
if (!threadId) {
|
|
666
|
+
return [];
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
try {
|
|
670
|
+
const raw =
|
|
671
|
+
window.localStorage.getItem(queueStorageKey(threadId)) ||
|
|
672
|
+
window.localStorage.getItem(legacyQueueStorageKey(threadId));
|
|
673
|
+
if (!raw) {
|
|
674
|
+
return [];
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const parsed = JSON.parse(raw);
|
|
678
|
+
if (!Array.isArray(parsed)) {
|
|
679
|
+
return [];
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
return parsed
|
|
683
|
+
.map((reply) => ({
|
|
684
|
+
attachments: cloneReplyAttachments(Array.isArray(reply?.attachments) ? reply.attachments : []),
|
|
685
|
+
id: String(reply?.id || "").trim(),
|
|
686
|
+
queuedAt: String(reply?.queuedAt || "").trim(),
|
|
687
|
+
text: String(reply?.text || "").trim(),
|
|
688
|
+
threadId: String(reply?.threadId || threadId).trim()
|
|
689
|
+
}))
|
|
690
|
+
.filter((reply) => reply.id && reply.threadId && (reply.text || reply.attachments.length));
|
|
691
|
+
} catch {
|
|
692
|
+
return [];
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function persistDraft(threadId = draftThreadId || currentThreadId(), text = nodes.replyText.value) {
|
|
697
|
+
if (!threadId) {
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
try {
|
|
702
|
+
const nextText = String(text || "").trimEnd();
|
|
703
|
+
if (!nextText) {
|
|
704
|
+
window.localStorage.removeItem(draftStorageKey(threadId));
|
|
705
|
+
window.localStorage.removeItem(legacyDraftStorageKey(threadId));
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
window.localStorage.setItem(
|
|
710
|
+
draftStorageKey(threadId),
|
|
711
|
+
JSON.stringify({
|
|
712
|
+
text: nextText,
|
|
713
|
+
updatedAt: new Date().toISOString()
|
|
714
|
+
})
|
|
715
|
+
);
|
|
716
|
+
window.localStorage.removeItem(legacyDraftStorageKey(threadId));
|
|
717
|
+
} catch {
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function restoreDraft(threadId, { force = false } = {}) {
|
|
723
|
+
if (!threadId || isSendingReply || isDictating) {
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
if (!force && draftThreadId === threadId) {
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (draftThreadId && draftThreadId !== threadId) {
|
|
732
|
+
persistDraft(draftThreadId);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
draftThreadId = threadId;
|
|
736
|
+
if (pendingAttachments.length || pendingOutgoingText || pendingOutgoingAttachments.length) {
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const storedDraft = loadPersistedDraft(threadId);
|
|
741
|
+
if (nodes.replyText.value !== storedDraft) {
|
|
742
|
+
nodes.replyText.value = storedDraft;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function clearPersistedDraft(threadId = draftThreadId || currentThreadId()) {
|
|
747
|
+
if (!threadId) {
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
try {
|
|
752
|
+
window.localStorage.removeItem(draftStorageKey(threadId));
|
|
753
|
+
window.localStorage.removeItem(legacyDraftStorageKey(threadId));
|
|
754
|
+
} catch {
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function persistQueuedReplies(threadId, replies = queuedRepliesByThreadId.get(threadId) || []) {
|
|
760
|
+
if (!threadId) {
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
try {
|
|
765
|
+
if (!replies.length) {
|
|
766
|
+
window.localStorage.removeItem(queueStorageKey(threadId));
|
|
767
|
+
window.localStorage.removeItem(legacyQueueStorageKey(threadId));
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
window.localStorage.setItem(queueStorageKey(threadId), JSON.stringify(replies));
|
|
772
|
+
window.localStorage.removeItem(legacyQueueStorageKey(threadId));
|
|
773
|
+
} catch {
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function hasComposerPayload() {
|
|
779
|
+
return Boolean(nodes.replyText.value.trim() || pendingAttachments.length);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function clearStagedCompanionWakeKey() {
|
|
783
|
+
stagedCompanionWakeKey = "";
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function manualAdvisorLabel(advisorId) {
|
|
787
|
+
return advisorId ? "Note" : "Companion";
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function scheduleComposerStatusReset(delayMs = 1400) {
|
|
791
|
+
window.setTimeout(() => {
|
|
792
|
+
if (isSendingReply || isDictating || composerStatusTone === "error") {
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
setComposerStatus("Ready");
|
|
797
|
+
render();
|
|
798
|
+
}, delayMs);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function setUiStatus(message = "", tone = "neutral") {
|
|
802
|
+
nodes.uiStatus.textContent = message;
|
|
803
|
+
setPanelHidden(nodes.uiStatus, !message);
|
|
804
|
+
nodes.uiStatus.classList.toggle("is-busy", tone === "busy");
|
|
805
|
+
nodes.uiStatus.classList.toggle("is-error", tone === "error");
|
|
806
|
+
nodes.uiStatus.classList.toggle("is-success", tone === "success");
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function setTransientUiNotice(message, tone = "neutral", delayMs = 3200) {
|
|
810
|
+
transientUiNotice = { message, tone };
|
|
811
|
+
if (transientUiNoticeTimer) {
|
|
812
|
+
window.clearTimeout(transientUiNoticeTimer);
|
|
813
|
+
}
|
|
814
|
+
transientUiNoticeTimer = window.setTimeout(() => {
|
|
815
|
+
transientUiNoticeTimer = null;
|
|
816
|
+
transientUiNotice = null;
|
|
817
|
+
render();
|
|
818
|
+
}, delayMs);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function uiBusyNotice() {
|
|
822
|
+
const pending = currentLiveState?.pendingInteraction || actionHandoffState || null;
|
|
823
|
+
|
|
824
|
+
if (uiState.booting) {
|
|
825
|
+
return { message: "Connecting to Dextunnel...", tone: "busy" };
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
if (uiState.controlling) {
|
|
829
|
+
return { message: "Updating remote control...", tone: "busy" };
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
if (uiState.selecting) {
|
|
833
|
+
return { message: selectionIntentMessage(selectionIntent, "Switching shared room..."), tone: "busy" };
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
if (uiState.refreshing) {
|
|
837
|
+
return { message: "", tone: "neutral" };
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
if (uiState.submittingAction) {
|
|
841
|
+
return { message: interactionBusyNotice(pending, uiState.submittingAction), tone: "busy" };
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
if (companionActionState) {
|
|
845
|
+
return { message: "Updating shared note...", tone: "busy" };
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
if (manualAdvisorAction) {
|
|
849
|
+
return { message: "Preparing shared note...", tone: "busy" };
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
if (isSendingReply) {
|
|
853
|
+
return { message: "Sending remote reply...", tone: "busy" };
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
if (isDictating) {
|
|
857
|
+
return { message: "Listening for voice memo...", tone: "busy" };
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
if (uiState.loadingChanges && currentChanges == null) {
|
|
861
|
+
return { message: "Loading files and diffs...", tone: "busy" };
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
return { message: "", tone: "neutral" };
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function interactionBusyNotice(pending, action) {
|
|
868
|
+
const subject = interactionSubject(pending);
|
|
869
|
+
|
|
870
|
+
switch (action) {
|
|
871
|
+
case "approve":
|
|
872
|
+
return pending?.kind === "permissions" ? `Allowing ${subject}...` : `Approving ${subject}...`;
|
|
873
|
+
case "session":
|
|
874
|
+
return pending?.kind === "permissions" ? `Allowing ${subject} for session...` : `Approving ${subject} for session...`;
|
|
875
|
+
case "decline":
|
|
876
|
+
return `Declining ${subject}...`;
|
|
877
|
+
case "cancel":
|
|
878
|
+
return `Cancelling ${subject}...`;
|
|
879
|
+
case "submit":
|
|
880
|
+
default:
|
|
881
|
+
return `Submitting ${subject}...`;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function interactionSubject(pending) {
|
|
886
|
+
const raw = String(pending?.summary || pending?.kindLabel || pending?.kind || "request").trim();
|
|
887
|
+
const normalized = raw.replace(/\s+approval$/i, "").trim();
|
|
888
|
+
return normalized || "request";
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function interactionActionSummary(pending, action = "approve") {
|
|
892
|
+
const subject = interactionSubject(pending);
|
|
893
|
+
|
|
894
|
+
switch (action) {
|
|
895
|
+
case "decline":
|
|
896
|
+
return `Declined ${subject}.`;
|
|
897
|
+
case "cancel":
|
|
898
|
+
return pending?.actionKind === "user_input" ? `Cancelled ${subject}.` : `Declined ${subject}.`;
|
|
899
|
+
case "session":
|
|
900
|
+
return pending?.kind === "permissions" ? `Allowed ${subject} for session.` : `Approved ${subject} for session.`;
|
|
901
|
+
case "submit":
|
|
902
|
+
return `Submitted ${subject}.`;
|
|
903
|
+
case "approve":
|
|
904
|
+
default:
|
|
905
|
+
return pending?.kind === "permissions" ? `Allowed ${subject}.` : `Approved ${subject}.`;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function clearActionHandoff({ renderNow = true } = {}) {
|
|
910
|
+
if (actionHandoffTimer) {
|
|
911
|
+
window.clearTimeout(actionHandoffTimer);
|
|
912
|
+
actionHandoffTimer = null;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
if (!actionHandoffState) {
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
actionHandoffState = null;
|
|
920
|
+
if (renderNow) {
|
|
921
|
+
render();
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function beginActionHandoff(previousPending, nextState = currentLiveState, action = "approve") {
|
|
926
|
+
clearActionHandoff({ renderNow: false });
|
|
927
|
+
|
|
928
|
+
if (!previousPending) {
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
const liveThread = nextState?.selectedThreadSnapshot?.thread || null;
|
|
933
|
+
const busy = threadBusyState({
|
|
934
|
+
activeTurnId: liveThread?.activeTurnId || "",
|
|
935
|
+
threadStatus: liveThread?.status || "",
|
|
936
|
+
writeLockStatus: nextState?.status?.writeLock?.status || ""
|
|
937
|
+
});
|
|
938
|
+
if (!busy) {
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
const step = previousPending.flowStep || 1;
|
|
943
|
+
const submittedSummary = interactionActionSummary(previousPending, action);
|
|
944
|
+
actionHandoffState = {
|
|
945
|
+
actionKind: "handoff",
|
|
946
|
+
detail: `${submittedSummary} ${step > 1 ? "Waiting for the next request in this turn..." : "Waiting for Codex to continue..."}`.trim(),
|
|
947
|
+
flowContinuation: previousPending.summary ? `Last request: ${previousPending.summary}.` : previousPending.flowContinuation || "",
|
|
948
|
+
flowLabel: previousPending.flowLabel || "",
|
|
949
|
+
handoff: true,
|
|
950
|
+
kindLabel: "Waiting",
|
|
951
|
+
title: previousPending.actionKind === "user_input" ? "Input received" : "Decision received"
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
actionHandoffTimer = window.setTimeout(() => {
|
|
955
|
+
actionHandoffTimer = null;
|
|
956
|
+
actionHandoffState = null;
|
|
957
|
+
render();
|
|
958
|
+
}, 1800);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function markLiveActivity() {
|
|
962
|
+
lastLiveActivityAt = Date.now();
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function markUserIntent() {
|
|
966
|
+
lastUserIntentAt = Date.now();
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function remoteEngaged(threadId = currentThreadId()) {
|
|
970
|
+
return Boolean(
|
|
971
|
+
isComposerOpen ||
|
|
972
|
+
isSendingReply ||
|
|
973
|
+
pendingAttachments.length ||
|
|
974
|
+
pendingOutgoingAttachments.length ||
|
|
975
|
+
pendingOutgoingText.trim() ||
|
|
976
|
+
queuedRepliesForThread(threadId).length ||
|
|
977
|
+
currentLiveState?.pendingInteraction ||
|
|
978
|
+
hasRemoteControl(threadId) ||
|
|
979
|
+
Date.now() - lastUserIntentAt <= CONTROL_RENEW_ACTIVE_WINDOW_MS
|
|
980
|
+
);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function buildPresencePayload() {
|
|
984
|
+
const threadId = currentThreadId() || currentLiveState?.selectedThreadId || "";
|
|
985
|
+
if (!threadId) {
|
|
986
|
+
return null;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
return {
|
|
990
|
+
clientId: surfaceAuthClientId,
|
|
991
|
+
engaged: remoteEngaged(threadId),
|
|
992
|
+
focused: document.hasFocus(),
|
|
993
|
+
surface: "remote",
|
|
994
|
+
threadId,
|
|
995
|
+
visible: document.visibilityState === "visible"
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function localSurfaceAttachment() {
|
|
1000
|
+
const payload = buildPresencePayload();
|
|
1001
|
+
if (!payload) {
|
|
1002
|
+
return null;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
return {
|
|
1006
|
+
count: 1,
|
|
1007
|
+
label: "remote",
|
|
1008
|
+
state: payload.visible && payload.focused && payload.engaged ? "active" : payload.visible ? "open" : "background",
|
|
1009
|
+
surface: "remote"
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function sendDetachPresence() {
|
|
1014
|
+
const threadId = currentThreadId() || currentLiveState?.selectedThreadId || "";
|
|
1015
|
+
if (!threadId) {
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const payload = JSON.stringify({
|
|
1020
|
+
clientId: surfaceAuthClientId,
|
|
1021
|
+
detach: true,
|
|
1022
|
+
surface: "remote",
|
|
1023
|
+
threadId
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
if (navigator.sendBeacon) {
|
|
1027
|
+
navigator.sendBeacon(
|
|
1028
|
+
withSurfaceTokenUrl(presenceUrl, surfaceBootstrap.accessToken),
|
|
1029
|
+
new Blob([payload], { type: "application/json" })
|
|
1030
|
+
);
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
void fetch(presenceUrl, {
|
|
1035
|
+
method: "POST",
|
|
1036
|
+
headers: { "Content-Type": "application/json" },
|
|
1037
|
+
body: payload,
|
|
1038
|
+
keepalive: true
|
|
1039
|
+
}).catch(() => {});
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
async function syncPresence({ force = false } = {}) {
|
|
1043
|
+
if (!force && bridgeState.streamState !== "live") {
|
|
1044
|
+
return null;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
const payload = buildPresencePayload();
|
|
1048
|
+
if (!payload) {
|
|
1049
|
+
return null;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const signature = JSON.stringify(payload);
|
|
1053
|
+
if (!force && signature === lastPresenceSignature && Date.now() - lastPresenceSyncAt < PRESENCE_HEARTBEAT_INTERVAL_MS - 500) {
|
|
1054
|
+
return null;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
if (presenceSyncPromise) {
|
|
1058
|
+
return presenceSyncPromise;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
presenceSyncPromise = requestJson(presenceUrl, {
|
|
1062
|
+
method: "POST",
|
|
1063
|
+
headers: { "Content-Type": "application/json" },
|
|
1064
|
+
body: JSON.stringify(payload)
|
|
1065
|
+
})
|
|
1066
|
+
.then((response) => {
|
|
1067
|
+
lastPresenceSignature = signature;
|
|
1068
|
+
lastPresenceSyncAt = Date.now();
|
|
1069
|
+
return response;
|
|
1070
|
+
})
|
|
1071
|
+
.catch(() => null)
|
|
1072
|
+
.finally(() => {
|
|
1073
|
+
presenceSyncPromise = null;
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
return presenceSyncPromise;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function schedulePresenceSync(delayMs = 140, { force = false } = {}) {
|
|
1080
|
+
if (presenceSyncTimer) {
|
|
1081
|
+
window.clearTimeout(presenceSyncTimer);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
presenceSyncTimer = window.setTimeout(() => {
|
|
1085
|
+
presenceSyncTimer = null;
|
|
1086
|
+
void syncPresence({ force });
|
|
1087
|
+
}, delayMs);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
function currentThreadId() {
|
|
1091
|
+
return currentLiveState?.selectedThreadSnapshot?.thread?.id || currentLiveState?.selectedThreadId || "";
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
function controlLeaseForThread(threadId = currentThreadId()) {
|
|
1095
|
+
const lease = currentLiveState?.status?.controlLeaseForSelection || null;
|
|
1096
|
+
if (!lease) {
|
|
1097
|
+
return null;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
if (threadId && lease.threadId && lease.threadId !== threadId) {
|
|
1101
|
+
return null;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
return lease;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
function hasAnyRemoteControl(threadId = currentThreadId()) {
|
|
1108
|
+
const lease = controlLeaseForThread(threadId);
|
|
1109
|
+
return Boolean(lease && (lease.owner === "remote" || lease.source === "remote"));
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
function hasRemoteControl(threadId = currentThreadId()) {
|
|
1113
|
+
const lease = controlLeaseForThread(threadId);
|
|
1114
|
+
return Boolean(
|
|
1115
|
+
lease &&
|
|
1116
|
+
(lease.owner === "remote" || lease.source === "remote") &&
|
|
1117
|
+
(!lease.ownerClientId || lease.ownerClientId === surfaceAuthClientId)
|
|
1118
|
+
);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function describeControlEvent(event, { forRemote = false } = {}) {
|
|
1122
|
+
if (!event?.action) {
|
|
1123
|
+
return "";
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
const actorLabel = describeSurfaceActor(event.actor, event.actorClientId, { localSurface: "remote" });
|
|
1127
|
+
|
|
1128
|
+
if (event.action === "claim") {
|
|
1129
|
+
if (forRemote && event.actor === "remote" && event.actorClientId === surfaceAuthClientId) {
|
|
1130
|
+
return "Remote control active.";
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
return event.actor === "remote" ? `${actorLabel} control active.` : `${actorLabel} claimed control.`;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
if (event.action === "release") {
|
|
1137
|
+
if (event.cause === "expired") {
|
|
1138
|
+
return "Remote control expired.";
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
if (forRemote && event.actor === "host") {
|
|
1142
|
+
return `${actorLabel} released remote control.`;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
return `${actorLabel} released control.`;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
return "";
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function shortSurfaceClientLabel(clientId = "") {
|
|
1152
|
+
const normalized = String(clientId || "")
|
|
1153
|
+
.toLowerCase()
|
|
1154
|
+
.replace(/[^a-z0-9]+/g, "");
|
|
1155
|
+
if (!normalized) {
|
|
1156
|
+
return "";
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
return normalized.length > 4 ? normalized.slice(-4) : normalized;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function describeSurfaceActor(surface, clientId, { localSurface = "" } = {}) {
|
|
1163
|
+
const base = surface === "host" ? "Host" : "Remote";
|
|
1164
|
+
if (surface === localSurface && clientId === surfaceAuthClientId) {
|
|
1165
|
+
return surface === "remote" ? "This remote" : "This host";
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
const suffix = shortSurfaceClientLabel(clientId);
|
|
1169
|
+
return suffix ? `${base} ${suffix}` : base;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
function controlOwnerLabel(lease) {
|
|
1173
|
+
if (!lease) {
|
|
1174
|
+
return "";
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
return describeSurfaceActor(lease.source || lease.owner || "remote", lease.ownerClientId || null, {
|
|
1178
|
+
localSurface: "remote"
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function handleControlEventNotice(previousState, nextState) {
|
|
1183
|
+
const event = nextState?.status?.lastControlEventForSelection || null;
|
|
1184
|
+
if (!event?.id) {
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
if (!previousState) {
|
|
1189
|
+
lastHandledControlEventId = event.id;
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
if (lastHandledControlEventId === event.id) {
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
lastHandledControlEventId = event.id;
|
|
1198
|
+
const feedback = controlEventStatusState({
|
|
1199
|
+
event,
|
|
1200
|
+
hasDraft: Boolean(
|
|
1201
|
+
nodes.replyText.value.trim() ||
|
|
1202
|
+
pendingAttachments.length ||
|
|
1203
|
+
pendingOutgoingAttachments.length ||
|
|
1204
|
+
pendingOutgoingText.trim()
|
|
1205
|
+
),
|
|
1206
|
+
isLocalActor: event.actor === "remote" && event.actorClientId === surfaceAuthClientId,
|
|
1207
|
+
queuedCount: queuedRepliesForThread(currentThreadId()).length
|
|
1208
|
+
});
|
|
1209
|
+
if (feedback) {
|
|
1210
|
+
setComposerStatus(feedback, "success");
|
|
1211
|
+
scheduleComposerStatusReset(2600);
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
const message = describeControlEvent(event, { forRemote: true });
|
|
1216
|
+
if (message) {
|
|
1217
|
+
setTransientUiNotice(message, "neutral", 2000);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
function handleControlLeaseTransition(previousState, nextState) {
|
|
1222
|
+
const previousThreadId = previousState?.selectedThreadSnapshot?.thread?.id || previousState?.selectedThreadId || "";
|
|
1223
|
+
const nextThreadId = nextState?.selectedThreadSnapshot?.thread?.id || nextState?.selectedThreadId || "";
|
|
1224
|
+
if (!previousThreadId || !nextThreadId || previousThreadId !== nextThreadId) {
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
const previousLease = previousState?.status?.controlLeaseForSelection || null;
|
|
1229
|
+
const nextLease = nextState?.status?.controlLeaseForSelection || null;
|
|
1230
|
+
const previousHadRemoteControl = Boolean(previousLease && (previousLease.owner === "remote" || previousLease.source === "remote"));
|
|
1231
|
+
const nextHasRemoteControl = Boolean(nextLease && (nextLease.owner === "remote" || nextLease.source === "remote"));
|
|
1232
|
+
|
|
1233
|
+
const releaseFeedback = controlReleaseFeedbackState({
|
|
1234
|
+
hasDraft: Boolean(
|
|
1235
|
+
nodes.replyText.value.trim() ||
|
|
1236
|
+
pendingAttachments.length ||
|
|
1237
|
+
pendingOutgoingAttachments.length ||
|
|
1238
|
+
pendingOutgoingText.trim()
|
|
1239
|
+
),
|
|
1240
|
+
isControlling: uiState.controlling,
|
|
1241
|
+
isSendingReply,
|
|
1242
|
+
nextHasRemoteControl,
|
|
1243
|
+
previousHadRemoteControl
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
if (releaseFeedback) {
|
|
1247
|
+
setComposerStatus(releaseFeedback, "success");
|
|
1248
|
+
scheduleComposerStatusReset();
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
function controlBlockedReason(threadId = currentThreadId()) {
|
|
1253
|
+
const lease = controlLeaseForThread(threadId);
|
|
1254
|
+
return controlBlockedReasonState({
|
|
1255
|
+
hasAnyRemoteControl: hasAnyRemoteControl(threadId),
|
|
1256
|
+
hasRemoteControl: hasRemoteControl(threadId),
|
|
1257
|
+
ownerLabel: controlOwnerLabel(lease),
|
|
1258
|
+
threadId
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
function sendBlockedReason(threadId = currentThreadId()) {
|
|
1263
|
+
const lease = controlLeaseForThread(threadId);
|
|
1264
|
+
return sendBlockedReasonState({
|
|
1265
|
+
hasAnyRemoteControl: hasAnyRemoteControl(threadId),
|
|
1266
|
+
hasRemoteControl: hasRemoteControl(threadId),
|
|
1267
|
+
ownerLabel: controlOwnerLabel(lease),
|
|
1268
|
+
pendingInteraction: Boolean(currentLiveState?.pendingInteraction),
|
|
1269
|
+
sessionReason: sessionBlockedReason(),
|
|
1270
|
+
threadId
|
|
1271
|
+
});
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
function queuedRepliesForThread(threadId = currentThreadId()) {
|
|
1275
|
+
if (!threadId) {
|
|
1276
|
+
return [];
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
if (!queuedRepliesByThreadId.has(threadId)) {
|
|
1280
|
+
const storedQueue = loadPersistedQueue(threadId);
|
|
1281
|
+
if (storedQueue.length) {
|
|
1282
|
+
queuedRepliesByThreadId.set(threadId, storedQueue);
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
return queuedRepliesByThreadId.get(threadId) || [];
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
function setQueuedRepliesForThread(threadId, replies) {
|
|
1290
|
+
if (!threadId) {
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
if (!replies.length) {
|
|
1295
|
+
queuedRepliesByThreadId.delete(threadId);
|
|
1296
|
+
persistQueuedReplies(threadId, []);
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
queuedRepliesByThreadId.set(threadId, replies);
|
|
1301
|
+
persistQueuedReplies(threadId, replies);
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
function removeQueuedReply(threadId, replyId) {
|
|
1305
|
+
if (!threadId || !replyId) {
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
const nextQueue = queuedRepliesForThread(threadId).filter((reply) => reply.id !== replyId);
|
|
1310
|
+
setQueuedRepliesForThread(threadId, nextQueue);
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
function clearQueuedReplies(threadId = currentThreadId()) {
|
|
1314
|
+
if (!threadId) {
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
setQueuedRepliesForThread(threadId, []);
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
function resetComposerDraft({ keepStatus = false } = {}) {
|
|
1322
|
+
if (isDictating) {
|
|
1323
|
+
stopDictation();
|
|
1324
|
+
}
|
|
1325
|
+
pendingOutgoingText = "";
|
|
1326
|
+
pendingOutgoingAttachments = [];
|
|
1327
|
+
pendingAttachments = [];
|
|
1328
|
+
isComposerOpen = false;
|
|
1329
|
+
isSendingReply = false;
|
|
1330
|
+
nodes.replyText.value = "";
|
|
1331
|
+
nodes.replyImageInput.value = "";
|
|
1332
|
+
renderAttachments();
|
|
1333
|
+
if (!keepStatus) {
|
|
1334
|
+
setComposerStatus("Ready");
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function formatAttachmentSize(bytes) {
|
|
1339
|
+
if (!Number.isFinite(bytes) || bytes <= 0) {
|
|
1340
|
+
return "";
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
if (bytes >= 1024 * 1024) {
|
|
1344
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
if (bytes >= 1024) {
|
|
1348
|
+
return `${Math.round(bytes / 1024)} KB`;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
return `${bytes} B`;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
function summarizeReplyPayload({ text = "", attachments = [] } = {}) {
|
|
1355
|
+
const parts = [];
|
|
1356
|
+
const trimmed = String(text || "").trim();
|
|
1357
|
+
if (trimmed) {
|
|
1358
|
+
parts.push(trimmed);
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
if (attachments.length) {
|
|
1362
|
+
const label = attachments.length === 1 ? "image attached" : "images attached";
|
|
1363
|
+
parts.push(`[${attachments.length} ${label}]`);
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
return parts.join("\n\n");
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
function shortPathLabel(value) {
|
|
1370
|
+
const parts = String(value || "")
|
|
1371
|
+
.split("/")
|
|
1372
|
+
.filter(Boolean);
|
|
1373
|
+
|
|
1374
|
+
return parts.at(-1) || value || "";
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
function remoteParticipant() {
|
|
1378
|
+
return {
|
|
1379
|
+
id: "remote",
|
|
1380
|
+
label: "remote",
|
|
1381
|
+
lane: "remote",
|
|
1382
|
+
role: "live",
|
|
1383
|
+
token: "remote"
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
function renderAttachments() {
|
|
1388
|
+
if (!pendingAttachments.length) {
|
|
1389
|
+
setHtmlIfChanged(nodes.attachmentList, "", "attachments:empty");
|
|
1390
|
+
setPanelHidden(nodes.attachmentList, true);
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
setPanelHidden(nodes.attachmentList, false);
|
|
1395
|
+
const html = pendingAttachments
|
|
1396
|
+
.map((attachment) => {
|
|
1397
|
+
const meta = [attachment.type?.replace("image/", "") || "image", formatAttachmentSize(attachment.size)]
|
|
1398
|
+
.filter(Boolean)
|
|
1399
|
+
.join(" / ");
|
|
1400
|
+
|
|
1401
|
+
return `
|
|
1402
|
+
<article class="attachment-chip">
|
|
1403
|
+
<img src="${attachment.dataUrl}" alt="${escapeHtml(attachment.name || "attachment preview")}" class="attachment-preview">
|
|
1404
|
+
<div class="attachment-copy">
|
|
1405
|
+
<strong>${escapeHtml(attachment.name || "image")}</strong>
|
|
1406
|
+
${meta ? `<span>${escapeHtml(meta)}</span>` : ""}
|
|
1407
|
+
</div>
|
|
1408
|
+
<button type="button" class="attachment-remove" data-attachment-id="${escapeHtml(attachment.id)}">Remove</button>
|
|
1409
|
+
</article>
|
|
1410
|
+
`;
|
|
1411
|
+
})
|
|
1412
|
+
.join("");
|
|
1413
|
+
const signature = `attachments:${pendingAttachments.map((attachment) => `${attachment.id}|${attachment.name}|${attachment.size}`).join("||")}`;
|
|
1414
|
+
setHtmlIfChanged(nodes.attachmentList, html, signature);
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
function queueEntryPreview(reply) {
|
|
1418
|
+
return summarizeReplyPayload(reply).replace(/\s+/g, " ").trim();
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
function renderQueuePanel() {
|
|
1422
|
+
const queue = queuedRepliesForThread();
|
|
1423
|
+
if (!queue.length) {
|
|
1424
|
+
setPanelHidden(nodes.composerQueueShell, true);
|
|
1425
|
+
setHtmlIfChanged(nodes.composerQueueList, "", "queue:empty");
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
const html = queue
|
|
1430
|
+
.map((reply, index) => `
|
|
1431
|
+
<article class="queue-item">
|
|
1432
|
+
<div class="queue-item-copy">
|
|
1433
|
+
<span class="queue-item-slot">${index + 1}</span>
|
|
1434
|
+
<span class="queue-item-preview">${escapeHtml(queueEntryPreview(reply) || "Queued reply")}</span>
|
|
1435
|
+
</div>
|
|
1436
|
+
<button
|
|
1437
|
+
type="button"
|
|
1438
|
+
class="queue-item-remove"
|
|
1439
|
+
data-queued-reply-id="${escapeHtml(reply.id)}"
|
|
1440
|
+
aria-label="Remove queued reply ${index + 1}"
|
|
1441
|
+
>Remove</button>
|
|
1442
|
+
</article>
|
|
1443
|
+
`)
|
|
1444
|
+
.join("");
|
|
1445
|
+
const signature = `queue:${queue.map((reply) => `${reply.id}|${reply.queuedAt}|${queueEntryPreview(reply)}`).join("||")}`;
|
|
1446
|
+
setPanelHidden(nodes.composerQueueShell, false);
|
|
1447
|
+
setHtmlIfChanged(nodes.composerQueueList, html, signature);
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
function clearAttachments() {
|
|
1451
|
+
pendingAttachments = [];
|
|
1452
|
+
nodes.replyImageInput.value = "";
|
|
1453
|
+
renderAttachments();
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
function changesSummaryText() {
|
|
1457
|
+
if (currentChanges == null) {
|
|
1458
|
+
return uiState.loadingChanges ? "Loading changes..." : "No repo diff";
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
if (!currentChanges.supported) {
|
|
1462
|
+
return "No repo diff";
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
const count = currentChanges.shownCount || currentChanges.items?.length || currentChanges.totalCount || 0;
|
|
1466
|
+
const hiddenLabel = currentChanges.hiddenCount ? ` // +${currentChanges.hiddenCount} more` : "";
|
|
1467
|
+
const sourceLabel =
|
|
1468
|
+
currentChanges.source === "live_turn"
|
|
1469
|
+
? " // live turn"
|
|
1470
|
+
: currentChanges.source === "git_session"
|
|
1471
|
+
? " // session focus"
|
|
1472
|
+
: "";
|
|
1473
|
+
const focusLabel = currentChanges.focusPaths?.length
|
|
1474
|
+
? ` // focus ${shortPathLabel(currentChanges.focusPaths[0])}${currentChanges.focusPaths.length > 1 ? ` +${currentChanges.focusPaths.length - 1}` : ""}`
|
|
1475
|
+
: "";
|
|
1476
|
+
|
|
1477
|
+
return `${count === 1 ? "1 file" : `${count} files`}${hiddenLabel}${sourceLabel}${focusLabel}${uiState.loadingChanges ? " // syncing" : ""}`;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
function currentThreadIdLabel() {
|
|
1481
|
+
return currentLiveState?.selectedThreadId || "";
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
function feedSectionKey(name) {
|
|
1485
|
+
return `${name}:${currentThreadIdLabel() || "none"}`;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
function changesSectionKey() {
|
|
1489
|
+
return feedSectionKey("changes");
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
function renderChangesSection() {
|
|
1493
|
+
const summary = changesSummaryText();
|
|
1494
|
+
const open = expandedFeedSections.has(changesSectionKey());
|
|
1495
|
+
|
|
1496
|
+
if (currentChanges == null) {
|
|
1497
|
+
return {
|
|
1498
|
+
html: `
|
|
1499
|
+
<details class="feed-section feed-section-changes feed-section-details" data-feed-section="changes"${open ? " open" : ""}>
|
|
1500
|
+
<summary class="feed-section-summary">
|
|
1501
|
+
<div>
|
|
1502
|
+
<p class="feed-section-kicker">Changes</p>
|
|
1503
|
+
</div>
|
|
1504
|
+
<span class="inline-status ${uiState.loadingChanges ? "is-busy" : ""}">${escapeHtml(summary)}</span>
|
|
1505
|
+
</summary>
|
|
1506
|
+
<div class="changes-list">
|
|
1507
|
+
<div class="empty-card">Loading changes for the selected session...</div>
|
|
1508
|
+
</div>
|
|
1509
|
+
</details>
|
|
1510
|
+
`,
|
|
1511
|
+
signature: `changes:loading:${open ? "open" : "closed"}`
|
|
1512
|
+
};
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
if (!currentChanges.supported) {
|
|
1516
|
+
return {
|
|
1517
|
+
html: `
|
|
1518
|
+
<details class="feed-section feed-section-changes feed-section-details" data-feed-section="changes"${open ? " open" : ""}>
|
|
1519
|
+
<summary class="feed-section-summary">
|
|
1520
|
+
<div>
|
|
1521
|
+
<p class="feed-section-kicker">Changes</p>
|
|
1522
|
+
</div>
|
|
1523
|
+
<span class="inline-status">${escapeHtml(summary)}</span>
|
|
1524
|
+
</summary>
|
|
1525
|
+
<div class="changes-list">
|
|
1526
|
+
<div class="empty-card">No Git diff available for this project.</div>
|
|
1527
|
+
</div>
|
|
1528
|
+
</details>
|
|
1529
|
+
`,
|
|
1530
|
+
signature: `changes:unsupported:${open ? "open" : "closed"}`
|
|
1531
|
+
};
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
const count = currentChanges.shownCount || currentChanges.items?.length || currentChanges.totalCount || 0;
|
|
1535
|
+
const html = count
|
|
1536
|
+
? currentChanges.items
|
|
1537
|
+
.map((change, index) => renderChangeCard(change, { open: index === 0 && count <= 3 }))
|
|
1538
|
+
.join("")
|
|
1539
|
+
: `<div class="empty-card">${currentChanges.source === "live_turn" ? "No live diff yet." : "Working tree is clean."}</div>`;
|
|
1540
|
+
const signature = JSON.stringify({
|
|
1541
|
+
count,
|
|
1542
|
+
cwd: currentChanges.cwd || "",
|
|
1543
|
+
source: currentChanges.source || "",
|
|
1544
|
+
focusPaths: currentChanges.focusPaths || [],
|
|
1545
|
+
hiddenCount: currentChanges.hiddenCount || 0,
|
|
1546
|
+
items: (currentChanges.items || []).map((change) => ({
|
|
1547
|
+
additions: change.additions,
|
|
1548
|
+
deletions: change.deletions,
|
|
1549
|
+
diffPreview: change.diffPreview,
|
|
1550
|
+
fromPath: change.fromPath,
|
|
1551
|
+
kind: change.kind,
|
|
1552
|
+
path: change.path,
|
|
1553
|
+
relevance: change.relevance,
|
|
1554
|
+
statusCode: change.statusCode
|
|
1555
|
+
}))
|
|
1556
|
+
});
|
|
1557
|
+
|
|
1558
|
+
return {
|
|
1559
|
+
html: `
|
|
1560
|
+
<details class="feed-section feed-section-changes feed-section-details" data-feed-section="changes"${open ? " open" : ""}>
|
|
1561
|
+
<summary class="feed-section-summary">
|
|
1562
|
+
<div>
|
|
1563
|
+
<p class="feed-section-kicker">Changes</p>
|
|
1564
|
+
</div>
|
|
1565
|
+
<span class="inline-status ${uiState.loadingChanges ? "is-busy" : ""}">${escapeHtml(summary)}</span>
|
|
1566
|
+
</summary>
|
|
1567
|
+
<div class="changes-list">${html}</div>
|
|
1568
|
+
</details>
|
|
1569
|
+
`,
|
|
1570
|
+
signature: `changes:${open ? "open" : "closed"}:${signature}`
|
|
1571
|
+
};
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
function renderFeedItems(entries) {
|
|
1575
|
+
return entries.map((entry) => {
|
|
1576
|
+
const nextEntry = { ...entry };
|
|
1577
|
+
if (entry?.participant?.role === "advisory" && entry.kind === "commentary") {
|
|
1578
|
+
const stageAction =
|
|
1579
|
+
stagedCompanionWakeKey && stagedCompanionWakeKey === entry.key
|
|
1580
|
+
? {
|
|
1581
|
+
action: "stage",
|
|
1582
|
+
disabled: true,
|
|
1583
|
+
label: "Staged",
|
|
1584
|
+
tone: "success"
|
|
1585
|
+
}
|
|
1586
|
+
: {
|
|
1587
|
+
action: "stage",
|
|
1588
|
+
label: entry.wakeKind === "review" ? "Stage review" : "Stage recap"
|
|
1589
|
+
};
|
|
1590
|
+
nextEntry.actions = [stageAction, ...(entry.actions || [])];
|
|
1591
|
+
}
|
|
1592
|
+
if (companionActionState?.key && entry.key === companionActionState.key) {
|
|
1593
|
+
nextEntry.actionState = {
|
|
1594
|
+
action: companionActionState.action,
|
|
1595
|
+
busy: true
|
|
1596
|
+
};
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
const isExpanded = expandAllCards || expandedEntryKeys.has(entryKey(entry));
|
|
1600
|
+
const isNew = isNewCard(entry);
|
|
1601
|
+
return {
|
|
1602
|
+
html: renderTranscriptCard(nextEntry, {
|
|
1603
|
+
expanded: isExpanded,
|
|
1604
|
+
isNew
|
|
1605
|
+
}),
|
|
1606
|
+
key: entryKey(entry),
|
|
1607
|
+
signature: JSON.stringify({
|
|
1608
|
+
actionState: nextEntry.actionState || null,
|
|
1609
|
+
actions: nextEntry.actions || null,
|
|
1610
|
+
entry: nextEntry,
|
|
1611
|
+
expanded: isExpanded
|
|
1612
|
+
})
|
|
1613
|
+
};
|
|
1614
|
+
});
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
function renderSupplementalSection(name, { kicker, title, entries, summary }) {
|
|
1618
|
+
if (!entries.length) {
|
|
1619
|
+
return null;
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
const open = expandedFeedSections.has(feedSectionKey(name));
|
|
1623
|
+
const items = renderFeedItems(entries);
|
|
1624
|
+
return {
|
|
1625
|
+
html: `
|
|
1626
|
+
<details class="feed-section feed-section-details" data-feed-section="${escapeHtml(name)}"${open ? " open" : ""}>
|
|
1627
|
+
<summary class="feed-section-summary">
|
|
1628
|
+
<div>
|
|
1629
|
+
<p class="feed-section-kicker">${escapeHtml(kicker)}</p>
|
|
1630
|
+
<h3>${escapeHtml(title)}</h3>
|
|
1631
|
+
</div>
|
|
1632
|
+
<span class="inline-status">${escapeHtml(summary)}</span>
|
|
1633
|
+
</summary>
|
|
1634
|
+
<div class="feed-list">${items.map((item) => item.html).join("")}</div>
|
|
1635
|
+
</details>
|
|
1636
|
+
`,
|
|
1637
|
+
key: `__section__:${name}`,
|
|
1638
|
+
signature: JSON.stringify({
|
|
1639
|
+
items: items.map((item) => item.signature),
|
|
1640
|
+
name,
|
|
1641
|
+
open
|
|
1642
|
+
})
|
|
1643
|
+
};
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
function fileToDataUrl(file) {
|
|
1647
|
+
return new Promise((resolve, reject) => {
|
|
1648
|
+
const reader = new FileReader();
|
|
1649
|
+
reader.onload = () => resolve(String(reader.result || ""));
|
|
1650
|
+
reader.onerror = () => reject(new Error(`Failed to read ${file.name}.`));
|
|
1651
|
+
reader.readAsDataURL(file);
|
|
1652
|
+
});
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
function resetCardHistoryIfNeeded() {
|
|
1656
|
+
const threadId = currentLiveState?.selectedThreadSnapshot?.thread?.id || null;
|
|
1657
|
+
if (threadId === renderedThreadId) {
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
restoreDraft(threadId, { force: true });
|
|
1662
|
+
renderedThreadId = threadId;
|
|
1663
|
+
pendingScrollToLatest = true;
|
|
1664
|
+
lastRenderedFeedTopKey = null;
|
|
1665
|
+
expandAllCards = surfaceViewState.loadExpansionMode(threadId || "none") === "expanded";
|
|
1666
|
+
seenCardKeys.clear();
|
|
1667
|
+
expandedEntryKeys.clear();
|
|
1668
|
+
expandedFeedSections.clear();
|
|
1669
|
+
for (const key of surfaceViewState.loadExpandedSections(threadId || "none")) {
|
|
1670
|
+
expandedFeedSections.add(key);
|
|
1671
|
+
}
|
|
1672
|
+
const historyState = historyStateForThread(threadId);
|
|
1673
|
+
if (historyState && historyState.items.length === 0) {
|
|
1674
|
+
historyState.awaitingUserScroll = false;
|
|
1675
|
+
historyState.beforeIndex = null;
|
|
1676
|
+
historyState.hasMore = true;
|
|
1677
|
+
historyState.loading = false;
|
|
1678
|
+
historyState.resumeAfterScrollY = null;
|
|
1679
|
+
}
|
|
1680
|
+
hasRenderedOnce = false;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
function historyStateForThread(threadId = currentThreadId()) {
|
|
1684
|
+
if (!threadId) {
|
|
1685
|
+
return null;
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
if (!transcriptHistoryByThreadId.has(threadId)) {
|
|
1689
|
+
transcriptHistoryByThreadId.set(threadId, {
|
|
1690
|
+
awaitingUserScroll: false,
|
|
1691
|
+
beforeIndex: null,
|
|
1692
|
+
hasMore: true,
|
|
1693
|
+
items: [],
|
|
1694
|
+
loading: false,
|
|
1695
|
+
resumeAfterScrollY: null
|
|
1696
|
+
});
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
return transcriptHistoryByThreadId.get(threadId);
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
function entryMatchesFeedFilter(entry) {
|
|
1703
|
+
if (!entry) {
|
|
1704
|
+
return false;
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
if (isAdvisoryEntry(entry)) {
|
|
1708
|
+
return false;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
if (entry.role === "tool") {
|
|
1712
|
+
return Boolean(feedFilters.tools);
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
if (isConversationEntry(entry)) {
|
|
1716
|
+
return Boolean(feedFilters.thread);
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
if (isSystemNoticeEntry(entry)) {
|
|
1720
|
+
return false;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
return Boolean(feedFilters.updates);
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
function mergeTranscriptEntries(...groups) {
|
|
1727
|
+
const merged = [];
|
|
1728
|
+
const seen = new Set();
|
|
1729
|
+
|
|
1730
|
+
for (const group of groups) {
|
|
1731
|
+
for (const entry of Array.isArray(group) ? group : []) {
|
|
1732
|
+
const key = entryDedupKey(entry);
|
|
1733
|
+
if (!key || seen.has(key)) {
|
|
1734
|
+
continue;
|
|
1735
|
+
}
|
|
1736
|
+
seen.add(key);
|
|
1737
|
+
merged.push(entry);
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
return merged;
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
function currentTranscriptEntries() {
|
|
1745
|
+
const recentTranscript = currentSurfaceTranscript({
|
|
1746
|
+
bootstrapSnapshot: currentSnapshot,
|
|
1747
|
+
liveState: currentLiveState
|
|
1748
|
+
});
|
|
1749
|
+
const historyItems = historyStateForThread()?.items || [];
|
|
1750
|
+
|
|
1751
|
+
return mergeTranscriptEntries(historyItems, recentTranscript)
|
|
1752
|
+
.map((entry, index) => ({
|
|
1753
|
+
...entry,
|
|
1754
|
+
transcriptOrder: index
|
|
1755
|
+
}))
|
|
1756
|
+
.slice()
|
|
1757
|
+
.sort(compareEntryChronology);
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
function isNewCard(entry) {
|
|
1761
|
+
const key = entryKey(entry);
|
|
1762
|
+
if (!hasRenderedOnce) {
|
|
1763
|
+
seenCardKeys.add(key);
|
|
1764
|
+
return false;
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
if (seenCardKeys.has(key)) {
|
|
1768
|
+
return false;
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
seenCardKeys.add(key);
|
|
1772
|
+
return true;
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
function buildEntries() {
|
|
1776
|
+
const transcript = currentTranscriptEntries();
|
|
1777
|
+
const companionWakeups = currentLiveState?.selectedCompanion?.wakeups || [];
|
|
1778
|
+
const entries = [...companionWakeups, ...transcript]
|
|
1779
|
+
.filter((entry) => !shouldHideTranscriptEntry(entry));
|
|
1780
|
+
|
|
1781
|
+
const syntheticEntries = [];
|
|
1782
|
+
|
|
1783
|
+
if (pendingOutgoingText || pendingOutgoingAttachments.length) {
|
|
1784
|
+
const pendingText = summarizeReplyPayload({
|
|
1785
|
+
attachments: pendingOutgoingAttachments,
|
|
1786
|
+
text: pendingOutgoingText
|
|
1787
|
+
});
|
|
1788
|
+
syntheticEntries.push({
|
|
1789
|
+
id: "pending-live-reply",
|
|
1790
|
+
lane: "remote",
|
|
1791
|
+
origin: "remote",
|
|
1792
|
+
participant: remoteParticipant(),
|
|
1793
|
+
role: "user",
|
|
1794
|
+
kind: "pending",
|
|
1795
|
+
text: pendingText,
|
|
1796
|
+
timestamp: new Date().toISOString()
|
|
1797
|
+
});
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
const queue = queuedRepliesForThread();
|
|
1801
|
+
if (queue.length) {
|
|
1802
|
+
queue.forEach((reply, index) => {
|
|
1803
|
+
syntheticEntries.push({
|
|
1804
|
+
id: reply.id,
|
|
1805
|
+
lane: "remote",
|
|
1806
|
+
origin: "remote",
|
|
1807
|
+
participant: remoteParticipant(),
|
|
1808
|
+
queuePosition: index + 1,
|
|
1809
|
+
role: "user",
|
|
1810
|
+
kind: "queued",
|
|
1811
|
+
text: summarizeReplyPayload(reply),
|
|
1812
|
+
timestamp: reply.queuedAt
|
|
1813
|
+
});
|
|
1814
|
+
});
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
return [...syntheticEntries, ...entries];
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
function advisoryDraftForEntry(entry) {
|
|
1821
|
+
if (entry?.wakeKind === "review") {
|
|
1822
|
+
return "Please do a quick risk review of the latest changes and call out anything risky, surprising, or missing.";
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
return "Please give me a concise recap of the last settled step in 3-5 bullets, plus any follow-up I should keep in mind.";
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
function stageCompanionPrompt(entry) {
|
|
1829
|
+
const prompt = advisoryDraftForEntry(entry);
|
|
1830
|
+
const currentValue = nodes.replyText.value.trim();
|
|
1831
|
+
const alreadyStaged = Boolean(currentValue) && currentValue.includes(prompt);
|
|
1832
|
+
if (!currentValue) {
|
|
1833
|
+
nodes.replyText.value = prompt;
|
|
1834
|
+
} else if (!alreadyStaged) {
|
|
1835
|
+
nodes.replyText.value = `${nodes.replyText.value.trimEnd()}\n\n${prompt}`;
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
stagedCompanionWakeKey = entry?.key || "";
|
|
1839
|
+
isComposerOpen = true;
|
|
1840
|
+
persistDraft();
|
|
1841
|
+
const wakeLabel = entry?.wakeKind === "review" ? "Review" : "Recap";
|
|
1842
|
+
setComposerStatus(
|
|
1843
|
+
hasRemoteControl()
|
|
1844
|
+
? `${wakeLabel} staged. Review before send.`
|
|
1845
|
+
: `${wakeLabel} staged. Take control to send.`,
|
|
1846
|
+
"success"
|
|
1847
|
+
);
|
|
1848
|
+
setTransientUiNotice(`${wakeLabel} prompt staged.`, "success", 2200);
|
|
1849
|
+
render();
|
|
1850
|
+
scheduleComposerStatusReset(1800);
|
|
1851
|
+
focusReplyTextAtEnd();
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
function renderFilterButtons(entries) {
|
|
1855
|
+
for (const button of nodes.filterButtons) {
|
|
1856
|
+
const filter = button.dataset.filter;
|
|
1857
|
+
const active = Boolean(feedFilters[filter]);
|
|
1858
|
+
button.classList.toggle("is-active", active);
|
|
1859
|
+
button.textContent = humanize(filter);
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
if (nodes.expandAllButton) {
|
|
1863
|
+
nodes.expandAllButton.textContent = expandAllCards ? "Collapse" : "Expand";
|
|
1864
|
+
nodes.expandAllButton.classList.toggle("is-active", expandAllCards);
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
function isMobileSidebarLayout() {
|
|
1869
|
+
return globalThis.innerWidth <= SIDEBAR_MOBILE_BREAKPOINT_PX;
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
function setSidebarExpanded(nextExpanded, { persist = true } = {}) {
|
|
1873
|
+
sidebarExpanded = Boolean(nextExpanded);
|
|
1874
|
+
if (persist) {
|
|
1875
|
+
surfaceViewState.saveSidebarMode(sidebarExpanded ? "expanded" : "collapsed");
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
let lastSidebarMobileLayout = isMobileSidebarLayout();
|
|
1880
|
+
|
|
1881
|
+
function sidebarThreadTimestamp(thread) {
|
|
1882
|
+
return formatSessionTimestamp(thread?.updatedAt || 0) || "";
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
function renderSidebar() {
|
|
1886
|
+
const threads = currentLiveState?.threads || [];
|
|
1887
|
+
const groups = groupThreadsByProject(threads);
|
|
1888
|
+
const selectedProject = currentLiveState?.selectedProjectCwd || "";
|
|
1889
|
+
const selectedThread = currentLiveState?.selectedThreadId || "";
|
|
1890
|
+
const pendingThreadId = uiState.selecting ? selectionIntent?.threadId || "" : "";
|
|
1891
|
+
const disabled = uiState.selecting || uiState.refreshing || uiState.booting || isDictating;
|
|
1892
|
+
|
|
1893
|
+
if (nodes.remoteWindow) {
|
|
1894
|
+
nodes.remoteWindow.classList.toggle("is-sidebar-open", sidebarExpanded);
|
|
1895
|
+
}
|
|
1896
|
+
if (nodes.sidebarOverlay) {
|
|
1897
|
+
setPanelHidden(nodes.sidebarOverlay, !(sidebarExpanded && isMobileSidebarLayout()));
|
|
1898
|
+
}
|
|
1899
|
+
if (nodes.sidebarToggleButton) {
|
|
1900
|
+
nodes.sidebarToggleButton.setAttribute("aria-expanded", sidebarExpanded ? "true" : "false");
|
|
1901
|
+
nodes.sidebarToggleButton.setAttribute("aria-label", sidebarExpanded ? "Collapse thread menu" : "Expand thread menu");
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
if (groups.length === 0) {
|
|
1905
|
+
setHtmlIfChanged(
|
|
1906
|
+
nodes.sidebarGroups,
|
|
1907
|
+
'<div class="remote-sidebar-empty">No shared threads yet.</div>',
|
|
1908
|
+
"remote-sidebar:empty"
|
|
1909
|
+
);
|
|
1910
|
+
return;
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
const html = groups
|
|
1914
|
+
.map((group) => {
|
|
1915
|
+
const rows = group.threads
|
|
1916
|
+
.map((thread) => {
|
|
1917
|
+
const selected = thread.id === selectedThread;
|
|
1918
|
+
const pending = !selected && thread.id === pendingThreadId;
|
|
1919
|
+
const classes = ["remote-thread-row"];
|
|
1920
|
+
if (selected) {
|
|
1921
|
+
classes.push("is-selected");
|
|
1922
|
+
}
|
|
1923
|
+
if (pending) {
|
|
1924
|
+
classes.push("is-pending");
|
|
1925
|
+
}
|
|
1926
|
+
const stamp = sidebarThreadTimestamp(thread);
|
|
1927
|
+
return `
|
|
1928
|
+
<button
|
|
1929
|
+
type="button"
|
|
1930
|
+
class="${classes.join(" ")}"
|
|
1931
|
+
data-sidebar-thread-id="${escapeHtml(thread.id)}"
|
|
1932
|
+
data-sidebar-cwd="${escapeHtml(thread.cwd || group.cwd || "")}"
|
|
1933
|
+
${disabled ? "disabled" : ""}
|
|
1934
|
+
>
|
|
1935
|
+
<span class="remote-thread-row-title">${escapeHtml(threadDisplayTitle(thread))}</span>
|
|
1936
|
+
<span class="remote-thread-row-meta">
|
|
1937
|
+
<span>${pending ? "Switching..." : stamp || shortThreadId(thread.id)}</span>
|
|
1938
|
+
</span>
|
|
1939
|
+
</button>
|
|
1940
|
+
`;
|
|
1941
|
+
})
|
|
1942
|
+
.join("");
|
|
1943
|
+
const active = group.cwd === selectedProject || group.threads.some((thread) => thread.id === selectedThread);
|
|
1944
|
+
return `
|
|
1945
|
+
<section class="remote-sidebar-group${active ? " is-active" : ""}">
|
|
1946
|
+
<h2 class="remote-sidebar-group-label">${escapeHtml(group.label)}</h2>
|
|
1947
|
+
<div class="remote-thread-list">${rows}</div>
|
|
1948
|
+
</section>
|
|
1949
|
+
`;
|
|
1950
|
+
})
|
|
1951
|
+
.join("");
|
|
1952
|
+
|
|
1953
|
+
setHtmlIfChanged(
|
|
1954
|
+
nodes.sidebarGroups,
|
|
1955
|
+
html,
|
|
1956
|
+
`remote-sidebar:${JSON.stringify(groups.map((group) => ({
|
|
1957
|
+
active: group.cwd === selectedProject || group.threads.some((thread) => thread.id === selectedThread),
|
|
1958
|
+
cwd: group.cwd,
|
|
1959
|
+
threads: group.threads.map((thread) => ({
|
|
1960
|
+
id: thread.id,
|
|
1961
|
+
pending: thread.id === pendingThreadId,
|
|
1962
|
+
selected: thread.id === selectedThread,
|
|
1963
|
+
stamp: sidebarThreadTimestamp(thread),
|
|
1964
|
+
title: threadDisplayTitle(thread)
|
|
1965
|
+
}))
|
|
1966
|
+
})))}:${disabled ? "disabled" : "ready"}`
|
|
1967
|
+
);
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
function sessionBlockedReason() {
|
|
1971
|
+
const liveThread = currentLiveState?.selectedThreadSnapshot?.thread || null;
|
|
1972
|
+
const status = currentLiveState?.status || null;
|
|
1973
|
+
|
|
1974
|
+
return sessionBlockedReasonState({
|
|
1975
|
+
hasLiveThread: Boolean(liveThread?.id),
|
|
1976
|
+
watcherConnected: Boolean(status?.watcherConnected)
|
|
1977
|
+
});
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
function composeBlockedReason() {
|
|
1981
|
+
return composeBlockedReasonState({
|
|
1982
|
+
pendingInteraction: Boolean(currentLiveState?.pendingInteraction),
|
|
1983
|
+
sessionReason: sessionBlockedReason()
|
|
1984
|
+
});
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
function threadBusy(status = currentLiveState?.status || null, liveThread = currentLiveState?.selectedThreadSnapshot?.thread || null) {
|
|
1988
|
+
return threadBusyState({
|
|
1989
|
+
activeTurnId: liveThread?.activeTurnId || "",
|
|
1990
|
+
threadStatus: liveThread?.status || "",
|
|
1991
|
+
isSendingReply,
|
|
1992
|
+
writeLockStatus: status?.writeLock?.status || ""
|
|
1993
|
+
});
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
function queueSummary(threadId = currentThreadId()) {
|
|
1997
|
+
return queueSummaryState(queuedRepliesForThread(threadId).length);
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
function hasDraftIntent() {
|
|
2001
|
+
return Boolean(
|
|
2002
|
+
nodes.replyText.value.trim() ||
|
|
2003
|
+
pendingAttachments.length ||
|
|
2004
|
+
pendingOutgoingAttachments.length ||
|
|
2005
|
+
pendingOutgoingText.trim()
|
|
2006
|
+
);
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
function shouldRenewControlLease() {
|
|
2010
|
+
const threadId = currentThreadId();
|
|
2011
|
+
if (!threadId || !hasRemoteControl(threadId) || document.visibilityState !== "visible") {
|
|
2012
|
+
return false;
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
const hasRecentIntent = Date.now() - lastUserIntentAt < CONTROL_RENEW_ACTIVE_WINDOW_MS;
|
|
2016
|
+
const hasQueuedReplies = queuedRepliesForThread(threadId).length > 0;
|
|
2017
|
+
return Boolean(
|
|
2018
|
+
hasRecentIntent ||
|
|
2019
|
+
hasDraftIntent() ||
|
|
2020
|
+
hasQueuedReplies ||
|
|
2021
|
+
isComposerOpen ||
|
|
2022
|
+
isSendingReply ||
|
|
2023
|
+
uiState.submittingAction ||
|
|
2024
|
+
currentLiveState?.pendingInteraction
|
|
2025
|
+
);
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
function shouldImmediatelyRefreshChanges(previousState, nextState) {
|
|
2029
|
+
if (!previousState) {
|
|
2030
|
+
return true;
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
if ((previousState.selectedThreadId || "") !== (nextState?.selectedThreadId || "")) {
|
|
2034
|
+
return true;
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
if ((previousState.turnDiff?.updatedAt || "") !== (nextState?.turnDiff?.updatedAt || "")) {
|
|
2038
|
+
return true;
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
if (
|
|
2042
|
+
(previousState.status?.lastWriteForSelection?.at || "") !==
|
|
2043
|
+
(nextState?.status?.lastWriteForSelection?.at || "")
|
|
2044
|
+
) {
|
|
2045
|
+
return true;
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
if ((previousState.pendingInteraction?.requestId || "") !== (nextState?.pendingInteraction?.requestId || "")) {
|
|
2049
|
+
return true;
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
if (
|
|
2053
|
+
(previousState.selectedThreadSnapshot?.thread?.activeTurnId || "") !==
|
|
2054
|
+
(nextState?.selectedThreadSnapshot?.thread?.activeTurnId || "")
|
|
2055
|
+
) {
|
|
2056
|
+
return true;
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
return false;
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
function renderStatuses() {
|
|
2063
|
+
const status = currentLiveState?.status || {};
|
|
2064
|
+
const liveThread = currentLiveState?.selectedThreadSnapshot?.thread || null;
|
|
2065
|
+
const selectedChannel = currentLiveState?.selectedChannel || currentLiveState?.selectedThreadSnapshot?.channel || null;
|
|
2066
|
+
const selectedSnapshot = currentLiveState?.selectedThreadSnapshot || null;
|
|
2067
|
+
const selectedAttachments = mergeSurfaceAttachments(currentLiveState?.selectedAttachments || [], localSurfaceAttachment());
|
|
2068
|
+
const controlActive = hasRemoteControl(liveThread?.id || "");
|
|
2069
|
+
const roomHydrating = roomStatusPending(liveThread, status, selectedSnapshot);
|
|
2070
|
+
const suppressBridgeDiagnostics = bridgeState.streamState !== "live" || uiState.booting || uiState.selecting;
|
|
2071
|
+
const operatorDiagnostics = describeOperatorDiagnostics({
|
|
2072
|
+
diagnostics: status.diagnostics || [],
|
|
2073
|
+
ownsControl: controlActive,
|
|
2074
|
+
status,
|
|
2075
|
+
surface: "remote"
|
|
2076
|
+
}).filter((entry) => {
|
|
2077
|
+
if (entry.code === "host_unavailable" || entry.code === "bridge_unavailable") {
|
|
2078
|
+
return false;
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
if (suppressBridgeDiagnostics && entry.code === "bridge_last_error") {
|
|
2082
|
+
return false;
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
return true;
|
|
2086
|
+
});
|
|
2087
|
+
const busy = threadBusy(status, liveThread);
|
|
2088
|
+
const queued = queueSummary(liveThread?.id || "");
|
|
2089
|
+
const queuedCount = queuedRepliesForThread(liveThread?.id || "").length;
|
|
2090
|
+
const hasDraftPayload = hasComposerPayload();
|
|
2091
|
+
const threadState = describeThreadState({
|
|
2092
|
+
pendingInteraction: currentLiveState?.pendingInteraction,
|
|
2093
|
+
status,
|
|
2094
|
+
thread: liveThread
|
|
2095
|
+
});
|
|
2096
|
+
const attachmentSummary = formatSurfaceAttachmentSummary(selectedAttachments);
|
|
2097
|
+
const pendingTitle = uiState.selecting ? selectionIntentTitle(selectionIntent) : "";
|
|
2098
|
+
const channelLabel = selectedChannel?.channelSlug || liveThread?.name || (liveThread?.id ? `#${shortThreadId(liveThread.id)}` : "");
|
|
2099
|
+
const remoteScopeNote = describeRemoteScopeNote({
|
|
2100
|
+
channelLabel,
|
|
2101
|
+
hasSelectedThread: Boolean(liveThread?.id)
|
|
2102
|
+
});
|
|
2103
|
+
const hasVisibleTranscript = liveThread?.id && currentTranscriptEntries().length > 0;
|
|
2104
|
+
const selectionPendingSnapshot = Boolean(currentLiveState?.selectedThreadId) && !liveThread?.id;
|
|
2105
|
+
|
|
2106
|
+
nodes.remoteTitle.textContent = pendingTitle || selectedChannel?.channelSlug || liveThread?.name || currentSnapshot?.session?.title || "#connecting";
|
|
2107
|
+
if (operatorDiagnostics.length > 0) {
|
|
2108
|
+
const diagnosticsHtml = operatorDiagnostics
|
|
2109
|
+
.slice(0, 2)
|
|
2110
|
+
.map((entry) => {
|
|
2111
|
+
const toneClass = entry.severity === "warn" ? "is-warn" : "is-info";
|
|
2112
|
+
const title = entry.detail ? `${entry.title} ${entry.detail}` : entry.title;
|
|
2113
|
+
return `<span class="diagnostic-chip ${toneClass}" title="${escapeHtml(title)}">${escapeHtml(entry.label)}</span>`;
|
|
2114
|
+
})
|
|
2115
|
+
.join("");
|
|
2116
|
+
setHtmlIfChanged(
|
|
2117
|
+
nodes.operatorDiagnostics,
|
|
2118
|
+
diagnosticsHtml,
|
|
2119
|
+
`remote-diagnostics:${operatorDiagnostics.map((entry) => `${entry.code}:${entry.label}`).join("|")}`
|
|
2120
|
+
);
|
|
2121
|
+
setPanelHidden(nodes.operatorDiagnostics, false);
|
|
2122
|
+
} else {
|
|
2123
|
+
setHtmlIfChanged(nodes.operatorDiagnostics, "", "remote-diagnostics:empty");
|
|
2124
|
+
setPanelHidden(nodes.operatorDiagnostics, true);
|
|
2125
|
+
}
|
|
2126
|
+
const controllerLabel = controlOwnerLabel(status.controlLeaseForSelection || null);
|
|
2127
|
+
nodes.remoteTarget.textContent = liveThread?.id
|
|
2128
|
+
? [
|
|
2129
|
+
`server ${selectedChannel?.serverLabel || projectLabel(liveThread.cwd || "")}`,
|
|
2130
|
+
attachmentSummary,
|
|
2131
|
+
controllerLabel && controlActive ? `${controllerLabel.toLowerCase()} control` : controlActive ? "control active" : "",
|
|
2132
|
+
queued,
|
|
2133
|
+
threadState !== "ready" ? threadState : ""
|
|
2134
|
+
]
|
|
2135
|
+
.filter(Boolean)
|
|
2136
|
+
.join(" // ")
|
|
2137
|
+
: "Select a project and session.";
|
|
2138
|
+
nodes.remoteScopeNote.textContent = remoteScopeNote;
|
|
2139
|
+
setPanelHidden(nodes.remoteScopeNote, true);
|
|
2140
|
+
nodes.composerTarget.textContent = liveThread?.id
|
|
2141
|
+
? `${selectedChannel?.channelSlug || `#${shortThreadId(liveThread.id)}`} // shared thread // ${
|
|
2142
|
+
queuedCount ? `${queuedCount} queued` : controlActive ? "control active" : "steer ready"
|
|
2143
|
+
}`
|
|
2144
|
+
: "No live target";
|
|
2145
|
+
nodes.composerScopeNote.textContent = remoteScopeNote;
|
|
2146
|
+
setPanelHidden(nodes.composerScopeNote, !remoteScopeNote);
|
|
2147
|
+
nodes.composerSyncNote.textContent = "";
|
|
2148
|
+
setPanelHidden(nodes.composerSyncNote, true);
|
|
2149
|
+
|
|
2150
|
+
let bridgeStatusLine = "Loading room...";
|
|
2151
|
+
const busyNotice = uiBusyNotice();
|
|
2152
|
+
|
|
2153
|
+
if (bridgeState.streamState !== "live") {
|
|
2154
|
+
bridgeStatusLine = currentLiveState ? "Reconnecting..." : "Connecting...";
|
|
2155
|
+
} else if (liveThread?.id && roomHydrating) {
|
|
2156
|
+
bridgeStatusLine = status.lastError
|
|
2157
|
+
? `Reconnecting ${channelLabel || "room"}...`
|
|
2158
|
+
: hasVisibleTranscript
|
|
2159
|
+
? `Loading more from ${channelLabel || "room"}...`
|
|
2160
|
+
: `Loading ${channelLabel || "room"}...`;
|
|
2161
|
+
} else if (selectionPendingSnapshot) {
|
|
2162
|
+
bridgeStatusLine = status.lastError ? `Reconnecting ${channelLabel || "room"}...` : `Loading ${channelLabel || "room"}...`;
|
|
2163
|
+
} else if (status.watcherConnected || hasVisibleTranscript) {
|
|
2164
|
+
const liveBits = [];
|
|
2165
|
+
if (controlActive) {
|
|
2166
|
+
liveBits.push("Remote control active");
|
|
2167
|
+
}
|
|
2168
|
+
if (queued) {
|
|
2169
|
+
liveBits.push(queued);
|
|
2170
|
+
}
|
|
2171
|
+
if (threadState !== "ready") {
|
|
2172
|
+
liveBits.push(threadState);
|
|
2173
|
+
}
|
|
2174
|
+
bridgeStatusLine = liveBits.join(" // ") || "Session bridge online";
|
|
2175
|
+
} else if (status.lastError) {
|
|
2176
|
+
bridgeStatusLine = "Reconnecting...";
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
if (busyNotice.message) {
|
|
2180
|
+
bridgeStatusLine = busyNotice.message;
|
|
2181
|
+
} else if (transientUiNotice?.message) {
|
|
2182
|
+
bridgeStatusLine = transientUiNotice.message;
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
const marqueeBusy = (
|
|
2186
|
+
busyNotice.tone === "busy" ||
|
|
2187
|
+
bridgeState.streamState !== "live" ||
|
|
2188
|
+
selectionPendingSnapshot ||
|
|
2189
|
+
(liveThread?.id && roomHydrating)
|
|
2190
|
+
);
|
|
2191
|
+
marqueeTicker.setText(marqueeBusy ? formatBusyMarqueeText(bridgeStatusLine) : bridgeStatusLine);
|
|
2192
|
+
nodes.marquee.classList.toggle("is-busy", marqueeBusy);
|
|
2193
|
+
if (transientUiNotice?.message) {
|
|
2194
|
+
setUiStatus(transientUiNotice.message, transientUiNotice.tone);
|
|
2195
|
+
} else {
|
|
2196
|
+
setUiStatus("", "neutral");
|
|
2197
|
+
}
|
|
2198
|
+
setPanelHidden(nodes.statusPanel, !(operatorDiagnostics.length > 0 || Boolean(transientUiNotice?.message)));
|
|
2199
|
+
|
|
2200
|
+
const blockedReason = composeBlockedReason();
|
|
2201
|
+
const controlBlocked = sessionBlockedReason();
|
|
2202
|
+
const sendBlocked = sendBlockedReason(liveThread?.id || "");
|
|
2203
|
+
const canCompose = !blockedReason;
|
|
2204
|
+
const canControl = !controlBlocked;
|
|
2205
|
+
nodes.replyToggleButton.disabled = !canCompose || uiState.selecting || isDictating;
|
|
2206
|
+
nodes.replyToggleButton.textContent = isSendingReply ? "Sending..." : isComposerOpen ? "Hide reply" : "Reply";
|
|
2207
|
+
|
|
2208
|
+
if (nodes.controlToggleButton) {
|
|
2209
|
+
nodes.controlToggleButton.disabled = !canControl || isSendingReply || uiState.selecting || uiState.controlling || isDictating;
|
|
2210
|
+
nodes.controlToggleButton.textContent = uiState.controlling
|
|
2211
|
+
? controlActive
|
|
2212
|
+
? "Releasing..."
|
|
2213
|
+
: "Taking..."
|
|
2214
|
+
: controlActive
|
|
2215
|
+
? "Release control"
|
|
2216
|
+
: "Take Control";
|
|
2217
|
+
nodes.controlToggleButton.classList.toggle("button-primary", !controlActive);
|
|
2218
|
+
nodes.controlToggleButton.classList.toggle("is-busy", uiState.controlling);
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
nodes.refreshButton.disabled =
|
|
2222
|
+
uiState.refreshing ||
|
|
2223
|
+
uiState.selecting ||
|
|
2224
|
+
uiState.booting ||
|
|
2225
|
+
isDictating;
|
|
2226
|
+
nodes.refreshButton.textContent = uiState.refreshing ? "Refreshing..." : "Refresh";
|
|
2227
|
+
nodes.refreshButton.classList.toggle("is-busy", uiState.refreshing);
|
|
2228
|
+
for (const button of nodes.companionSummonButtons) {
|
|
2229
|
+
const advisorId = button.dataset.companionSummon || "";
|
|
2230
|
+
const isBusy = manualAdvisorAction === advisorId;
|
|
2231
|
+
button.disabled = !liveThread?.id || uiState.selecting || isSendingReply || Boolean(companionActionState) || isBusy || isDictating;
|
|
2232
|
+
button.classList.toggle("is-busy", isBusy);
|
|
2233
|
+
button.textContent = isBusy ? "Waking..." : manualAdvisorLabel(advisorId);
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
nodes.replyText.disabled = Boolean(blockedReason) || isSendingReply || uiState.selecting || isDictating;
|
|
2237
|
+
nodes.replyImageInput.disabled = Boolean(blockedReason) || isSendingReply || uiState.selecting || isDictating;
|
|
2238
|
+
nodes.dictationButton.disabled = Boolean(blockedReason) || isSendingReply || uiState.selecting || uiState.controlling;
|
|
2239
|
+
const dictationUi = dictationUiModel();
|
|
2240
|
+
if (nodes.dictationButtonLabel) {
|
|
2241
|
+
nodes.dictationButtonLabel.textContent = dictationUi.label;
|
|
2242
|
+
}
|
|
2243
|
+
if (nodes.dictationButtonMeta) {
|
|
2244
|
+
nodes.dictationButtonMeta.textContent = dictationUi.meta;
|
|
2245
|
+
}
|
|
2246
|
+
if (nodes.dictationIndicatorText) {
|
|
2247
|
+
nodes.dictationIndicatorText.textContent = dictationUi.indicatorText;
|
|
2248
|
+
}
|
|
2249
|
+
setPanelHidden(nodes.dictationIndicator, !dictationUi.live);
|
|
2250
|
+
nodes.dictationButton.classList.toggle("is-busy", isDictating);
|
|
2251
|
+
nodes.dictationButton.classList.toggle("is-listening", isDictating);
|
|
2252
|
+
nodes.dictationButton.classList.toggle("is-armed", isDictationPressActive);
|
|
2253
|
+
nodes.dictationIndicator.classList.toggle("is-live", dictationUi.live);
|
|
2254
|
+
setPanelHidden(nodes.composerControlButton, true);
|
|
2255
|
+
nodes.composerControlButton.disabled = true;
|
|
2256
|
+
nodes.composerControlButton.textContent = "Take Control";
|
|
2257
|
+
nodes.composerControlButton.classList.remove("is-busy");
|
|
2258
|
+
const canQueue = canQueueReplyState({
|
|
2259
|
+
controlActive,
|
|
2260
|
+
hasDraftPayload,
|
|
2261
|
+
isControlling: uiState.controlling,
|
|
2262
|
+
isSelecting: uiState.selecting,
|
|
2263
|
+
isSendingReply,
|
|
2264
|
+
pendingInteraction: Boolean(currentLiveState?.pendingInteraction),
|
|
2265
|
+
queuedCount,
|
|
2266
|
+
sessionBlocked: Boolean(sessionBlockedReason()),
|
|
2267
|
+
threadBusy: busy,
|
|
2268
|
+
threadId: liveThread?.id || ""
|
|
2269
|
+
});
|
|
2270
|
+
nodes.sendReplyButton.disabled = !canSteerReplyState({
|
|
2271
|
+
blockedReason: sendBlocked,
|
|
2272
|
+
hasDraftPayload,
|
|
2273
|
+
isControlling: uiState.controlling,
|
|
2274
|
+
isDictating,
|
|
2275
|
+
isSelecting: uiState.selecting,
|
|
2276
|
+
isSendingReply,
|
|
2277
|
+
threadBusy: busy,
|
|
2278
|
+
threadId: liveThread?.id || ""
|
|
2279
|
+
});
|
|
2280
|
+
nodes.sendReplyButton.textContent = isSendingReply ? "Steering..." : "Steer Now";
|
|
2281
|
+
nodes.sendReplyButton.classList.toggle("is-busy", isSendingReply);
|
|
2282
|
+
nodes.queueReplyButton.disabled = !canQueue || isDictating;
|
|
2283
|
+
nodes.queueReplyButton.textContent = queuedCount > 0 ? `Queue (${queuedCount})` : "Queue";
|
|
2284
|
+
nodes.queueReplyButton.classList.remove("is-busy");
|
|
2285
|
+
nodes.clearQueueButton.disabled = !queuedCount || isSendingReply || uiState.selecting || uiState.controlling || isDictating;
|
|
2286
|
+
|
|
2287
|
+
nodes.composerStatus.textContent = defaultComposerStatus({
|
|
2288
|
+
blockedReason,
|
|
2289
|
+
composerStatus,
|
|
2290
|
+
composerStatusTone,
|
|
2291
|
+
controlActive,
|
|
2292
|
+
hasDraftPayload,
|
|
2293
|
+
isSendingReply,
|
|
2294
|
+
queuedCount,
|
|
2295
|
+
threadBusy: busy
|
|
2296
|
+
});
|
|
2297
|
+
|
|
2298
|
+
nodes.composerStatus.className = `composer-status ${
|
|
2299
|
+
composerStatusTone === "sending"
|
|
2300
|
+
? "composer-status-sending"
|
|
2301
|
+
: composerStatusTone === "error"
|
|
2302
|
+
? "composer-status-error"
|
|
2303
|
+
: composerStatusTone === "success"
|
|
2304
|
+
? "composer-status-success"
|
|
2305
|
+
: ""
|
|
2306
|
+
}`;
|
|
2307
|
+
|
|
2308
|
+
const shouldShowComposer = isComposerOpen;
|
|
2309
|
+
setPanelHidden(nodes.composerShell, !shouldShowComposer);
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
function formatActionDetail(pending) {
|
|
2313
|
+
const parts = [];
|
|
2314
|
+
|
|
2315
|
+
if (pending.flowLabel || pending.flowContinuation) {
|
|
2316
|
+
parts.push(`
|
|
2317
|
+
<div class="interaction-flow">
|
|
2318
|
+
${pending.flowLabel ? `<p class="interaction-flow-label">${escapeHtml(pending.flowLabel)}</p>` : ""}
|
|
2319
|
+
${pending.flowContinuation ? `<p class="interaction-flow-copy">${escapeHtml(pending.flowContinuation)}</p>` : ""}
|
|
2320
|
+
</div>
|
|
2321
|
+
`);
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
if (pending.summary) {
|
|
2325
|
+
parts.push(`<p class="question-help">Now: ${escapeHtml(pending.summary)}</p>`);
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
if (pending.detail) {
|
|
2329
|
+
parts.push(`<p>${escapeHtml(pending.detail)}</p>`);
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
if (pending.command) {
|
|
2333
|
+
parts.push(`<pre class="command-preview">${escapeHtml(pending.command)}</pre>`);
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
if (pending.cwd) {
|
|
2337
|
+
parts.push(`<p class="question-help">${escapeHtml(pending.cwd)}</p>`);
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
if (pending.permissions) {
|
|
2341
|
+
parts.push(`<pre class="command-preview">${escapeHtml(JSON.stringify(pending.permissions, null, 2))}</pre>`);
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
return parts.join("");
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
function renderQuestions(questions, requestId = "") {
|
|
2348
|
+
const html = questions
|
|
2349
|
+
.map((question) => {
|
|
2350
|
+
const options = question.options || [];
|
|
2351
|
+
const baseControl = options.length
|
|
2352
|
+
? `
|
|
2353
|
+
<select data-answer-id="${escapeHtml(question.id)}">
|
|
2354
|
+
<option value="">Select</option>
|
|
2355
|
+
${options
|
|
2356
|
+
.map((option) => `<option value="${escapeHtml(option.label)}">${escapeHtml(option.label)}</option>`)
|
|
2357
|
+
.join("")}
|
|
2358
|
+
${question.isOther ? '<option value="__other__">Other</option>' : ""}
|
|
2359
|
+
</select>
|
|
2360
|
+
`
|
|
2361
|
+
: `
|
|
2362
|
+
<input
|
|
2363
|
+
type="${question.isSecret ? "password" : "text"}"
|
|
2364
|
+
data-answer-id="${escapeHtml(question.id)}"
|
|
2365
|
+
placeholder="${escapeHtml(question.header || question.question)}"
|
|
2366
|
+
>
|
|
2367
|
+
`;
|
|
2368
|
+
|
|
2369
|
+
const otherControl = question.isOther
|
|
2370
|
+
? `
|
|
2371
|
+
<input
|
|
2372
|
+
type="${question.isSecret ? "password" : "text"}"
|
|
2373
|
+
data-answer-other="${escapeHtml(question.id)}"
|
|
2374
|
+
placeholder="Other"
|
|
2375
|
+
>
|
|
2376
|
+
`
|
|
2377
|
+
: "";
|
|
2378
|
+
|
|
2379
|
+
return `
|
|
2380
|
+
<label class="question-field">
|
|
2381
|
+
<span>${escapeHtml(question.header || question.id)}</span>
|
|
2382
|
+
<span class="question-help">${escapeHtml(question.question)}</span>
|
|
2383
|
+
${baseControl}
|
|
2384
|
+
${otherControl}
|
|
2385
|
+
</label>
|
|
2386
|
+
`;
|
|
2387
|
+
})
|
|
2388
|
+
.join("");
|
|
2389
|
+
const signature = JSON.stringify(
|
|
2390
|
+
(questions || []).map((question) => ({
|
|
2391
|
+
header: question.header,
|
|
2392
|
+
id: question.id,
|
|
2393
|
+
isOther: Boolean(question.isOther),
|
|
2394
|
+
isSecret: Boolean(question.isSecret),
|
|
2395
|
+
options: (question.options || []).map((option) => option.label),
|
|
2396
|
+
question: question.question
|
|
2397
|
+
}))
|
|
2398
|
+
);
|
|
2399
|
+
setHtmlIfChanged(nodes.actionQuestions, html, `questions:${requestId}:${signature}`);
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
function renderActionPanel() {
|
|
2403
|
+
const pending = currentLiveState?.pendingInteraction || actionHandoffState || null;
|
|
2404
|
+
const remoteCanRespond = hasRemoteControl(pending?.threadId || currentThreadId());
|
|
2405
|
+
|
|
2406
|
+
if (!pending) {
|
|
2407
|
+
setPanelHidden(nodes.actionPanel, true);
|
|
2408
|
+
nodes.actionPanel.classList.remove("panel-pop");
|
|
2409
|
+
setPanelHidden(nodes.actionForm, true);
|
|
2410
|
+
setPanelHidden(nodes.actionButtons, false);
|
|
2411
|
+
setPanelHidden(nodes.approveSessionButton, true);
|
|
2412
|
+
setPanelHidden(nodes.actionControlGate, true);
|
|
2413
|
+
clearHtmlRenderState(nodes.actionQuestions);
|
|
2414
|
+
return;
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
setPanelHidden(nodes.actionPanel, false);
|
|
2418
|
+
nodes.actionPanel.classList.add("panel-pop");
|
|
2419
|
+
nodes.actionTitle.textContent = pending.title || "Action required";
|
|
2420
|
+
nodes.actionKind.textContent = pending.kindLabel || (pending.actionKind === "user_input" ? "Input" : pending.handoff ? "Waiting" : "Approval");
|
|
2421
|
+
nodes.actionCard.innerHTML = formatActionDetail(pending);
|
|
2422
|
+
|
|
2423
|
+
if (pending.handoff) {
|
|
2424
|
+
setPanelHidden(nodes.actionForm, true);
|
|
2425
|
+
setPanelHidden(nodes.actionButtons, true);
|
|
2426
|
+
setPanelHidden(nodes.approveSessionButton, true);
|
|
2427
|
+
setPanelHidden(nodes.actionControlGate, true);
|
|
2428
|
+
clearHtmlRenderState(nodes.actionQuestions);
|
|
2429
|
+
return;
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
const isUserInput = pending.actionKind === "user_input";
|
|
2433
|
+
setPanelHidden(nodes.actionControlGate, remoteCanRespond);
|
|
2434
|
+
nodes.actionControlButton.disabled = uiState.controlling || uiState.selecting;
|
|
2435
|
+
nodes.actionControlButton.textContent = uiState.controlling ? "Taking..." : "Take Control";
|
|
2436
|
+
nodes.actionControlButton.classList.toggle("is-busy", uiState.controlling);
|
|
2437
|
+
setPanelHidden(nodes.actionForm, !isUserInput);
|
|
2438
|
+
setPanelHidden(nodes.actionButtons, isUserInput);
|
|
2439
|
+
|
|
2440
|
+
if (isUserInput) {
|
|
2441
|
+
renderQuestions(pending.questions || [], pending.requestId || "pending");
|
|
2442
|
+
nodes.actionSubmit.textContent = uiState.submittingAction === "submit" ? "Submitting..." : pending.submitLabel || "Submit";
|
|
2443
|
+
nodes.actionCancel.textContent = uiState.submittingAction === "cancel" ? "Cancelling..." : "Cancel";
|
|
2444
|
+
} else {
|
|
2445
|
+
clearHtmlRenderState(nodes.actionQuestions);
|
|
2446
|
+
nodes.approveButton.textContent =
|
|
2447
|
+
uiState.submittingAction === "approve"
|
|
2448
|
+
? pending.kind === "permissions"
|
|
2449
|
+
? "Allowing..."
|
|
2450
|
+
: "Approving..."
|
|
2451
|
+
: pending.approveLabel || (pending.kind === "permissions" ? "Allow turn" : "Approve");
|
|
2452
|
+
nodes.declineButton.textContent = uiState.submittingAction === "decline" ? "Declining..." : pending.declineLabel || "Decline";
|
|
2453
|
+
nodes.approveSessionButton.textContent =
|
|
2454
|
+
uiState.submittingAction === "session"
|
|
2455
|
+
? pending.kind === "permissions"
|
|
2456
|
+
? "Allowing..."
|
|
2457
|
+
: "Approving..."
|
|
2458
|
+
: pending.sessionActionLabel || "Approve for session";
|
|
2459
|
+
setPanelHidden(nodes.approveSessionButton, !pending.canApproveForSession);
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
nodes.actionSubmit.disabled = uiState.submittingAction || uiState.controlling || !remoteCanRespond;
|
|
2463
|
+
nodes.actionCancel.disabled = uiState.submittingAction || uiState.controlling || !remoteCanRespond;
|
|
2464
|
+
nodes.approveButton.disabled = uiState.submittingAction || uiState.controlling || !remoteCanRespond;
|
|
2465
|
+
nodes.approveSessionButton.disabled = uiState.submittingAction || uiState.controlling || !remoteCanRespond;
|
|
2466
|
+
nodes.declineButton.disabled = uiState.submittingAction || uiState.controlling || !remoteCanRespond;
|
|
2467
|
+
nodes.actionSubmit.classList.toggle("is-busy", uiState.submittingAction && isUserInput);
|
|
2468
|
+
nodes.approveButton.classList.toggle("is-busy", uiState.submittingAction && !isUserInput);
|
|
2469
|
+
nodes.approveSessionButton.classList.toggle("is-busy", uiState.submittingAction && !isUserInput);
|
|
2470
|
+
nodes.declineButton.classList.toggle("is-busy", uiState.submittingAction && !isUserInput);
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
function renderFeed() {
|
|
2474
|
+
resetCardHistoryIfNeeded();
|
|
2475
|
+
const entries = buildEntries();
|
|
2476
|
+
const transcriptHistoryState = historyStateForThread();
|
|
2477
|
+
if (
|
|
2478
|
+
stagedCompanionWakeKey &&
|
|
2479
|
+
!entries.some((entry) => entry.key === stagedCompanionWakeKey && entry?.participant?.role === "advisory")
|
|
2480
|
+
) {
|
|
2481
|
+
clearStagedCompanionWakeKey();
|
|
2482
|
+
}
|
|
2483
|
+
renderFilterButtons(entries);
|
|
2484
|
+
const changesSection = feedFilters.changes ? renderChangesSection() : null;
|
|
2485
|
+
const items = [];
|
|
2486
|
+
const threadEntries = entries
|
|
2487
|
+
.filter((entry) => isConversationEntry(entry) && !isAdvisoryEntry(entry))
|
|
2488
|
+
.slice()
|
|
2489
|
+
.sort(compareEntryChronologyDesc);
|
|
2490
|
+
const unifiedEntries = entries.filter((entry) => entryMatchesFeedFilter(entry)).sort(compareEntryChronologyDesc);
|
|
2491
|
+
|
|
2492
|
+
if (changesSection) {
|
|
2493
|
+
items.push({
|
|
2494
|
+
html: changesSection.html,
|
|
2495
|
+
key: "__changes__",
|
|
2496
|
+
signature: changesSection.signature
|
|
2497
|
+
});
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
if (unifiedEntries.length) {
|
|
2501
|
+
items.push(...renderFeedItems(unifiedEntries));
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
if (feedFilters.thread) {
|
|
2505
|
+
if (threadEntries.length || transcriptHistoryState?.loading || transcriptHistoryState?.hasMore !== false) {
|
|
2506
|
+
items.push({
|
|
2507
|
+
html: transcriptHistoryState?.loading
|
|
2508
|
+
? '<div class="empty-card history-loading" data-history-sentinel="true">Loading older messages...</div>'
|
|
2509
|
+
: '<div class="history-sentinel" data-history-sentinel="true" aria-hidden="true"></div>',
|
|
2510
|
+
key: "__history__",
|
|
2511
|
+
signature: `history:${threadEntries.length}:${transcriptHistoryState?.loading ? "loading" : "idle"}:${transcriptHistoryState?.hasMore === false ? "done" : "more"}`
|
|
2512
|
+
});
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2516
|
+
if (!items.length) {
|
|
2517
|
+
items.push({
|
|
2518
|
+
html: '<div class="empty-card">No matching items.</div>',
|
|
2519
|
+
key: "__empty__",
|
|
2520
|
+
signature: "empty"
|
|
2521
|
+
});
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
reconcileRenderedList(nodes.feed, items);
|
|
2525
|
+
|
|
2526
|
+
hasRenderedOnce = true;
|
|
2527
|
+
const topRenderableItem = items.find((item) => !String(item.key || "").startsWith("__history__")) || null;
|
|
2528
|
+
return {
|
|
2529
|
+
topKey: topRenderableItem?.key || null
|
|
2530
|
+
};
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
function render() {
|
|
2534
|
+
if (!currentSnapshot && !currentLiveState) {
|
|
2535
|
+
return;
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
const previousScrollY = window.scrollY || window.pageYOffset || 0;
|
|
2539
|
+
const wasNearTop = previousScrollY <= FEED_STICKY_TOP_THRESHOLD_PX;
|
|
2540
|
+
|
|
2541
|
+
queuedRepliesForThread();
|
|
2542
|
+
renderSidebar();
|
|
2543
|
+
renderStatuses();
|
|
2544
|
+
renderActionPanel();
|
|
2545
|
+
renderAttachments();
|
|
2546
|
+
renderQueuePanel();
|
|
2547
|
+
const { topKey } = renderFeed();
|
|
2548
|
+
setPanelHidden(nodes.composerForm, !isComposerOpen);
|
|
2549
|
+
|
|
2550
|
+
if (pendingScrollToLatest) {
|
|
2551
|
+
pendingScrollToLatest = false;
|
|
2552
|
+
window.requestAnimationFrame(() => {
|
|
2553
|
+
window.scrollTo({ top: 0, behavior: "auto" });
|
|
2554
|
+
});
|
|
2555
|
+
} else if (wasNearTop && topKey && topKey !== lastRenderedFeedTopKey) {
|
|
2556
|
+
window.requestAnimationFrame(() => {
|
|
2557
|
+
window.scrollTo({ top: 0, behavior: "auto" });
|
|
2558
|
+
});
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
lastRenderedFeedTopKey = topKey;
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
async function loadOlderTranscriptHistory() {
|
|
2565
|
+
const threadId = currentThreadId();
|
|
2566
|
+
const historyState = historyStateForThread(threadId);
|
|
2567
|
+
if (!threadId || !historyState || historyState.loading || historyState.hasMore === false) {
|
|
2568
|
+
return null;
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
const visibleCount = currentTranscriptEntries().length;
|
|
2572
|
+
if (!visibleCount) {
|
|
2573
|
+
return null;
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
const previousScrollY = window.scrollY || window.pageYOffset || 0;
|
|
2577
|
+
let loadedOlderItems = false;
|
|
2578
|
+
historyState.loading = true;
|
|
2579
|
+
render();
|
|
2580
|
+
|
|
2581
|
+
try {
|
|
2582
|
+
const query = new URLSearchParams({
|
|
2583
|
+
limit: String(TRANSCRIPT_HISTORY_PAGE_SIZE),
|
|
2584
|
+
threadId
|
|
2585
|
+
});
|
|
2586
|
+
|
|
2587
|
+
if (Number.isFinite(historyState.beforeIndex)) {
|
|
2588
|
+
query.set("beforeIndex", String(historyState.beforeIndex));
|
|
2589
|
+
} else {
|
|
2590
|
+
query.set("visibleCount", String(visibleCount));
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
const payload = await requestJson(`${transcriptHistoryUrl}?${query.toString()}`);
|
|
2594
|
+
historyState.items = mergeTranscriptEntries(payload.items || [], historyState.items || []);
|
|
2595
|
+
historyState.beforeIndex = Number.isFinite(payload.nextBeforeIndex) ? payload.nextBeforeIndex : null;
|
|
2596
|
+
historyState.hasMore = Boolean(payload.hasMore);
|
|
2597
|
+
loadedOlderItems = Array.isArray(payload.items) && payload.items.length > 0;
|
|
2598
|
+
historyState.awaitingUserScroll = loadedOlderItems;
|
|
2599
|
+
historyState.resumeAfterScrollY = null;
|
|
2600
|
+
return payload;
|
|
2601
|
+
} catch (error) {
|
|
2602
|
+
setTransientUiNotice(error.message || "Could not load older history.", "error", 2400);
|
|
2603
|
+
historyState.awaitingUserScroll = false;
|
|
2604
|
+
historyState.resumeAfterScrollY = null;
|
|
2605
|
+
return null;
|
|
2606
|
+
} finally {
|
|
2607
|
+
historyState.loading = false;
|
|
2608
|
+
render();
|
|
2609
|
+
if (loadedOlderItems) {
|
|
2610
|
+
window.requestAnimationFrame(() => {
|
|
2611
|
+
historyState.resumeAfterScrollY = previousScrollY + TRANSCRIPT_HISTORY_RESUME_SCROLL_DELTA_PX;
|
|
2612
|
+
});
|
|
2613
|
+
}
|
|
2614
|
+
}
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
function maybeLoadOlderTranscriptHistory() {
|
|
2618
|
+
const historyState = historyStateForThread();
|
|
2619
|
+
if (!historyState || historyState.loading || historyState.hasMore === false || !feedFilters.thread) {
|
|
2620
|
+
return;
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
const currentScrollY = window.scrollY || window.pageYOffset || 0;
|
|
2624
|
+
if (historyState.awaitingUserScroll) {
|
|
2625
|
+
if (!Number.isFinite(historyState.resumeAfterScrollY)) {
|
|
2626
|
+
return;
|
|
2627
|
+
}
|
|
2628
|
+
if (currentScrollY < historyState.resumeAfterScrollY) {
|
|
2629
|
+
return;
|
|
2630
|
+
}
|
|
2631
|
+
historyState.awaitingUserScroll = false;
|
|
2632
|
+
historyState.resumeAfterScrollY = null;
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
const sentinel = nodes.feed.querySelector('[data-history-sentinel="true"]');
|
|
2636
|
+
if (!sentinel) {
|
|
2637
|
+
return;
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
const rect = sentinel.getBoundingClientRect();
|
|
2641
|
+
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
|
|
2642
|
+
if (rect.top <= viewportHeight + TRANSCRIPT_HISTORY_BOTTOM_THRESHOLD_PX) {
|
|
2643
|
+
void loadOlderTranscriptHistory();
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
async function requestJson(url, options = {}) {
|
|
2648
|
+
const response = await fetch(url, withSurfaceHeaders(options, surfaceBootstrap.accessToken));
|
|
2649
|
+
const payload = await response.json();
|
|
2650
|
+
|
|
2651
|
+
if (!response.ok) {
|
|
2652
|
+
throw createRequestError(payload, response);
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
return payload;
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
function adoptErrorState(error) {
|
|
2659
|
+
const state = error?.state || error?.payload?.state || null;
|
|
2660
|
+
if (!state) {
|
|
2661
|
+
return false;
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
currentLiveState = state;
|
|
2665
|
+
settleSelectionIntent();
|
|
2666
|
+
markLiveActivity();
|
|
2667
|
+
return true;
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
const bridgeLifecycle = createLiveBridgeLifecycle({
|
|
2671
|
+
bootstrapRetry: { baseMs: BOOTSTRAP_RETRY_BASE_MS, maxMs: BOOTSTRAP_RETRY_MAX_MS },
|
|
2672
|
+
createEventSource: (url) => new EventSource(withSurfaceTokenUrl(url, surfaceBootstrap.accessToken)),
|
|
2673
|
+
getHasLiveState: () => Boolean(currentLiveState),
|
|
2674
|
+
getVisible: () => document.visibilityState === "visible",
|
|
2675
|
+
onBootstrapError: ({ error, retrying }) => {
|
|
2676
|
+
if (!currentLiveState) {
|
|
2677
|
+
uiState.booting = true;
|
|
2678
|
+
nodes.remoteTarget.textContent = retrying ? "Waiting for session bridge..." : error.message;
|
|
2679
|
+
setComposerStatus(retrying ? "Waiting for session bridge..." : error.message, retrying ? "neutral" : "error");
|
|
2680
|
+
setUiStatus(retrying ? "Waiting for session bridge..." : error.message, retrying ? "busy" : "error");
|
|
2681
|
+
}
|
|
2682
|
+
},
|
|
2683
|
+
onBootstrapStart: () => {
|
|
2684
|
+
if (!currentLiveState) {
|
|
2685
|
+
uiState.booting = true;
|
|
2686
|
+
}
|
|
2687
|
+
},
|
|
2688
|
+
onBootstrapSuccess: ({ snapshot, live }) => {
|
|
2689
|
+
const previousState = currentLiveState;
|
|
2690
|
+
currentSnapshot = snapshot;
|
|
2691
|
+
currentLiveState = live;
|
|
2692
|
+
syncRoomStatusHold(previousState, currentLiveState);
|
|
2693
|
+
settleSelectionIntent();
|
|
2694
|
+
clearActionHandoff({ renderNow: false });
|
|
2695
|
+
uiState.booting = false;
|
|
2696
|
+
markLiveActivity();
|
|
2697
|
+
scheduleChangesRefresh({ immediate: true, showLoading: true });
|
|
2698
|
+
schedulePresenceSync(20, { force: true });
|
|
2699
|
+
scheduleQueueFlush(220);
|
|
2700
|
+
},
|
|
2701
|
+
onLive: (live) => {
|
|
2702
|
+
const previousState = currentLiveState;
|
|
2703
|
+
currentLiveState = live;
|
|
2704
|
+
syncRoomStatusHold(previousState, currentLiveState);
|
|
2705
|
+
settleSelectionIntent();
|
|
2706
|
+
handleControlEventNotice(previousState, currentLiveState);
|
|
2707
|
+
handleControlLeaseTransition(previousState, currentLiveState);
|
|
2708
|
+
if (currentLiveState?.pendingInteraction || !currentLiveState?.selectedThreadSnapshot?.thread?.activeTurnId) {
|
|
2709
|
+
clearActionHandoff({ renderNow: false });
|
|
2710
|
+
}
|
|
2711
|
+
markLiveActivity();
|
|
2712
|
+
scheduleChangesRefresh({ immediate: shouldImmediatelyRefreshChanges(previousState, currentLiveState) });
|
|
2713
|
+
schedulePresenceSync(90);
|
|
2714
|
+
scheduleQueueFlush(220);
|
|
2715
|
+
},
|
|
2716
|
+
onRender: render,
|
|
2717
|
+
onSnapshot: (snapshot) => {
|
|
2718
|
+
currentSnapshot = snapshot;
|
|
2719
|
+
settleSelectionIntent();
|
|
2720
|
+
markLiveActivity();
|
|
2721
|
+
},
|
|
2722
|
+
onStreamOpen: () => {
|
|
2723
|
+
if (streamIssueStartedAt) {
|
|
2724
|
+
setTransientUiNotice(`Bridge recovered in ${formatRecoveryDuration(Date.now() - streamIssueStartedAt)}.`, "success", 2200);
|
|
2725
|
+
streamIssueStartedAt = 0;
|
|
2726
|
+
}
|
|
2727
|
+
markLiveActivity();
|
|
2728
|
+
},
|
|
2729
|
+
onStreamError: () => {
|
|
2730
|
+
if (!streamIssueStartedAt) {
|
|
2731
|
+
streamIssueStartedAt = Date.now();
|
|
2732
|
+
}
|
|
2733
|
+
},
|
|
2734
|
+
requestBootstrap: async () => {
|
|
2735
|
+
const [snapshot, live] = await Promise.all([
|
|
2736
|
+
requestJson(stateUrl),
|
|
2737
|
+
requestJson(liveStateUrl)
|
|
2738
|
+
]);
|
|
2739
|
+
|
|
2740
|
+
return { live, snapshot };
|
|
2741
|
+
},
|
|
2742
|
+
requestRefresh: async ({ background = false }) => {
|
|
2743
|
+
if (!background) {
|
|
2744
|
+
uiState.refreshing = true;
|
|
2745
|
+
render();
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
try {
|
|
2749
|
+
const url = background ? `${refreshUrl}?threads=0` : refreshUrl;
|
|
2750
|
+
const payload = await requestJson(url, { method: "POST" });
|
|
2751
|
+
const previousState = currentLiveState;
|
|
2752
|
+
currentLiveState = payload.state;
|
|
2753
|
+
syncRoomStatusHold(previousState, currentLiveState);
|
|
2754
|
+
settleSelectionIntent();
|
|
2755
|
+
markLiveActivity();
|
|
2756
|
+
scheduleChangesRefresh({ immediate: true, showLoading: !background });
|
|
2757
|
+
schedulePresenceSync(90, { force: true });
|
|
2758
|
+
scheduleQueueFlush(220);
|
|
2759
|
+
return payload;
|
|
2760
|
+
} finally {
|
|
2761
|
+
if (!background) {
|
|
2762
|
+
uiState.refreshing = false;
|
|
2763
|
+
render();
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
},
|
|
2767
|
+
state: bridgeState,
|
|
2768
|
+
streamRecovery: { baseMs: STREAM_RECOVERY_BASE_MS, maxMs: STREAM_RECOVERY_MAX_MS },
|
|
2769
|
+
streamUrl: "/api/stream"
|
|
2770
|
+
});
|
|
2771
|
+
|
|
2772
|
+
function closeStream() {
|
|
2773
|
+
bridgeLifecycle.closeStream();
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2776
|
+
function ensureStream({ force = false } = {}) {
|
|
2777
|
+
bridgeLifecycle.ensureStream({ force });
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
async function bootstrapLiveState({ retrying = false } = {}) {
|
|
2781
|
+
return bridgeLifecycle.bootstrap({ retrying });
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
async function refreshLiveState({ background = false } = {}) {
|
|
2785
|
+
return bridgeLifecycle.refresh({ background });
|
|
2786
|
+
}
|
|
2787
|
+
|
|
2788
|
+
async function refreshChanges({ background = false, cwd = currentLiveState?.selectedProjectCwd || "", showLoading = false } = {}) {
|
|
2789
|
+
if (!cwd) {
|
|
2790
|
+
currentChanges = null;
|
|
2791
|
+
uiState.loadingChanges = false;
|
|
2792
|
+
render();
|
|
2793
|
+
return null;
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
if (changesRefreshPromise) {
|
|
2797
|
+
return changesRefreshPromise;
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
if (showLoading) {
|
|
2801
|
+
uiState.loadingChanges = true;
|
|
2802
|
+
render();
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
const threadId = currentLiveState?.selectedThreadId || "";
|
|
2806
|
+
const query = new URLSearchParams({ cwd });
|
|
2807
|
+
if (threadId) {
|
|
2808
|
+
query.set("threadId", threadId);
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
changesRefreshPromise = requestJson(`${changesUrl}?${query.toString()}`)
|
|
2812
|
+
.then((payload) => {
|
|
2813
|
+
currentChanges = payload;
|
|
2814
|
+
render();
|
|
2815
|
+
return payload;
|
|
2816
|
+
})
|
|
2817
|
+
.catch((error) => {
|
|
2818
|
+
if (background) {
|
|
2819
|
+
return null;
|
|
2820
|
+
}
|
|
2821
|
+
throw error;
|
|
2822
|
+
})
|
|
2823
|
+
.finally(() => {
|
|
2824
|
+
if (showLoading) {
|
|
2825
|
+
uiState.loadingChanges = false;
|
|
2826
|
+
render();
|
|
2827
|
+
}
|
|
2828
|
+
changesRefreshPromise = null;
|
|
2829
|
+
});
|
|
2830
|
+
|
|
2831
|
+
return changesRefreshPromise;
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
function scheduleChangesRefresh({ immediate = false, showLoading = false } = {}) {
|
|
2835
|
+
if (changesRefreshTimer) {
|
|
2836
|
+
window.clearTimeout(changesRefreshTimer);
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2839
|
+
changesRefreshTimer = window.setTimeout(() => {
|
|
2840
|
+
changesRefreshTimer = null;
|
|
2841
|
+
void refreshChanges({ background: true, showLoading });
|
|
2842
|
+
}, immediate ? 0 : 350);
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2845
|
+
async function submitSelection(body) {
|
|
2846
|
+
const requestVersion = ++selectionRequestVersion;
|
|
2847
|
+
const previousThreadId = currentThreadId();
|
|
2848
|
+
const previousDraft = nodes.replyText.value;
|
|
2849
|
+
selectionIntent = createSelectionIntent({
|
|
2850
|
+
cwd: body.cwd || currentLiveState?.selectedProjectCwd || "",
|
|
2851
|
+
projectLabel: projectLabel(body.cwd || currentLiveState?.selectedProjectCwd || ""),
|
|
2852
|
+
source: body.source || "remote",
|
|
2853
|
+
threadId: body.threadId || "",
|
|
2854
|
+
threadLabel:
|
|
2855
|
+
currentLiveState?.threads?.find((thread) => thread.id === body.threadId)?.channel?.channelSlug ||
|
|
2856
|
+
currentLiveState?.threads?.find((thread) => thread.id === body.threadId)?.name ||
|
|
2857
|
+
""
|
|
2858
|
+
});
|
|
2859
|
+
uiState.selecting = true;
|
|
2860
|
+
render();
|
|
2861
|
+
|
|
2862
|
+
try {
|
|
2863
|
+
const payload = await requestJson(selectionUrl, {
|
|
2864
|
+
method: "POST",
|
|
2865
|
+
headers: { "Content-Type": "application/json" },
|
|
2866
|
+
body: JSON.stringify({
|
|
2867
|
+
...body,
|
|
2868
|
+
clientId: surfaceAuthClientId
|
|
2869
|
+
})
|
|
2870
|
+
});
|
|
2871
|
+
if (requestVersion !== selectionRequestVersion) {
|
|
2872
|
+
return;
|
|
2873
|
+
}
|
|
2874
|
+
currentLiveState = payload.state;
|
|
2875
|
+
settleSelectionIntent();
|
|
2876
|
+
const nextThreadId = currentThreadId();
|
|
2877
|
+
if (previousThreadId !== nextThreadId) {
|
|
2878
|
+
persistDraft(previousThreadId, previousDraft);
|
|
2879
|
+
resetComposerDraft({ keepStatus: true });
|
|
2880
|
+
clearStagedCompanionWakeKey();
|
|
2881
|
+
restoreDraft(nextThreadId, { force: true });
|
|
2882
|
+
setComposerStatus("Target changed. Re-open reply to send.");
|
|
2883
|
+
}
|
|
2884
|
+
markLiveActivity();
|
|
2885
|
+
scheduleChangesRefresh({ immediate: true, showLoading: true });
|
|
2886
|
+
schedulePresenceSync(40, { force: true });
|
|
2887
|
+
render();
|
|
2888
|
+
scheduleQueueFlush(220);
|
|
2889
|
+
} catch (error) {
|
|
2890
|
+
if (requestVersion === selectionRequestVersion) {
|
|
2891
|
+
adoptErrorState(error);
|
|
2892
|
+
selectionIntent = null;
|
|
2893
|
+
uiState.selecting = false;
|
|
2894
|
+
render();
|
|
2895
|
+
}
|
|
2896
|
+
throw error;
|
|
2897
|
+
} finally {
|
|
2898
|
+
if (requestVersion === selectionRequestVersion && !selectionIntent) {
|
|
2899
|
+
uiState.selecting = false;
|
|
2900
|
+
render();
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
async function submitControl(action) {
|
|
2906
|
+
uiState.controlling = true;
|
|
2907
|
+
render();
|
|
2908
|
+
|
|
2909
|
+
try {
|
|
2910
|
+
const payload = await requestJson(controlUrl, {
|
|
2911
|
+
method: "POST",
|
|
2912
|
+
headers: { "Content-Type": "application/json" },
|
|
2913
|
+
body: JSON.stringify({
|
|
2914
|
+
action,
|
|
2915
|
+
clientId: surfaceAuthClientId,
|
|
2916
|
+
source: "remote",
|
|
2917
|
+
threadId: currentThreadId()
|
|
2918
|
+
})
|
|
2919
|
+
});
|
|
2920
|
+
currentLiveState = payload.state;
|
|
2921
|
+
markLiveActivity();
|
|
2922
|
+
schedulePresenceSync(30, { force: true });
|
|
2923
|
+
render();
|
|
2924
|
+
if (action === "claim") {
|
|
2925
|
+
scheduleQueueFlush(180);
|
|
2926
|
+
}
|
|
2927
|
+
} catch (error) {
|
|
2928
|
+
adoptErrorState(error);
|
|
2929
|
+
throw error;
|
|
2930
|
+
} finally {
|
|
2931
|
+
uiState.controlling = false;
|
|
2932
|
+
render();
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
async function renewControlLeaseInBackground() {
|
|
2937
|
+
if (controlRenewPromise || uiState.controlling || uiState.selecting || uiState.booting || !shouldRenewControlLease()) {
|
|
2938
|
+
return controlRenewPromise;
|
|
2939
|
+
}
|
|
2940
|
+
|
|
2941
|
+
controlRenewPromise = requestJson(controlUrl, {
|
|
2942
|
+
method: "POST",
|
|
2943
|
+
headers: { "Content-Type": "application/json" },
|
|
2944
|
+
body: JSON.stringify({
|
|
2945
|
+
action: "renew",
|
|
2946
|
+
clientId: surfaceAuthClientId,
|
|
2947
|
+
source: "remote",
|
|
2948
|
+
threadId: currentThreadId()
|
|
2949
|
+
})
|
|
2950
|
+
})
|
|
2951
|
+
.then((payload) => {
|
|
2952
|
+
currentLiveState = payload.state;
|
|
2953
|
+
markLiveActivity();
|
|
2954
|
+
return payload;
|
|
2955
|
+
})
|
|
2956
|
+
.catch(async () => {
|
|
2957
|
+
await refreshLiveState({ background: true });
|
|
2958
|
+
return null;
|
|
2959
|
+
})
|
|
2960
|
+
.finally(() => {
|
|
2961
|
+
controlRenewPromise = null;
|
|
2962
|
+
});
|
|
2963
|
+
|
|
2964
|
+
return controlRenewPromise;
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
async function submitInteraction(body) {
|
|
2968
|
+
const previousPending = currentLiveState?.pendingInteraction || null;
|
|
2969
|
+
uiState.submittingAction = body?.action || "submit";
|
|
2970
|
+
render();
|
|
2971
|
+
|
|
2972
|
+
try {
|
|
2973
|
+
const payload = await requestJson(interactionUrl, {
|
|
2974
|
+
method: "POST",
|
|
2975
|
+
headers: { "Content-Type": "application/json" },
|
|
2976
|
+
body: JSON.stringify({
|
|
2977
|
+
...body,
|
|
2978
|
+
source: "remote"
|
|
2979
|
+
})
|
|
2980
|
+
});
|
|
2981
|
+
currentLiveState = payload.state;
|
|
2982
|
+
if (!currentLiveState?.pendingInteraction) {
|
|
2983
|
+
beginActionHandoff(previousPending, currentLiveState, body?.action || "submit");
|
|
2984
|
+
}
|
|
2985
|
+
markLiveActivity();
|
|
2986
|
+
scheduleChangesRefresh({ immediate: true });
|
|
2987
|
+
schedulePresenceSync(30, { force: true });
|
|
2988
|
+
render();
|
|
2989
|
+
scheduleQueueFlush(220);
|
|
2990
|
+
} catch (error) {
|
|
2991
|
+
adoptErrorState(error);
|
|
2992
|
+
throw error;
|
|
2993
|
+
} finally {
|
|
2994
|
+
uiState.submittingAction = false;
|
|
2995
|
+
render();
|
|
2996
|
+
}
|
|
2997
|
+
}
|
|
2998
|
+
|
|
2999
|
+
async function submitCompanionAction({ action, advisorId, wakeKey }) {
|
|
3000
|
+
if (!action || !wakeKey || companionActionState) {
|
|
3001
|
+
return;
|
|
3002
|
+
}
|
|
3003
|
+
|
|
3004
|
+
companionActionState = {
|
|
3005
|
+
action,
|
|
3006
|
+
key: wakeKey
|
|
3007
|
+
};
|
|
3008
|
+
render();
|
|
3009
|
+
|
|
3010
|
+
try {
|
|
3011
|
+
const payload = await requestJson(companionUrl, {
|
|
3012
|
+
method: "POST",
|
|
3013
|
+
headers: { "Content-Type": "application/json" },
|
|
3014
|
+
body: JSON.stringify({
|
|
3015
|
+
action,
|
|
3016
|
+
advisorId,
|
|
3017
|
+
threadId: currentThreadId(),
|
|
3018
|
+
wakeKey
|
|
3019
|
+
})
|
|
3020
|
+
});
|
|
3021
|
+
currentLiveState = payload.state;
|
|
3022
|
+
markLiveActivity();
|
|
3023
|
+
schedulePresenceSync(30, { force: true });
|
|
3024
|
+
setTransientUiNotice(payload.message || "Advisory reminder updated.", "success");
|
|
3025
|
+
render();
|
|
3026
|
+
} catch (error) {
|
|
3027
|
+
adoptErrorState(error);
|
|
3028
|
+
throw error;
|
|
3029
|
+
} finally {
|
|
3030
|
+
companionActionState = null;
|
|
3031
|
+
render();
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
|
|
3035
|
+
async function summonAdvisor(advisorId = "") {
|
|
3036
|
+
const normalizedAdvisorId = String(advisorId || "").trim().toLowerCase();
|
|
3037
|
+
if (!normalizedAdvisorId || manualAdvisorAction || companionActionState) {
|
|
3038
|
+
return;
|
|
3039
|
+
}
|
|
3040
|
+
|
|
3041
|
+
manualAdvisorAction = normalizedAdvisorId;
|
|
3042
|
+
render();
|
|
3043
|
+
|
|
3044
|
+
try {
|
|
3045
|
+
const payload = await requestJson(companionUrl, {
|
|
3046
|
+
method: "POST",
|
|
3047
|
+
headers: { "Content-Type": "application/json" },
|
|
3048
|
+
body: JSON.stringify({
|
|
3049
|
+
action: "summon",
|
|
3050
|
+
advisorId: normalizedAdvisorId,
|
|
3051
|
+
threadId: currentThreadId()
|
|
3052
|
+
})
|
|
3053
|
+
});
|
|
3054
|
+
currentLiveState = payload.state || currentLiveState;
|
|
3055
|
+
markLiveActivity();
|
|
3056
|
+
schedulePresenceSync(30, { force: true });
|
|
3057
|
+
setTransientUiNotice(payload.message || "Shared note ready.", "success", 2400);
|
|
3058
|
+
render();
|
|
3059
|
+
} catch (error) {
|
|
3060
|
+
adoptErrorState(error);
|
|
3061
|
+
throw error;
|
|
3062
|
+
} finally {
|
|
3063
|
+
manualAdvisorAction = "";
|
|
3064
|
+
render();
|
|
3065
|
+
}
|
|
3066
|
+
}
|
|
3067
|
+
|
|
3068
|
+
function collectAnswers(questions) {
|
|
3069
|
+
const answers = {};
|
|
3070
|
+
|
|
3071
|
+
for (const question of questions || []) {
|
|
3072
|
+
const primary = nodes.actionQuestions.querySelector(`[data-answer-id="${question.id}"]`);
|
|
3073
|
+
const other = nodes.actionQuestions.querySelector(`[data-answer-other="${question.id}"]`);
|
|
3074
|
+
const otherValue = other?.value.trim();
|
|
3075
|
+
|
|
3076
|
+
if (otherValue) {
|
|
3077
|
+
answers[question.id] = otherValue;
|
|
3078
|
+
continue;
|
|
3079
|
+
}
|
|
3080
|
+
|
|
3081
|
+
const primaryValue = primary?.value?.trim();
|
|
3082
|
+
if (primaryValue && primaryValue !== "__other__") {
|
|
3083
|
+
answers[question.id] = primaryValue;
|
|
3084
|
+
}
|
|
3085
|
+
}
|
|
3086
|
+
|
|
3087
|
+
return answers;
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3090
|
+
function scheduleQueueFlush(delayMs = 320) {
|
|
3091
|
+
if (queueFlushTimer) {
|
|
3092
|
+
window.clearTimeout(queueFlushTimer);
|
|
3093
|
+
}
|
|
3094
|
+
|
|
3095
|
+
queueFlushTimer = window.setTimeout(() => {
|
|
3096
|
+
queueFlushTimer = null;
|
|
3097
|
+
void flushQueuedReplies({ background: true });
|
|
3098
|
+
}, delayMs);
|
|
3099
|
+
}
|
|
3100
|
+
|
|
3101
|
+
function queueLiveReply({ rawText, attachments }) {
|
|
3102
|
+
const threadId = currentThreadId();
|
|
3103
|
+
const reply = createQueuedReply({
|
|
3104
|
+
attachments,
|
|
3105
|
+
queuedAt: new Date().toISOString(),
|
|
3106
|
+
rawText,
|
|
3107
|
+
sequence: queuedReplySequence += 1,
|
|
3108
|
+
threadId
|
|
3109
|
+
});
|
|
3110
|
+
|
|
3111
|
+
const nextQueue = [...queuedRepliesForThread(threadId), reply];
|
|
3112
|
+
setQueuedRepliesForThread(threadId, nextQueue);
|
|
3113
|
+
nodes.replyText.value = "";
|
|
3114
|
+
clearPersistedDraft(threadId);
|
|
3115
|
+
clearStagedCompanionWakeKey();
|
|
3116
|
+
clearAttachments();
|
|
3117
|
+
isComposerOpen = true;
|
|
3118
|
+
setComposerStatus(
|
|
3119
|
+
hasRemoteControl(threadId)
|
|
3120
|
+
? nextQueue.length === 1
|
|
3121
|
+
? "Queued. Waiting for idle."
|
|
3122
|
+
: `Queued. ${nextQueue.length} waiting for idle.`
|
|
3123
|
+
: threadBusy()
|
|
3124
|
+
? nextQueue.length === 1
|
|
3125
|
+
? "Queued locally. Waiting for idle."
|
|
3126
|
+
: `Queued locally. ${nextQueue.length} waiting for idle.`
|
|
3127
|
+
: nextQueue.length === 1
|
|
3128
|
+
? "Queued locally. Sending soon."
|
|
3129
|
+
: `Queued locally. ${nextQueue.length} sending soon.`,
|
|
3130
|
+
"success"
|
|
3131
|
+
);
|
|
3132
|
+
render();
|
|
3133
|
+
focusReplyTextAtEnd();
|
|
3134
|
+
scheduleQueueFlush();
|
|
3135
|
+
}
|
|
3136
|
+
|
|
3137
|
+
async function dispatchLiveReply({ rawText, attachments, threadId = currentThreadId(), preserveDraftOnError = false } = {}) {
|
|
3138
|
+
const text = rawText.trim();
|
|
3139
|
+
if (!text && !attachments.length) {
|
|
3140
|
+
throw new Error("Reply cannot be empty.");
|
|
3141
|
+
}
|
|
3142
|
+
|
|
3143
|
+
const blockedReason = sendBlockedReason(threadId);
|
|
3144
|
+
if (blockedReason) {
|
|
3145
|
+
throw new Error(blockedReason);
|
|
3146
|
+
}
|
|
3147
|
+
|
|
3148
|
+
const liveThread = currentLiveState?.selectedThreadSnapshot?.thread;
|
|
3149
|
+
if (!liveThread?.id || liveThread.id !== threadId) {
|
|
3150
|
+
throw new Error("Selected session changed before send.");
|
|
3151
|
+
}
|
|
3152
|
+
|
|
3153
|
+
const composerWasOpen = isComposerOpen;
|
|
3154
|
+
const previousDraft = nodes.replyText.value;
|
|
3155
|
+
const previousAttachments = pendingAttachments.slice();
|
|
3156
|
+
|
|
3157
|
+
isSendingReply = true;
|
|
3158
|
+
pendingOutgoingText = text;
|
|
3159
|
+
pendingOutgoingAttachments = cloneReplyAttachments(attachments);
|
|
3160
|
+
isComposerOpen = composerWasOpen;
|
|
3161
|
+
nodes.replyText.value = "";
|
|
3162
|
+
clearPersistedDraft(threadId);
|
|
3163
|
+
clearStagedCompanionWakeKey();
|
|
3164
|
+
clearAttachments();
|
|
3165
|
+
setComposerStatus("Sending...", "sending");
|
|
3166
|
+
render();
|
|
3167
|
+
|
|
3168
|
+
try {
|
|
3169
|
+
const payload = await requestJson(turnUrl, {
|
|
3170
|
+
method: "POST",
|
|
3171
|
+
headers: { "Content-Type": "application/json" },
|
|
3172
|
+
body: JSON.stringify({
|
|
3173
|
+
attachments: attachments.map((attachment) => ({
|
|
3174
|
+
dataUrl: attachment.dataUrl,
|
|
3175
|
+
name: attachment.name,
|
|
3176
|
+
type: attachment.type
|
|
3177
|
+
})),
|
|
3178
|
+
clientId: surfaceAuthClientId,
|
|
3179
|
+
source: "remote",
|
|
3180
|
+
threadId: liveThread.id,
|
|
3181
|
+
text
|
|
3182
|
+
})
|
|
3183
|
+
});
|
|
3184
|
+
|
|
3185
|
+
currentLiveState = {
|
|
3186
|
+
...(currentLiveState || {}),
|
|
3187
|
+
selectedProjectCwd: payload.thread?.cwd || currentLiveState?.selectedProjectCwd || "",
|
|
3188
|
+
selectedThreadId: payload.thread?.id || currentLiveState?.selectedThreadId || "",
|
|
3189
|
+
selectedThreadSnapshot: payload.snapshot || currentLiveState?.selectedThreadSnapshot || null,
|
|
3190
|
+
status: {
|
|
3191
|
+
...(currentLiveState?.status || {}),
|
|
3192
|
+
lastWrite: {
|
|
3193
|
+
at: new Date().toISOString(),
|
|
3194
|
+
source: "remote",
|
|
3195
|
+
threadId: payload.thread?.id || liveThread.id,
|
|
3196
|
+
turnId: payload.turn?.id || null,
|
|
3197
|
+
turnStatus: payload.turn?.status || null
|
|
3198
|
+
},
|
|
3199
|
+
lastWriteForSelection: {
|
|
3200
|
+
at: new Date().toISOString(),
|
|
3201
|
+
source: "remote",
|
|
3202
|
+
threadId: payload.thread?.id || liveThread.id,
|
|
3203
|
+
turnId: payload.turn?.id || null,
|
|
3204
|
+
turnStatus: payload.turn?.status || null
|
|
3205
|
+
},
|
|
3206
|
+
writeLock: null
|
|
3207
|
+
}
|
|
3208
|
+
};
|
|
3209
|
+
markLiveActivity();
|
|
3210
|
+
scheduleChangesRefresh({ immediate: true });
|
|
3211
|
+
pendingOutgoingText = "";
|
|
3212
|
+
pendingOutgoingAttachments = [];
|
|
3213
|
+
isSendingReply = false;
|
|
3214
|
+
isComposerOpen = composerWasOpen;
|
|
3215
|
+
setComposerStatus(
|
|
3216
|
+
"Sent here. Dextunnel is current. Desktop Codex may still need a quit and reopen to show this turn.",
|
|
3217
|
+
"success"
|
|
3218
|
+
);
|
|
3219
|
+
render();
|
|
3220
|
+
if (composerWasOpen) {
|
|
3221
|
+
focusReplyTextAtEnd();
|
|
3222
|
+
}
|
|
3223
|
+
scheduleQueueFlush(650);
|
|
3224
|
+
window.setTimeout(() => {
|
|
3225
|
+
if (!isSendingReply && composerStatus === "Sent.") {
|
|
3226
|
+
setComposerStatus("Ready");
|
|
3227
|
+
render();
|
|
3228
|
+
}
|
|
3229
|
+
}, 1600);
|
|
3230
|
+
} catch (error) {
|
|
3231
|
+
adoptErrorState(error);
|
|
3232
|
+
isSendingReply = false;
|
|
3233
|
+
pendingOutgoingText = "";
|
|
3234
|
+
pendingOutgoingAttachments = [];
|
|
3235
|
+
if (preserveDraftOnError) {
|
|
3236
|
+
nodes.replyText.value = previousDraft;
|
|
3237
|
+
pendingAttachments = previousAttachments;
|
|
3238
|
+
persistDraft(threadId, previousDraft);
|
|
3239
|
+
renderAttachments();
|
|
3240
|
+
isComposerOpen = composerWasOpen;
|
|
3241
|
+
}
|
|
3242
|
+
setComposerStatus(error.message, "error");
|
|
3243
|
+
render();
|
|
3244
|
+
throw error;
|
|
3245
|
+
}
|
|
3246
|
+
}
|
|
3247
|
+
|
|
3248
|
+
async function flushQueuedReplies({ background = false } = {}) {
|
|
3249
|
+
if (queueFlushPromise || isSendingReply) {
|
|
3250
|
+
return queueFlushPromise;
|
|
3251
|
+
}
|
|
3252
|
+
|
|
3253
|
+
const threadId = currentThreadId();
|
|
3254
|
+
const queue = queuedRepliesForThread(threadId);
|
|
3255
|
+
if (
|
|
3256
|
+
!shouldFlushQueuedReplies({
|
|
3257
|
+
blockedReason: sendBlockedReason(threadId),
|
|
3258
|
+
hasInFlight: Boolean(queueFlushPromise),
|
|
3259
|
+
isSendingReply,
|
|
3260
|
+
queuedCount: queue.length,
|
|
3261
|
+
threadBusy: threadBusy(),
|
|
3262
|
+
threadId
|
|
3263
|
+
})
|
|
3264
|
+
) {
|
|
3265
|
+
return null;
|
|
3266
|
+
}
|
|
3267
|
+
|
|
3268
|
+
const blockedReason = sendBlockedReason(threadId);
|
|
3269
|
+
if (controlClaimRequired(blockedReason)) {
|
|
3270
|
+
try {
|
|
3271
|
+
await submitControl("claim");
|
|
3272
|
+
} catch (error) {
|
|
3273
|
+
if (!background) {
|
|
3274
|
+
throw error;
|
|
3275
|
+
}
|
|
3276
|
+
setComposerStatus(error.message, "error");
|
|
3277
|
+
render();
|
|
3278
|
+
return null;
|
|
3279
|
+
}
|
|
3280
|
+
}
|
|
3281
|
+
|
|
3282
|
+
const [nextReply, ...remaining] = queue;
|
|
3283
|
+
setQueuedRepliesForThread(threadId, remaining);
|
|
3284
|
+
render();
|
|
3285
|
+
|
|
3286
|
+
queueFlushPromise = dispatchLiveReply({
|
|
3287
|
+
attachments: nextReply.attachments,
|
|
3288
|
+
rawText: nextReply.text,
|
|
3289
|
+
threadId
|
|
3290
|
+
})
|
|
3291
|
+
.then((payload) => {
|
|
3292
|
+
if (background && payload) {
|
|
3293
|
+
const remainingCount = queuedRepliesForThread(threadId).length;
|
|
3294
|
+
setTransientUiNotice(
|
|
3295
|
+
remainingCount ? `Queued send complete. ${remainingCount} left.` : "Queued send complete.",
|
|
3296
|
+
"success",
|
|
3297
|
+
2200
|
|
3298
|
+
);
|
|
3299
|
+
}
|
|
3300
|
+
return payload;
|
|
3301
|
+
})
|
|
3302
|
+
.catch((error) => {
|
|
3303
|
+
setQueuedRepliesForThread(threadId, [nextReply, ...queuedRepliesForThread(threadId)]);
|
|
3304
|
+
setComposerStatus(error.message, "error");
|
|
3305
|
+
render();
|
|
3306
|
+
if (!background) {
|
|
3307
|
+
throw error;
|
|
3308
|
+
}
|
|
3309
|
+
return null;
|
|
3310
|
+
})
|
|
3311
|
+
.finally(() => {
|
|
3312
|
+
queueFlushPromise = null;
|
|
3313
|
+
if (queuedRepliesForThread(threadId).length) {
|
|
3314
|
+
scheduleQueueFlush(900);
|
|
3315
|
+
}
|
|
3316
|
+
});
|
|
3317
|
+
|
|
3318
|
+
return queueFlushPromise;
|
|
3319
|
+
}
|
|
3320
|
+
|
|
3321
|
+
async function submitLiveReply({ rawText, attachments }) {
|
|
3322
|
+
const text = rawText.trim();
|
|
3323
|
+
if (!text && !attachments.length) {
|
|
3324
|
+
throw new Error("Reply cannot be empty.");
|
|
3325
|
+
}
|
|
3326
|
+
|
|
3327
|
+
const blockedReason = sendBlockedReason();
|
|
3328
|
+
if (blockedReason && !controlClaimRequired(blockedReason)) {
|
|
3329
|
+
throw new Error(blockedReason);
|
|
3330
|
+
}
|
|
3331
|
+
|
|
3332
|
+
if (controlClaimRequired(blockedReason)) {
|
|
3333
|
+
setComposerStatus("Taking control...", "sending");
|
|
3334
|
+
render();
|
|
3335
|
+
await submitControl("claim");
|
|
3336
|
+
}
|
|
3337
|
+
|
|
3338
|
+
if (threadBusy()) {
|
|
3339
|
+
throw new Error("Codex is busy. Use Queue.");
|
|
3340
|
+
}
|
|
3341
|
+
|
|
3342
|
+
try {
|
|
3343
|
+
await dispatchLiveReply({
|
|
3344
|
+
attachments,
|
|
3345
|
+
rawText: text,
|
|
3346
|
+
preserveDraftOnError: true
|
|
3347
|
+
});
|
|
3348
|
+
} catch (error) {
|
|
3349
|
+
if (/already has an active turn|Write already in progress|running/i.test(String(error.message || ""))) {
|
|
3350
|
+
queueLiveReply({ attachments, rawText: text });
|
|
3351
|
+
setComposerStatus("Thread turned busy. Reply queued.", "success");
|
|
3352
|
+
render();
|
|
3353
|
+
return;
|
|
3354
|
+
}
|
|
3355
|
+
|
|
3356
|
+
throw error;
|
|
3357
|
+
}
|
|
3358
|
+
}
|
|
3359
|
+
|
|
3360
|
+
nodes.sidebarToggleButton?.addEventListener("click", () => {
|
|
3361
|
+
markUserIntent();
|
|
3362
|
+
setSidebarExpanded(!sidebarExpanded);
|
|
3363
|
+
render();
|
|
3364
|
+
});
|
|
3365
|
+
|
|
3366
|
+
nodes.sidebarOverlay?.addEventListener("click", () => {
|
|
3367
|
+
if (!isMobileSidebarLayout() || !sidebarExpanded) {
|
|
3368
|
+
return;
|
|
3369
|
+
}
|
|
3370
|
+
markUserIntent();
|
|
3371
|
+
setSidebarExpanded(false);
|
|
3372
|
+
render();
|
|
3373
|
+
});
|
|
3374
|
+
|
|
3375
|
+
window.addEventListener("resize", () => {
|
|
3376
|
+
const mobileLayout = isMobileSidebarLayout();
|
|
3377
|
+
if (mobileLayout === lastSidebarMobileLayout) {
|
|
3378
|
+
return;
|
|
3379
|
+
}
|
|
3380
|
+
|
|
3381
|
+
lastSidebarMobileLayout = mobileLayout;
|
|
3382
|
+
if (mobileLayout) {
|
|
3383
|
+
setSidebarExpanded(false, { persist: false });
|
|
3384
|
+
} else {
|
|
3385
|
+
setSidebarExpanded(surfaceViewState.loadSidebarMode() !== "collapsed", { persist: false });
|
|
3386
|
+
}
|
|
3387
|
+
render();
|
|
3388
|
+
});
|
|
3389
|
+
|
|
3390
|
+
nodes.sidebarGroups?.addEventListener("click", async (event) => {
|
|
3391
|
+
const target = event.target.closest("[data-sidebar-thread-id]");
|
|
3392
|
+
if (!(target instanceof HTMLElement) || target.hasAttribute("disabled")) {
|
|
3393
|
+
return;
|
|
3394
|
+
}
|
|
3395
|
+
|
|
3396
|
+
const threadId = String(target.dataset.sidebarThreadId || "").trim();
|
|
3397
|
+
const cwd = String(target.dataset.sidebarCwd || "").trim();
|
|
3398
|
+
if (!threadId) {
|
|
3399
|
+
return;
|
|
3400
|
+
}
|
|
3401
|
+
|
|
3402
|
+
markUserIntent();
|
|
3403
|
+
try {
|
|
3404
|
+
await submitSelection({
|
|
3405
|
+
clientId: surfaceAuthClientId,
|
|
3406
|
+
cwd,
|
|
3407
|
+
source: "remote",
|
|
3408
|
+
threadId
|
|
3409
|
+
});
|
|
3410
|
+
if (isMobileSidebarLayout()) {
|
|
3411
|
+
setSidebarExpanded(false);
|
|
3412
|
+
render();
|
|
3413
|
+
}
|
|
3414
|
+
} catch (error) {
|
|
3415
|
+
setComposerStatus(error.message, "error");
|
|
3416
|
+
render();
|
|
3417
|
+
}
|
|
3418
|
+
});
|
|
3419
|
+
|
|
3420
|
+
for (const button of nodes.filterButtons) {
|
|
3421
|
+
button.addEventListener("click", () => {
|
|
3422
|
+
markUserIntent();
|
|
3423
|
+
const filter = button.dataset.filter;
|
|
3424
|
+
|
|
3425
|
+
feedFilters[filter] = !feedFilters[filter];
|
|
3426
|
+
surfaceViewState.saveFilters(feedFilters);
|
|
3427
|
+
render();
|
|
3428
|
+
});
|
|
3429
|
+
}
|
|
3430
|
+
|
|
3431
|
+
nodes.expandAllButton?.addEventListener("click", () => {
|
|
3432
|
+
markUserIntent();
|
|
3433
|
+
expandAllCards = !expandAllCards;
|
|
3434
|
+
if (!expandAllCards) {
|
|
3435
|
+
expandedEntryKeys.clear();
|
|
3436
|
+
}
|
|
3437
|
+
surfaceViewState.saveExpansionMode(currentThreadIdLabel() || "none", expandAllCards ? "expanded" : "compact");
|
|
3438
|
+
render();
|
|
3439
|
+
});
|
|
3440
|
+
|
|
3441
|
+
nodes.feed.addEventListener(
|
|
3442
|
+
"toggle",
|
|
3443
|
+
(event) => {
|
|
3444
|
+
const details = event.target.closest?.("details[data-feed-section]");
|
|
3445
|
+
if (!details) {
|
|
3446
|
+
return;
|
|
3447
|
+
}
|
|
3448
|
+
|
|
3449
|
+
const key = feedSectionKey(details.dataset.feedSection || "");
|
|
3450
|
+
if (details.open) {
|
|
3451
|
+
expandedFeedSections.add(key);
|
|
3452
|
+
} else {
|
|
3453
|
+
expandedFeedSections.delete(key);
|
|
3454
|
+
}
|
|
3455
|
+
surfaceViewState.saveExpandedSections(currentThreadIdLabel() || "none", [...expandedFeedSections]);
|
|
3456
|
+
},
|
|
3457
|
+
true
|
|
3458
|
+
);
|
|
3459
|
+
|
|
3460
|
+
for (const button of nodes.companionSummonButtons) {
|
|
3461
|
+
button.addEventListener("click", async () => {
|
|
3462
|
+
markUserIntent();
|
|
3463
|
+
try {
|
|
3464
|
+
await summonAdvisor(button.dataset.companionSummon || "");
|
|
3465
|
+
} catch (error) {
|
|
3466
|
+
setComposerStatus(error.message, "error");
|
|
3467
|
+
render();
|
|
3468
|
+
}
|
|
3469
|
+
});
|
|
3470
|
+
}
|
|
3471
|
+
|
|
3472
|
+
nodes.replyToggleButton.addEventListener("click", () => {
|
|
3473
|
+
markUserIntent();
|
|
3474
|
+
if (composeBlockedReason() || isSendingReply) {
|
|
3475
|
+
return;
|
|
3476
|
+
}
|
|
3477
|
+
|
|
3478
|
+
isComposerOpen = !isComposerOpen;
|
|
3479
|
+
if (isComposerOpen && !hasRemoteControl()) {
|
|
3480
|
+
setComposerStatus("Steer now takes control. Queue stays local.");
|
|
3481
|
+
} else if (isComposerOpen) {
|
|
3482
|
+
setComposerStatus("Ready");
|
|
3483
|
+
} else {
|
|
3484
|
+
clearStagedCompanionWakeKey();
|
|
3485
|
+
setComposerStatus("Ready");
|
|
3486
|
+
}
|
|
3487
|
+
render();
|
|
3488
|
+
|
|
3489
|
+
if (isComposerOpen) {
|
|
3490
|
+
focusReplyTextAtEnd();
|
|
3491
|
+
}
|
|
3492
|
+
});
|
|
3493
|
+
|
|
3494
|
+
nodes.composerCloseButton.addEventListener("click", () => {
|
|
3495
|
+
markUserIntent();
|
|
3496
|
+
if (isSendingReply) {
|
|
3497
|
+
return;
|
|
3498
|
+
}
|
|
3499
|
+
|
|
3500
|
+
if (isDictating) {
|
|
3501
|
+
stopDictation();
|
|
3502
|
+
return;
|
|
3503
|
+
}
|
|
3504
|
+
|
|
3505
|
+
isComposerOpen = false;
|
|
3506
|
+
clearStagedCompanionWakeKey();
|
|
3507
|
+
setComposerStatus("Ready");
|
|
3508
|
+
render();
|
|
3509
|
+
});
|
|
3510
|
+
|
|
3511
|
+
nodes.dictationButton.addEventListener("click", () => {
|
|
3512
|
+
markUserIntent();
|
|
3513
|
+
if (suppressNextDictationClick) {
|
|
3514
|
+
suppressNextDictationClick = false;
|
|
3515
|
+
return;
|
|
3516
|
+
}
|
|
3517
|
+
try {
|
|
3518
|
+
if (isDictating) {
|
|
3519
|
+
stopDictation();
|
|
3520
|
+
return;
|
|
3521
|
+
}
|
|
3522
|
+
|
|
3523
|
+
startDictation();
|
|
3524
|
+
} catch (error) {
|
|
3525
|
+
setComposerStatus(error.message, "error");
|
|
3526
|
+
render();
|
|
3527
|
+
}
|
|
3528
|
+
});
|
|
3529
|
+
|
|
3530
|
+
if (hasPointerSupport()) {
|
|
3531
|
+
nodes.dictationButton.addEventListener("pointerdown", (event) => {
|
|
3532
|
+
markUserIntent();
|
|
3533
|
+
if (event.pointerType === "mouse" && event.button !== 0) {
|
|
3534
|
+
return;
|
|
3535
|
+
}
|
|
3536
|
+
if (nodes.dictationButton.disabled) {
|
|
3537
|
+
return;
|
|
3538
|
+
}
|
|
3539
|
+
try {
|
|
3540
|
+
nodes.dictationButton.setPointerCapture?.(event.pointerId);
|
|
3541
|
+
} catch {}
|
|
3542
|
+
beginPressDictation(event.pointerId);
|
|
3543
|
+
});
|
|
3544
|
+
|
|
3545
|
+
const releasePointerDictation = (event) => {
|
|
3546
|
+
endPressDictation(event.pointerId);
|
|
3547
|
+
};
|
|
3548
|
+
|
|
3549
|
+
nodes.dictationButton.addEventListener("pointerup", releasePointerDictation);
|
|
3550
|
+
nodes.dictationButton.addEventListener("pointercancel", releasePointerDictation);
|
|
3551
|
+
nodes.dictationButton.addEventListener("lostpointercapture", () => {
|
|
3552
|
+
endPressDictation();
|
|
3553
|
+
});
|
|
3554
|
+
}
|
|
3555
|
+
|
|
3556
|
+
nodes.controlToggleButton.addEventListener("click", async () => {
|
|
3557
|
+
markUserIntent();
|
|
3558
|
+
const threadId = currentThreadId();
|
|
3559
|
+
if (!threadId || sessionBlockedReason() || isSendingReply || uiState.controlling) {
|
|
3560
|
+
return;
|
|
3561
|
+
}
|
|
3562
|
+
|
|
3563
|
+
try {
|
|
3564
|
+
const nextAction = hasRemoteControl(threadId) ? "release" : "claim";
|
|
3565
|
+
await submitControl(nextAction);
|
|
3566
|
+
const message = nextAction === "claim" ? "Remote control active." : "Remote control released.";
|
|
3567
|
+
setComposerStatus(message, "success");
|
|
3568
|
+
render();
|
|
3569
|
+
scheduleComposerStatusReset();
|
|
3570
|
+
} catch (error) {
|
|
3571
|
+
setComposerStatus(error.message, "error");
|
|
3572
|
+
render();
|
|
3573
|
+
}
|
|
3574
|
+
});
|
|
3575
|
+
|
|
3576
|
+
nodes.actionControlButton.addEventListener("click", async () => {
|
|
3577
|
+
markUserIntent();
|
|
3578
|
+
if (!currentThreadId() || uiState.controlling || sessionBlockedReason()) {
|
|
3579
|
+
return;
|
|
3580
|
+
}
|
|
3581
|
+
|
|
3582
|
+
try {
|
|
3583
|
+
await submitControl("claim");
|
|
3584
|
+
setComposerStatus("Remote control active.", "success");
|
|
3585
|
+
render();
|
|
3586
|
+
scheduleComposerStatusReset();
|
|
3587
|
+
} catch (error) {
|
|
3588
|
+
setComposerStatus(error.message, "error");
|
|
3589
|
+
render();
|
|
3590
|
+
}
|
|
3591
|
+
});
|
|
3592
|
+
|
|
3593
|
+
nodes.composerControlButton.addEventListener("click", async () => {
|
|
3594
|
+
markUserIntent();
|
|
3595
|
+
if (!currentThreadId() || uiState.controlling || sessionBlockedReason()) {
|
|
3596
|
+
return;
|
|
3597
|
+
}
|
|
3598
|
+
|
|
3599
|
+
try {
|
|
3600
|
+
await submitControl("claim");
|
|
3601
|
+
setComposerStatus("Remote control active.", "success");
|
|
3602
|
+
render();
|
|
3603
|
+
scheduleComposerStatusReset();
|
|
3604
|
+
} catch (error) {
|
|
3605
|
+
setComposerStatus(error.message, "error");
|
|
3606
|
+
render();
|
|
3607
|
+
}
|
|
3608
|
+
});
|
|
3609
|
+
|
|
3610
|
+
nodes.replyImageInput.addEventListener("change", async (event) => {
|
|
3611
|
+
markUserIntent();
|
|
3612
|
+
const files = Array.from(event.target.files || []).filter((file) => file.type.startsWith("image/"));
|
|
3613
|
+
if (!files.length) {
|
|
3614
|
+
return;
|
|
3615
|
+
}
|
|
3616
|
+
|
|
3617
|
+
try {
|
|
3618
|
+
const nextAttachments = await Promise.all(
|
|
3619
|
+
files.map(async (file, index) => ({
|
|
3620
|
+
dataUrl: await fileToDataUrl(file),
|
|
3621
|
+
id: `${file.name}-${file.size}-${Date.now()}-${index}`,
|
|
3622
|
+
name: file.name,
|
|
3623
|
+
size: file.size,
|
|
3624
|
+
type: file.type || "image/png"
|
|
3625
|
+
}))
|
|
3626
|
+
);
|
|
3627
|
+
|
|
3628
|
+
pendingAttachments = [...pendingAttachments, ...nextAttachments].slice(0, 4);
|
|
3629
|
+
nodes.replyImageInput.value = "";
|
|
3630
|
+
renderAttachments();
|
|
3631
|
+
if (!isComposerOpen && !isSendingReply) {
|
|
3632
|
+
isComposerOpen = true;
|
|
3633
|
+
}
|
|
3634
|
+
setComposerStatus("Image attached.");
|
|
3635
|
+
render();
|
|
3636
|
+
} catch (error) {
|
|
3637
|
+
nodes.replyImageInput.value = "";
|
|
3638
|
+
setComposerStatus(error.message, "error");
|
|
3639
|
+
render();
|
|
3640
|
+
}
|
|
3641
|
+
});
|
|
3642
|
+
|
|
3643
|
+
nodes.replyText.addEventListener("input", () => {
|
|
3644
|
+
if (isDictating) {
|
|
3645
|
+
return;
|
|
3646
|
+
}
|
|
3647
|
+
persistDraft();
|
|
3648
|
+
if (!isComposerOpen && !isSendingReply) {
|
|
3649
|
+
isComposerOpen = true;
|
|
3650
|
+
}
|
|
3651
|
+
render();
|
|
3652
|
+
});
|
|
3653
|
+
|
|
3654
|
+
nodes.attachmentList.addEventListener("click", (event) => {
|
|
3655
|
+
markUserIntent();
|
|
3656
|
+
const button = event.target.closest("[data-attachment-id]");
|
|
3657
|
+
if (!button || isSendingReply) {
|
|
3658
|
+
return;
|
|
3659
|
+
}
|
|
3660
|
+
|
|
3661
|
+
pendingAttachments = pendingAttachments.filter((attachment) => attachment.id !== button.dataset.attachmentId);
|
|
3662
|
+
renderAttachments();
|
|
3663
|
+
setComposerStatus(pendingAttachments.length ? "Image removed." : "Ready");
|
|
3664
|
+
render();
|
|
3665
|
+
});
|
|
3666
|
+
|
|
3667
|
+
nodes.feed.addEventListener("click", async (event) => {
|
|
3668
|
+
markUserIntent();
|
|
3669
|
+
const actionButton = event.target.closest("[data-companion-action]");
|
|
3670
|
+
if (actionButton) {
|
|
3671
|
+
event.preventDefault();
|
|
3672
|
+
event.stopPropagation();
|
|
3673
|
+
if (actionButton.dataset.companionAction === "stage") {
|
|
3674
|
+
const key = actionButton.dataset.wakeKey;
|
|
3675
|
+
const entry = buildEntries().find((candidate) => candidate.key === key);
|
|
3676
|
+
if (entry) {
|
|
3677
|
+
stageCompanionPrompt(entry);
|
|
3678
|
+
}
|
|
3679
|
+
return;
|
|
3680
|
+
}
|
|
3681
|
+
|
|
3682
|
+
try {
|
|
3683
|
+
await submitCompanionAction({
|
|
3684
|
+
action: actionButton.dataset.companionAction,
|
|
3685
|
+
advisorId: actionButton.dataset.advisorId,
|
|
3686
|
+
wakeKey: actionButton.dataset.wakeKey
|
|
3687
|
+
});
|
|
3688
|
+
} catch (error) {
|
|
3689
|
+
setComposerStatus(error.message, "error");
|
|
3690
|
+
render();
|
|
3691
|
+
}
|
|
3692
|
+
return;
|
|
3693
|
+
}
|
|
3694
|
+
|
|
3695
|
+
if (event.target.closest("a")) {
|
|
3696
|
+
return;
|
|
3697
|
+
}
|
|
3698
|
+
|
|
3699
|
+
const card = event.target.closest("[data-entry-key]");
|
|
3700
|
+
if (!card || card.dataset.expandable !== "true") {
|
|
3701
|
+
return;
|
|
3702
|
+
}
|
|
3703
|
+
|
|
3704
|
+
const key = card.dataset.entryKey;
|
|
3705
|
+
if (expandedEntryKeys.has(key)) {
|
|
3706
|
+
expandedEntryKeys.delete(key);
|
|
3707
|
+
} else {
|
|
3708
|
+
expandedEntryKeys.add(key);
|
|
3709
|
+
}
|
|
3710
|
+
render();
|
|
3711
|
+
});
|
|
3712
|
+
|
|
3713
|
+
nodes.feed.addEventListener("keydown", (event) => {
|
|
3714
|
+
markUserIntent();
|
|
3715
|
+
if (event.target.closest("[data-companion-action]")) {
|
|
3716
|
+
return;
|
|
3717
|
+
}
|
|
3718
|
+
|
|
3719
|
+
if (event.key !== "Enter" && event.key !== " ") {
|
|
3720
|
+
return;
|
|
3721
|
+
}
|
|
3722
|
+
|
|
3723
|
+
const card = event.target.closest("[data-entry-key]");
|
|
3724
|
+
if (!card || card.dataset.expandable !== "true") {
|
|
3725
|
+
return;
|
|
3726
|
+
}
|
|
3727
|
+
|
|
3728
|
+
event.preventDefault();
|
|
3729
|
+
card.click();
|
|
3730
|
+
});
|
|
3731
|
+
|
|
3732
|
+
nodes.refreshButton.addEventListener("click", async () => {
|
|
3733
|
+
markUserIntent();
|
|
3734
|
+
try {
|
|
3735
|
+
await refreshLiveState();
|
|
3736
|
+
} catch (error) {
|
|
3737
|
+
setComposerStatus(error.message, "error");
|
|
3738
|
+
render();
|
|
3739
|
+
}
|
|
3740
|
+
});
|
|
3741
|
+
|
|
3742
|
+
nodes.approveButton.addEventListener("click", async () => {
|
|
3743
|
+
markUserIntent();
|
|
3744
|
+
try {
|
|
3745
|
+
await submitInteraction({ action: "approve" });
|
|
3746
|
+
setComposerStatus("Approved.", "success");
|
|
3747
|
+
render();
|
|
3748
|
+
} catch (error) {
|
|
3749
|
+
setComposerStatus(error.message, "error");
|
|
3750
|
+
render();
|
|
3751
|
+
}
|
|
3752
|
+
});
|
|
3753
|
+
|
|
3754
|
+
nodes.approveSessionButton.addEventListener("click", async () => {
|
|
3755
|
+
markUserIntent();
|
|
3756
|
+
try {
|
|
3757
|
+
await submitInteraction({ action: "session" });
|
|
3758
|
+
setComposerStatus("Approved for session.", "success");
|
|
3759
|
+
render();
|
|
3760
|
+
} catch (error) {
|
|
3761
|
+
setComposerStatus(error.message, "error");
|
|
3762
|
+
render();
|
|
3763
|
+
}
|
|
3764
|
+
});
|
|
3765
|
+
|
|
3766
|
+
nodes.declineButton.addEventListener("click", async () => {
|
|
3767
|
+
markUserIntent();
|
|
3768
|
+
try {
|
|
3769
|
+
await submitInteraction({ action: "decline" });
|
|
3770
|
+
setComposerStatus("Declined.", "success");
|
|
3771
|
+
render();
|
|
3772
|
+
} catch (error) {
|
|
3773
|
+
setComposerStatus(error.message, "error");
|
|
3774
|
+
render();
|
|
3775
|
+
}
|
|
3776
|
+
});
|
|
3777
|
+
|
|
3778
|
+
nodes.actionForm.addEventListener("submit", async (event) => {
|
|
3779
|
+
event.preventDefault();
|
|
3780
|
+
markUserIntent();
|
|
3781
|
+
const pending = currentLiveState?.pendingInteraction;
|
|
3782
|
+
if (!pending) {
|
|
3783
|
+
return;
|
|
3784
|
+
}
|
|
3785
|
+
|
|
3786
|
+
try {
|
|
3787
|
+
await submitInteraction({
|
|
3788
|
+
action: "submit",
|
|
3789
|
+
answers: collectAnswers(pending.questions || [])
|
|
3790
|
+
});
|
|
3791
|
+
setComposerStatus("Input sent.", "success");
|
|
3792
|
+
render();
|
|
3793
|
+
} catch (error) {
|
|
3794
|
+
setComposerStatus(error.message, "error");
|
|
3795
|
+
render();
|
|
3796
|
+
}
|
|
3797
|
+
});
|
|
3798
|
+
|
|
3799
|
+
nodes.actionCancel.addEventListener("click", async () => {
|
|
3800
|
+
markUserIntent();
|
|
3801
|
+
try {
|
|
3802
|
+
await submitInteraction({ action: "cancel" });
|
|
3803
|
+
setComposerStatus("Cancelled.", "success");
|
|
3804
|
+
render();
|
|
3805
|
+
} catch (error) {
|
|
3806
|
+
setComposerStatus(error.message, "error");
|
|
3807
|
+
render();
|
|
3808
|
+
}
|
|
3809
|
+
});
|
|
3810
|
+
|
|
3811
|
+
nodes.composerForm.addEventListener("submit", async (event) => {
|
|
3812
|
+
event.preventDefault();
|
|
3813
|
+
markUserIntent();
|
|
3814
|
+
try {
|
|
3815
|
+
await submitLiveReply({
|
|
3816
|
+
rawText: nodes.replyText.value,
|
|
3817
|
+
attachments: pendingAttachments.slice()
|
|
3818
|
+
});
|
|
3819
|
+
} catch {
|
|
3820
|
+
// Status is already rendered by submitLiveReply.
|
|
3821
|
+
}
|
|
3822
|
+
});
|
|
3823
|
+
|
|
3824
|
+
nodes.queueReplyButton.addEventListener("click", () => {
|
|
3825
|
+
markUserIntent();
|
|
3826
|
+
if (!currentThreadId() || composeBlockedReason() || isSendingReply || uiState.selecting || uiState.controlling) {
|
|
3827
|
+
return;
|
|
3828
|
+
}
|
|
3829
|
+
|
|
3830
|
+
const rawText = nodes.replyText.value;
|
|
3831
|
+
const attachments = pendingAttachments.slice();
|
|
3832
|
+
if (!rawText.trim() && !attachments.length) {
|
|
3833
|
+
setComposerStatus("Write something before queueing.", "error");
|
|
3834
|
+
render();
|
|
3835
|
+
return;
|
|
3836
|
+
}
|
|
3837
|
+
|
|
3838
|
+
queueLiveReply({ attachments, rawText });
|
|
3839
|
+
});
|
|
3840
|
+
|
|
3841
|
+
nodes.clearQueueButton.addEventListener("click", () => {
|
|
3842
|
+
markUserIntent();
|
|
3843
|
+
const threadId = currentThreadId();
|
|
3844
|
+
if (!threadId || !queuedRepliesForThread(threadId).length) {
|
|
3845
|
+
return;
|
|
3846
|
+
}
|
|
3847
|
+
|
|
3848
|
+
clearQueuedReplies(threadId);
|
|
3849
|
+
setComposerStatus("Queue cleared.", "success");
|
|
3850
|
+
render();
|
|
3851
|
+
});
|
|
3852
|
+
|
|
3853
|
+
nodes.composerQueueList.addEventListener("click", (event) => {
|
|
3854
|
+
markUserIntent();
|
|
3855
|
+
const button = event.target.closest("[data-queued-reply-id]");
|
|
3856
|
+
if (!button) {
|
|
3857
|
+
return;
|
|
3858
|
+
}
|
|
3859
|
+
|
|
3860
|
+
const threadId = currentThreadId();
|
|
3861
|
+
const replyId = button.dataset.queuedReplyId || "";
|
|
3862
|
+
if (!threadId || !replyId) {
|
|
3863
|
+
return;
|
|
3864
|
+
}
|
|
3865
|
+
|
|
3866
|
+
removeQueuedReply(threadId, replyId);
|
|
3867
|
+
const remaining = queuedRepliesForThread(threadId).length;
|
|
3868
|
+
setComposerStatus(remaining ? `Removed from queue. ${remaining} left.` : "Removed from queue.", "success");
|
|
3869
|
+
render();
|
|
3870
|
+
});
|
|
3871
|
+
|
|
3872
|
+
window.setInterval(() => {
|
|
3873
|
+
if (document.visibilityState !== "visible") {
|
|
3874
|
+
return;
|
|
3875
|
+
}
|
|
3876
|
+
|
|
3877
|
+
if (!currentLiveState) {
|
|
3878
|
+
return;
|
|
3879
|
+
}
|
|
3880
|
+
|
|
3881
|
+
if (bridgeState.streamState !== "live") {
|
|
3882
|
+
return;
|
|
3883
|
+
}
|
|
3884
|
+
|
|
3885
|
+
if (Date.now() - lastLiveActivityAt <= FALLBACK_REFRESH_STALE_MS) {
|
|
3886
|
+
return;
|
|
3887
|
+
}
|
|
3888
|
+
|
|
3889
|
+
void refreshLiveState({ background: true });
|
|
3890
|
+
}, FALLBACK_REFRESH_INTERVAL_MS);
|
|
3891
|
+
|
|
3892
|
+
document.addEventListener("visibilitychange", () => {
|
|
3893
|
+
if (document.visibilityState === "hidden" && isDictating) {
|
|
3894
|
+
stopDictation();
|
|
3895
|
+
}
|
|
3896
|
+
});
|
|
3897
|
+
|
|
3898
|
+
window.setInterval(() => {
|
|
3899
|
+
if (!shouldRenewControlLease()) {
|
|
3900
|
+
return;
|
|
3901
|
+
}
|
|
3902
|
+
|
|
3903
|
+
void renewControlLeaseInBackground();
|
|
3904
|
+
}, CONTROL_RENEW_INTERVAL_MS);
|
|
3905
|
+
|
|
3906
|
+
document.addEventListener(
|
|
3907
|
+
"pointerdown",
|
|
3908
|
+
() => {
|
|
3909
|
+
markUserIntent();
|
|
3910
|
+
schedulePresenceSync(120);
|
|
3911
|
+
},
|
|
3912
|
+
{ passive: true }
|
|
3913
|
+
);
|
|
3914
|
+
|
|
3915
|
+
document.addEventListener("keydown", () => {
|
|
3916
|
+
markUserIntent();
|
|
3917
|
+
schedulePresenceSync(120);
|
|
3918
|
+
});
|
|
3919
|
+
|
|
3920
|
+
document.addEventListener("input", () => {
|
|
3921
|
+
markUserIntent();
|
|
3922
|
+
schedulePresenceSync(120);
|
|
3923
|
+
});
|
|
3924
|
+
|
|
3925
|
+
document.addEventListener("visibilitychange", () => {
|
|
3926
|
+
if (document.visibilityState === "hidden") {
|
|
3927
|
+
persistDraft();
|
|
3928
|
+
}
|
|
3929
|
+
schedulePresenceSync(40, { force: true });
|
|
3930
|
+
if (document.visibilityState === "visible") {
|
|
3931
|
+
bridgeLifecycle.resumeVisible();
|
|
3932
|
+
}
|
|
3933
|
+
});
|
|
3934
|
+
|
|
3935
|
+
window.addEventListener("focus", () => {
|
|
3936
|
+
schedulePresenceSync(40, { force: true });
|
|
3937
|
+
bridgeLifecycle.resumeVisible();
|
|
3938
|
+
});
|
|
3939
|
+
|
|
3940
|
+
window.addEventListener("blur", () => {
|
|
3941
|
+
schedulePresenceSync(40, { force: true });
|
|
3942
|
+
});
|
|
3943
|
+
|
|
3944
|
+
window.addEventListener(
|
|
3945
|
+
"scroll",
|
|
3946
|
+
() => {
|
|
3947
|
+
maybeLoadOlderTranscriptHistory();
|
|
3948
|
+
},
|
|
3949
|
+
{ passive: true }
|
|
3950
|
+
);
|
|
3951
|
+
|
|
3952
|
+
window.addEventListener("pagehide", () => {
|
|
3953
|
+
persistDraft();
|
|
3954
|
+
sendDetachPresence();
|
|
3955
|
+
closeStream();
|
|
3956
|
+
});
|
|
3957
|
+
|
|
3958
|
+
window.setInterval(() => {
|
|
3959
|
+
if (!currentLiveState || bridgeState.streamState !== "live") {
|
|
3960
|
+
return;
|
|
3961
|
+
}
|
|
3962
|
+
|
|
3963
|
+
void syncPresence();
|
|
3964
|
+
}, PRESENCE_HEARTBEAT_INTERVAL_MS);
|
|
3965
|
+
|
|
3966
|
+
ensureStream();
|
|
3967
|
+
void bootstrapLiveState();
|