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.
Files changed (76) hide show
  1. package/LICENSE +211 -0
  2. package/README.md +112 -0
  3. package/SECURITY.md +27 -0
  4. package/SUPPORT.md +43 -0
  5. package/package.json +44 -0
  6. package/public/client-shared.js +1831 -0
  7. package/public/favicon.svg +11 -0
  8. package/public/host.html +29 -0
  9. package/public/host.js +2079 -0
  10. package/public/index.html +28 -0
  11. package/public/index.js +98 -0
  12. package/public/live-bridge-lifecycle.js +258 -0
  13. package/public/live-bridge-retry-state.js +61 -0
  14. package/public/live-selection-intent.js +79 -0
  15. package/public/remote-operator-state.js +316 -0
  16. package/public/remote.html +167 -0
  17. package/public/remote.js +3967 -0
  18. package/public/styles.css +2793 -0
  19. package/public/surface-view-state.js +89 -0
  20. package/public/voice-dictation.js +45 -0
  21. package/src/bin/desktop-rehydration-smoke.mjs +111 -0
  22. package/src/bin/dextunnel.mjs +41 -0
  23. package/src/bin/doctor.mjs +48 -0
  24. package/src/bin/launch-attest.mjs +39 -0
  25. package/src/bin/launch-status.mjs +49 -0
  26. package/src/bin/mobile-link-proxy.mjs +221 -0
  27. package/src/bin/mobile-proof.mjs +164 -0
  28. package/src/bin/mobile-transport-smoke.mjs +200 -0
  29. package/src/bin/probe-codex-app-server-write.mjs +36 -0
  30. package/src/bin/probe-codex-app-server.mjs +30 -0
  31. package/src/lib/agent-room-context.mjs +54 -0
  32. package/src/lib/agent-room-runtime.mjs +355 -0
  33. package/src/lib/agent-room-service.mjs +335 -0
  34. package/src/lib/agent-room-state.mjs +406 -0
  35. package/src/lib/agent-room-store.mjs +71 -0
  36. package/src/lib/agent-room-text.mjs +48 -0
  37. package/src/lib/app-server-contract.mjs +66 -0
  38. package/src/lib/app-server-runtime.mjs +60 -0
  39. package/src/lib/attachment-service.mjs +119 -0
  40. package/src/lib/bridge-api-handler.mjs +719 -0
  41. package/src/lib/bridge-runtime-lifecycle.mjs +51 -0
  42. package/src/lib/bridge-status-builder.mjs +60 -0
  43. package/src/lib/codex-app-server-client.mjs +1511 -0
  44. package/src/lib/companion-state.mjs +453 -0
  45. package/src/lib/control-lease-service.mjs +180 -0
  46. package/src/lib/debug-harness-service.mjs +173 -0
  47. package/src/lib/desktop-integration.mjs +146 -0
  48. package/src/lib/desktop-rehydration-smoke.mjs +269 -0
  49. package/src/lib/dextunnel-cli.mjs +122 -0
  50. package/src/lib/discovery-docs.mjs +1321 -0
  51. package/src/lib/fake-codex-app-server-bridge.mjs +340 -0
  52. package/src/lib/install-preflight.mjs +373 -0
  53. package/src/lib/interaction-resolution-service.mjs +185 -0
  54. package/src/lib/interaction-state.mjs +360 -0
  55. package/src/lib/launch-release-bar.mjs +158 -0
  56. package/src/lib/live-control-state.mjs +107 -0
  57. package/src/lib/live-payload-builder.mjs +298 -0
  58. package/src/lib/live-selection-transition-state.mjs +49 -0
  59. package/src/lib/live-transcript-state.mjs +549 -0
  60. package/src/lib/mobile-network-profile.mjs +39 -0
  61. package/src/lib/mock-codex-adapter.mjs +62 -0
  62. package/src/lib/operator-diagnostics.mjs +82 -0
  63. package/src/lib/repo-changes-service.mjs +527 -0
  64. package/src/lib/runtime-config.mjs +106 -0
  65. package/src/lib/selection-state-service.mjs +214 -0
  66. package/src/lib/session-store.mjs +355 -0
  67. package/src/lib/shared-room-state.mjs +473 -0
  68. package/src/lib/shared-selection-state.mjs +40 -0
  69. package/src/lib/sse-hub.mjs +35 -0
  70. package/src/lib/static-surface-service.mjs +71 -0
  71. package/src/lib/surface-access.mjs +189 -0
  72. package/src/lib/surface-presence-service.mjs +118 -0
  73. package/src/lib/surface-request-guard.mjs +52 -0
  74. package/src/lib/thread-sync-state.mjs +536 -0
  75. package/src/lib/watcher-lifecycle.mjs +287 -0
  76. package/src/server.mjs +1446 -0
@@ -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();