@oar-sh/oar 0.9.9 → 0.9.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +247 -0
- package/README.md +78 -9
- package/bin/bat/oar.bat +46 -46
- package/package.json +2 -2
- package/server/README.md +286 -3
- package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
- package/server/claude-worker/claude-session-process.mjs +35 -1
- package/server/claude-worker/claude-turn-publisher.mjs +3 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
- package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
- package/server/db-schema.mjs +36 -0
- package/server/migrations/0009-subagent-timeline.mjs +135 -0
- package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
- package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
- package/server/public/app/agent-exchange-fold-view.mjs +248 -0
- package/server/public/app/api-client.js +61 -0
- package/server/public/app/archive-confirm.mjs +28 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/attachments-view.js +516 -6
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +186 -35
- package/server/public/app/chat-actions-menu-state.mjs +116 -0
- package/server/public/app/claude-cloud-conversation-ui.js +10 -22
- package/server/public/app/composer-control-state.mjs +16 -0
- package/server/public/app/composer-keyboard.mjs +54 -0
- package/server/public/app/conversation-view.js +361 -85
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/host-suspend-ui.js +6 -3
- package/server/public/app/journal-view.js +146 -26
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/relay-board-view.js +3 -0
- package/server/public/app/relay-event-subscriptions-ui.js +184 -0
- package/server/public/app/relay-event-view.mjs +234 -0
- package/server/public/app/settings-modal.js +53 -1
- package/server/public/app/socket-handlers.js +59 -28
- package/server/public/app/store.js +19 -2
- package/server/public/app/subagent-row.mjs +138 -0
- package/server/public/app/subagent-timeline-view.mjs +186 -0
- package/server/public/app/subagent-viewer-watch.mjs +50 -0
- package/server/public/app/subagent-viewer.mjs +534 -0
- package/server/public/app/task-row-format.mjs +29 -0
- package/server/public/app/thinking-follow.js +4 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
- package/server/public/app/transcript-jump-bottom.mjs +99 -0
- package/server/public/app/transcript-reload-freshness.mjs +25 -0
- package/server/public/app/update-ui.js +1 -1
- package/server/public/app/viewer-history.mjs +5 -3
- package/server/public/index.html +396 -5
- package/server/repositories/message-repository.mjs +29 -3
- package/server/repositories/question-repository.mjs +9 -3
- package/server/repositories/remote-relay-repository.mjs +6 -2
- package/server/repositories/subagent-timeline-statements.mjs +170 -0
- package/server/routes/ask-user-routes.mjs +3 -0
- package/server/routes/messages-routes.mjs +515 -50
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +72 -0
- package/server/server-runtime.mjs +245 -62
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -0
- package/server/services/preview-proxy-server.mjs +41 -23
- package/server/services/push-dispatch-service.mjs +18 -0
- package/server/services/relay-event-link-service.mjs +289 -0
- package/server/services/relay-event-service.mjs +1935 -0
- package/server/services/relay-shutdown-gate.mjs +31 -0
- package/server/services/remote-relay-caller-context.mjs +52 -7
- package/server/services/remote-relay-dispatcher.mjs +494 -2
- package/server/services/remote-relay-registry-service.mjs +11 -1
- package/server/services/session-activity-tracker.mjs +487 -0
- package/server/services/session-history-refresh-service.mjs +7 -0
- package/server/services/stale-attempt-log-limiter.mjs +96 -0
- package/server/services/stale-recovery-clock-guard.mjs +57 -0
- package/server/services/stale-subagent-run-sweep.mjs +109 -0
- package/server/services/subagent-record-retention.mjs +164 -0
- package/server/services/subagent-timeline-service.mjs +328 -0
- package/server/services/subagent-viewer-rooms.mjs +119 -0
- package/server/services/upgrade-socket-guard.mjs +16 -0
- package/shared/relay-events.mjs +371 -0
- package/shared/remote-relay-contract.mjs +157 -5
- package/shared/subagent-record-retention.mjs +31 -0
- package/shared/subagent-timeline.mjs +79 -0
|
@@ -5,8 +5,12 @@
|
|
|
5
5
|
// `backgroundTasks` on reload; hidden whenever the set is empty.
|
|
6
6
|
|
|
7
7
|
import { apiFetch } from './api-client.js';
|
|
8
|
-
import {
|
|
8
|
+
import { formatTaskModelLabel, formatTaskTokens } from './task-row-format.mjs';
|
|
9
9
|
import { prefixToolActivityEmoji } from './tool-activity-emoji.mjs';
|
|
10
|
+
|
|
11
|
+
// Shared with the sub-agent rows (subagent-row.mjs); re-exported for the
|
|
12
|
+
// transcript's finished-workflow card and the existing importers.
|
|
13
|
+
export { formatTaskModelLabel, formatTaskTokens };
|
|
10
14
|
import {
|
|
11
15
|
getPreviewsForConversation,
|
|
12
16
|
renderPreviewRowsInto,
|
|
@@ -71,16 +75,6 @@ function taskKindLabel(task) {
|
|
|
71
75
|
return label === 'Subagent' && subagentType ? subagentType : label;
|
|
72
76
|
}
|
|
73
77
|
|
|
74
|
-
// "claude-opus-5-5" → "Opus 5.5". A bare alias ("opus") is what a spawn pin
|
|
75
|
-
// carries until the subagent's first answer names the real model.
|
|
76
|
-
export function formatTaskModelLabel(model) {
|
|
77
|
-
const value = String(model || '').trim();
|
|
78
|
-
if (!value) return '';
|
|
79
|
-
if (/^claude-/i.test(value)) return humanizeModelLabel(value);
|
|
80
|
-
if (/^[a-z]+(\[[^\]]+\])?$/i.test(value)) return value.charAt(0).toUpperCase() + value.slice(1);
|
|
81
|
-
return humanizeModelLabel(value);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
78
|
// Detail line: the live tool call when the worker saw one ("🪓 Tool
|
|
85
79
|
// (PowerShell): cd …", cropped to the row by CSS), else the SDK's progress
|
|
86
80
|
// summary, else the bare tool name.
|
|
@@ -93,20 +87,6 @@ export function taskActivityText(task) {
|
|
|
93
87
|
return { text: lastToolName ? `using ${lastToolName}` : '', toolCall: false };
|
|
94
88
|
}
|
|
95
89
|
|
|
96
|
-
// Compact token counts: "812 tok", "62.2k tok", "1.2M tok". Exported for the
|
|
97
|
-
// transcript's finished-workflow card, which shares the panel's formatting.
|
|
98
|
-
export function formatTaskTokens(totalTokens) {
|
|
99
|
-
const tokens = Number(totalTokens);
|
|
100
|
-
if (!Number.isFinite(tokens) || tokens <= 0) return '';
|
|
101
|
-
if (tokens < 1000) return `${tokens} tok`;
|
|
102
|
-
if (tokens < 1_000_000) {
|
|
103
|
-
const thousands = tokens / 1000;
|
|
104
|
-
return `${thousands >= 100 ? Math.round(thousands) : thousands.toFixed(1)}k tok`;
|
|
105
|
-
}
|
|
106
|
-
const millions = tokens / 1_000_000;
|
|
107
|
-
return `${millions >= 100 ? Math.round(millions) : millions.toFixed(1)}M tok`;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
90
|
function escHtml(value) {
|
|
111
91
|
return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
|
|
112
92
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
@@ -377,6 +357,16 @@ async function stopBackgroundTask(conversationId, taskId) {
|
|
|
377
357
|
}
|
|
378
358
|
}
|
|
379
359
|
|
|
360
|
+
// A sub-agent task opens its record in the viewer: the task's spawning
|
|
361
|
+
// tool_use id is the run's id. Bash tasks, monitors and workflows have no
|
|
362
|
+
// record, and Copilot's tasks carry no run id.
|
|
363
|
+
export function subagentRunIdForTask(task) {
|
|
364
|
+
const runId = String(task?.toolUseId || '').trim();
|
|
365
|
+
if (!runId) return '';
|
|
366
|
+
const type = String(task?.taskType || '').trim();
|
|
367
|
+
return type === 'local_agent' || type === 'agent' || type === 'subagent' ? runId : '';
|
|
368
|
+
}
|
|
369
|
+
|
|
380
370
|
function renderTaskRow(conversationId, task) {
|
|
381
371
|
const key = `${conversationId}:${task.taskId}`;
|
|
382
372
|
const stopping = stopsInFlight.has(key);
|
|
@@ -403,7 +393,9 @@ function renderTaskRow(conversationId, task) {
|
|
|
403
393
|
<div class="bg-task-row" data-task-id="${escHtml(task.taskId)}">
|
|
404
394
|
${foldButton}<span class="bg-task-icon" title="${escHtml(task.taskType || 'task')}">${taskIcon(task.taskType)}</span>
|
|
405
395
|
<span class="bg-task-main">
|
|
406
|
-
|
|
396
|
+
${subagentRunIdForTask(task)
|
|
397
|
+
? `<button type="button" class="subagent-open bg-task-desc" data-subagent-open="${escHtml(subagentRunIdForTask(task))}" title="Open this sub-agent's thoughts, tool calls and reply">${escHtml(task.description || task.taskId)}</button>`
|
|
398
|
+
: `<span class="bg-task-desc">${escHtml(task.description || task.taskId)}</span>`}
|
|
407
399
|
${activity.text ? `<span class="bg-task-detail${activity.toolCall ? ' bg-task-toolcall' : ''}" title="${escHtml(activity.text)}">${escHtml(activity.text)}</span>` : ''}
|
|
408
400
|
</span>
|
|
409
401
|
<span class="bg-task-side">
|
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
hasPendingUserMessageForConversation,
|
|
54
54
|
} from './store.js';
|
|
55
55
|
import { createTranscriptResizeKeeper } from './transcript-viewport-anchor.mjs';
|
|
56
|
+
import { initTranscriptJumpToBottom } from './transcript-jump-bottom-view.mjs';
|
|
56
57
|
import {
|
|
57
58
|
verifyExistingSession,
|
|
58
59
|
verifyToken,
|
|
@@ -103,6 +104,7 @@ import {
|
|
|
103
104
|
renderMessages,
|
|
104
105
|
appendMessage,
|
|
105
106
|
compactCurrentConversation,
|
|
107
|
+
renderNoConversationState,
|
|
106
108
|
sendMessage,
|
|
107
109
|
handleKey,
|
|
108
110
|
updateComposerSlashMenu,
|
|
@@ -126,11 +128,14 @@ import {
|
|
|
126
128
|
syncRenderedPinState,
|
|
127
129
|
isConversationWindowAtTail,
|
|
128
130
|
getTranscriptJumpEpoch,
|
|
131
|
+
getTranscriptLiveRowEpoch,
|
|
132
|
+
syncAgentExchangeFoldsNow,
|
|
129
133
|
} from './conversation-view.js';
|
|
134
|
+
import { loadKeepingLiveRows } from './transcript-reload-freshness.mjs';
|
|
130
135
|
import { initPinnedMessagesView, setPinsConversation } from './pinned-messages-view.mjs';
|
|
131
136
|
import { bindChatSelectionGuard, chatSelectionGuard, isChatInteractionHeld } from './selection-guard.mjs';
|
|
132
137
|
import { loadRepoBrowserTree, openRepoBrowser, closeRepoBrowser, setRepoBrowserSessionInfo, resetWorkspaceRepoBrowserForRootChange } from './attachments-view.js';
|
|
133
|
-
import { handleAttachmentInput, retryAttachmentUpload, openAnnotateEditorForPending, openAnnotateEditorForPreview, handleComposerPaste, handleComposerDrop, refreshComposerAttachmentWarning, removeAttachment, clearAttachments, openUploadedAttachmentViewer, setFilePreviewMode, toggleFilePreviewHtml, copyFilePreviewImage, closeFilePreview, goBackFilePreview, openWorkspaceFilePreview, openWorkspaceFilePreviewFromRepo, setRepoBrowserRoot, setRepoBrowserViewMode, toggleRepoBrowserHidden, toggleRepoBrowserHeavy, refreshRepoBrowser, focusRepoTree, setRepoCurrentPath, confirmRepoBrowserCwdPick } from './attachments-view.js';
|
|
138
|
+
import { handleAttachmentInput, retryAttachmentUpload, openAnnotateEditorForPending, openAnnotateEditorForPreview, handleComposerPaste, handleComposerDrop, refreshComposerAttachmentWarning, removeAttachment, clearAttachments, openUploadedAttachmentViewer, setFilePreviewMode, toggleFilePreviewHtml, copyFilePreviewImage, closeFilePreview, goBackFilePreview, openWorkspaceFilePreview, openWorkspaceFilePreviewFromRepo, setRepoBrowserRoot, setRepoBrowserViewMode, toggleRepoBrowserHidden, toggleRepoBrowserHeavy, refreshRepoBrowser, focusRepoTree, setRepoCurrentPath, confirmRepoBrowserCwdPick, startRepoNewFolder } from './attachments-view.js';
|
|
134
139
|
import { initEmojiPicker, toggleEmojiPicker } from './emoji-view.js';
|
|
135
140
|
import { initSlashAutocomplete } from './slash-autocomplete.mjs';
|
|
136
141
|
import { initMentionAutocomplete } from './mention-autocomplete.mjs';
|
|
@@ -173,6 +178,7 @@ import {
|
|
|
173
178
|
openMessageSearchModal,
|
|
174
179
|
closeMessageSearchModal,
|
|
175
180
|
} from './message-search-view.js';
|
|
181
|
+
import { initSubagentViewer } from './subagent-viewer.mjs';
|
|
176
182
|
import {
|
|
177
183
|
initGitChangesView,
|
|
178
184
|
openGitChangesModal,
|
|
@@ -274,6 +280,7 @@ import {
|
|
|
274
280
|
updateTheme,
|
|
275
281
|
openSettingsModal,
|
|
276
282
|
closeSettingsModal,
|
|
283
|
+
isSuspendHostActionVisible,
|
|
277
284
|
syncSuspendHostVisibility,
|
|
278
285
|
updateShowSuspendHostSetting,
|
|
279
286
|
syncDefaultSessionWorkspaceRootInput,
|
|
@@ -309,6 +316,7 @@ import {
|
|
|
309
316
|
updateTurnCeilingSetting,
|
|
310
317
|
previewBackgroundTaskTimeoutSetting,
|
|
311
318
|
updateBackgroundTaskTimeoutSetting,
|
|
319
|
+
updateSubagentRecordRetentionSetting,
|
|
312
320
|
} from './settings-modal.js';
|
|
313
321
|
import {
|
|
314
322
|
togglePushOnThisDevice,
|
|
@@ -356,14 +364,21 @@ import {
|
|
|
356
364
|
subscribeClaudeCloudSettings,
|
|
357
365
|
toggleClaudeCloudProvider,
|
|
358
366
|
} from './claude-cloud-settings-ui.js';
|
|
359
|
-
import { openCurrentCloudSession, syncClaudeCloudConversationUi } from './claude-cloud-conversation-ui.js';
|
|
367
|
+
import { isCurrentConversationClaudeCloud, openCurrentCloudSession, syncClaudeCloudConversationUi } from './claude-cloud-conversation-ui.js';
|
|
368
|
+
import {
|
|
369
|
+
CHAT_ACTIONS_MENU_ENTRY_IDS,
|
|
370
|
+
CHAT_ACTIONS_MENU_META_IDS,
|
|
371
|
+
resolveChatActionsMenuState,
|
|
372
|
+
} from './chat-actions-menu-state.mjs';
|
|
373
|
+
import { subscribeProviderReadiness, updateProviderReadinessInputs } from './provider-readiness.mjs';
|
|
360
374
|
import { syncConversationOriginHeader } from './conversation-origin-ui.js';
|
|
375
|
+
import { loadRelayEventSubscriptions, onRelayEventSubscriptionsChanged, syncRelayEventHeader } from './relay-event-subscriptions-ui.js';
|
|
361
376
|
import {
|
|
362
377
|
previewAgentSessionsMaxWait,
|
|
363
378
|
saveAgentSessionsMaxWait,
|
|
364
379
|
toggleAgentSessions,
|
|
365
380
|
} from './agent-sessions-settings-ui.js';
|
|
366
|
-
import { CLAUDE_CLOUD_PROVIDER, claudeCloudComposerModelIds } from './claude-cloud-ui.mjs';
|
|
381
|
+
import { CLAUDE_CLOUD_PROVIDER, buildCloudLineModel, claudeCloudComposerModelIds } from './claude-cloud-ui.mjs';
|
|
367
382
|
import {
|
|
368
383
|
confirmCliInstall,
|
|
369
384
|
runCliInstall,
|
|
@@ -377,6 +392,7 @@ import {
|
|
|
377
392
|
dismissAvailableUpdate,
|
|
378
393
|
dismissUpdateOutcome,
|
|
379
394
|
} from './update-ui.js';
|
|
395
|
+
import { shouldAutofocusComposerOnLoad } from './composer-keyboard.mjs';
|
|
380
396
|
|
|
381
397
|
const MODEL_STORAGE_KEY = 'copilot_selected_model';
|
|
382
398
|
// The New Chat modal used to keep its own model key, so a selection made there
|
|
@@ -953,6 +969,10 @@ function applySharedReaderUi() {
|
|
|
953
969
|
if (modelSelect) modelSelect.hidden = true;
|
|
954
970
|
if (effortSelect) effortSelect.hidden = true;
|
|
955
971
|
if (modeSelect) modeSelect.hidden = true;
|
|
972
|
+
// One source for the menu: the resolver's shared branch (Day/Night only,
|
|
973
|
+
// trigger usable). The loop below stays as a guard for entries the resolver
|
|
974
|
+
// does not know.
|
|
975
|
+
applyChatActionsMenuState(resolveChatActionsMenuState({ sharedMode: true }));
|
|
956
976
|
if (chatMenu && sharedThemeBtn) {
|
|
957
977
|
for (const child of Array.from(chatMenu.children)) {
|
|
958
978
|
if (child !== sharedThemeBtn) hideSharedMenuEntry(child);
|
|
@@ -1006,6 +1026,15 @@ function resolveStableScrollTopForLiveRefresh(messagesEl, capturedScrollTop) {
|
|
|
1006
1026
|
return captured;
|
|
1007
1027
|
}
|
|
1008
1028
|
|
|
1029
|
+
// Whether a reload keeps the reader at the end: as it was when the reload was
|
|
1030
|
+
// asked for, unless they have moved while it was on its way (scrolled up, or
|
|
1031
|
+
// jumped back to the end); then as it is now.
|
|
1032
|
+
function resolveLiveRefreshAtBottom(messagesEl, capturedScrollTop, capturedAtBottom) {
|
|
1033
|
+
const current = Number(messagesEl?.scrollTop);
|
|
1034
|
+
if (!Number.isFinite(current) || Math.abs(current - Number(capturedScrollTop)) <= 2) return capturedAtBottom;
|
|
1035
|
+
return isMessagesAtBottom();
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1009
1038
|
async function refreshSharedConversation() {
|
|
1010
1039
|
if (sharedConversationPollInFlight) return;
|
|
1011
1040
|
const sharedToken = resolveSharedTokenFromLocation();
|
|
@@ -1013,11 +1042,17 @@ async function refreshSharedConversation() {
|
|
|
1013
1042
|
sharedConversationPollInFlight = true;
|
|
1014
1043
|
const requestSeq = ++sharedConversationRequestSeq;
|
|
1015
1044
|
const messagesEl = document.getElementById('messages');
|
|
1016
|
-
const
|
|
1045
|
+
const wasAtBottom = isMessagesAtBottom();
|
|
1017
1046
|
const savedScrollTop = messagesEl?.scrollTop || 0;
|
|
1018
1047
|
try {
|
|
1019
|
-
|
|
1048
|
+
// The reader has no socket today, so nothing moves the count; the guard
|
|
1049
|
+
// keeps a rebuild from dropping a live row should one ever arrive.
|
|
1050
|
+
const { response, stale } = await loadKeepingLiveRows(
|
|
1051
|
+
() => loadSharedConversation(sharedToken, { limit: 120 }),
|
|
1052
|
+
getTranscriptLiveRowEpoch,
|
|
1053
|
+
);
|
|
1020
1054
|
if (requestSeq < sharedConversationAppliedSeq) return;
|
|
1055
|
+
if (stale) return;
|
|
1021
1056
|
if (!response?.ok) {
|
|
1022
1057
|
const message = String(response?.error || 'Could not load shared conversation.').trim();
|
|
1023
1058
|
if (message && message !== sharedConversationLastError) {
|
|
@@ -1050,6 +1085,7 @@ async function refreshSharedConversation() {
|
|
|
1050
1085
|
return;
|
|
1051
1086
|
}
|
|
1052
1087
|
sharedConversationRenderKey = renderKey;
|
|
1088
|
+
const preserveBottom = resolveLiveRefreshAtBottom(messagesEl, savedScrollTop, wasAtBottom);
|
|
1053
1089
|
const stableSavedScrollTop = resolveStableScrollTopForLiveRefresh(messagesEl, savedScrollTop);
|
|
1054
1090
|
applyLoadedConversationState(convId, response, {
|
|
1055
1091
|
restoreScroll: !preserveBottom,
|
|
@@ -1382,6 +1418,8 @@ function syncComposerPlaceholder() {
|
|
|
1382
1418
|
if (!input) return;
|
|
1383
1419
|
if (isSharedReaderMode()) return;
|
|
1384
1420
|
if (imageEditTarget) return;
|
|
1421
|
+
// The composer is locked until a provider is ready; its text says why.
|
|
1422
|
+
if (input.dataset.setupLocked === '1') return;
|
|
1385
1423
|
const rawValue = String(document.getElementById('model-select')?.value || '');
|
|
1386
1424
|
const { baseModelId } = splitModelVariantId(rawValue);
|
|
1387
1425
|
input.placeholder = composerPlaceholderFor({
|
|
@@ -1616,6 +1654,8 @@ function updateModelCatalogState(payload) {
|
|
|
1616
1654
|
// Shared readers never load the catalog, so this would only surface
|
|
1617
1655
|
// "metadata unavailable" warnings for a picker they cannot see.
|
|
1618
1656
|
if (isSharedReaderMode()) return;
|
|
1657
|
+
// The catalog's `source` is the Copilot readiness signal (provider-readiness.mjs).
|
|
1658
|
+
updateProviderReadinessInputs({ catalog: payload && typeof payload === 'object' ? payload : null });
|
|
1619
1659
|
const select = document.getElementById('model-select');
|
|
1620
1660
|
if (!select) return;
|
|
1621
1661
|
const models = Array.isArray(payload?.models)
|
|
@@ -2180,6 +2220,8 @@ function initModeSelector() {
|
|
|
2180
2220
|
function refreshModelCatalog(force = false) {
|
|
2181
2221
|
return loadModelCatalog().then((r) => {
|
|
2182
2222
|
if (!r) {
|
|
2223
|
+
// A failed load leaves provider readiness unknown: no setup wall on a blip.
|
|
2224
|
+
updateProviderReadinessInputs({ catalog: null });
|
|
2183
2225
|
if (isModelMetadataHealthy(modelCatalogState) || isModelMetadataHealthy(lastHealthyModelCatalogState)) {
|
|
2184
2226
|
setModelBanner('⚠️ Could not refresh model metadata; using last known good catalog.');
|
|
2185
2227
|
syncModelMetadataBlocker();
|
|
@@ -3630,18 +3672,25 @@ async function pollAuthenticatedCurrentConversationLive() {
|
|
|
3630
3672
|
liveConversationPollInFlight = true;
|
|
3631
3673
|
try {
|
|
3632
3674
|
const messagesEl = document.getElementById('messages');
|
|
3633
|
-
const
|
|
3675
|
+
const wasAtBottom = isMessagesAtBottom();
|
|
3634
3676
|
const savedScrollTop = messagesEl?.scrollTop || 0;
|
|
3635
3677
|
const savedLoadedCount = loadConversationLoadedMessageCount(currentId);
|
|
3636
3678
|
const requestLimit = Math.max(20, getConversationLoadedMessageCount() || 0, savedLoadedCount || 0);
|
|
3637
3679
|
const jumpEpoch = getTranscriptJumpEpoch();
|
|
3638
|
-
const response = await
|
|
3680
|
+
const { response, stale } = await loadKeepingLiveRows(
|
|
3681
|
+
() => loadConversation(currentId, { limit: requestLimit }),
|
|
3682
|
+
getTranscriptLiveRowEpoch,
|
|
3683
|
+
);
|
|
3639
3684
|
if (!response) return;
|
|
3640
3685
|
if (String(currentConvId || '').trim() !== currentId) return;
|
|
3641
3686
|
// The user jumped into the history while this request was on its way: the
|
|
3642
3687
|
// answer is the end of the conversation as it was asked for before that,
|
|
3643
3688
|
// and would overwrite the window they jumped to. The next round asks again.
|
|
3644
3689
|
if (getTranscriptJumpEpoch() !== jumpEpoch) return;
|
|
3690
|
+
// Rows kept arriving through every request: the page has them, the answer
|
|
3691
|
+
// does not. The next round asks again.
|
|
3692
|
+
if (stale) return;
|
|
3693
|
+
const preserveBottom = resolveLiveRefreshAtBottom(messagesEl, savedScrollTop, wasAtBottom);
|
|
3645
3694
|
const stableSavedScrollTop = resolveStableScrollTopForLiveRefresh(messagesEl, savedScrollTop);
|
|
3646
3695
|
applyLoadedConversationState(currentId, response, {
|
|
3647
3696
|
restoreScroll: !preserveBottom,
|
|
@@ -3793,6 +3842,19 @@ function initMessageScrollPersistence() {
|
|
|
3793
3842
|
}, { passive: true });
|
|
3794
3843
|
}
|
|
3795
3844
|
|
|
3845
|
+
// The jump-to-bottom button on a window in the middle of the history: the
|
|
3846
|
+
// end of the conversation replaces it, scrolled to the bottom.
|
|
3847
|
+
async function loadCurrentConversationEnd() {
|
|
3848
|
+
const currentId = String(currentConvId || '').trim();
|
|
3849
|
+
if (!currentId) return;
|
|
3850
|
+
const jumpEpoch = getTranscriptJumpEpoch();
|
|
3851
|
+
const response = await loadConversation(currentId, { limit: 20 });
|
|
3852
|
+
if (!response || String(currentConvId || '').trim() !== currentId) return;
|
|
3853
|
+
// A jump into the history while it was on its way wins.
|
|
3854
|
+
if (getTranscriptJumpEpoch() !== jumpEpoch) return;
|
|
3855
|
+
applyLoadedConversationState(currentId, response, { restoreScroll: false });
|
|
3856
|
+
}
|
|
3857
|
+
|
|
3796
3858
|
async function runConversationHistoryRefresh({ source = 'menu' } = {}) {
|
|
3797
3859
|
const currentId = String(currentConvId || '').trim();
|
|
3798
3860
|
if (!currentId) {
|
|
@@ -3851,9 +3913,8 @@ async function refreshCurrentView() {
|
|
|
3851
3913
|
const capturedVersion = ++refreshViewVersion;
|
|
3852
3914
|
const jumpEpoch = getTranscriptJumpEpoch();
|
|
3853
3915
|
const messagesEl = document.getElementById('messages');
|
|
3854
|
-
const
|
|
3916
|
+
const wasAtBottom = isMessagesAtBottom();
|
|
3855
3917
|
const scrollTop = messagesEl?.scrollTop || 0;
|
|
3856
|
-
const stableSavedScrollTop = resolveStableScrollTopForLiveRefresh(messagesEl, scrollTop);
|
|
3857
3918
|
|
|
3858
3919
|
await refreshConversations();
|
|
3859
3920
|
|
|
@@ -3869,13 +3930,19 @@ async function refreshCurrentView() {
|
|
|
3869
3930
|
|
|
3870
3931
|
const savedLoadedCount = loadConversationLoadedMessageCount(currentId);
|
|
3871
3932
|
const requestLimit = Math.max(20, getConversationLoadedMessageCount() || 0, savedLoadedCount || 0);
|
|
3872
|
-
const r = await
|
|
3933
|
+
const { response: r, stale } = await loadKeepingLiveRows(
|
|
3934
|
+
() => loadConversation(currentId, { limit: requestLimit }),
|
|
3935
|
+
getTranscriptLiveRowEpoch,
|
|
3936
|
+
);
|
|
3873
3937
|
if (capturedVersion < refreshViewVersion) return;
|
|
3874
3938
|
if (String(currentConvId || '').trim() !== currentId) return;
|
|
3875
3939
|
// The user jumped into the history, before this refresh or while it was on
|
|
3876
3940
|
// its way: title, tasks, pins and the rest are refreshed, the transcript
|
|
3877
|
-
// and its scroll position are left as they are.
|
|
3878
|
-
|
|
3941
|
+
// and its scroll position are left as they are. So too when rows kept
|
|
3942
|
+
// arriving through every request: the page has rows the answer lacks.
|
|
3943
|
+
const keepTranscript = getTranscriptJumpEpoch() !== jumpEpoch || transcriptIsInHistory(currentId) || stale;
|
|
3944
|
+
const preserveBottom = resolveLiveRefreshAtBottom(messagesEl, scrollTop, wasAtBottom);
|
|
3945
|
+
const stableSavedScrollTop = resolveStableScrollTopForLiveRefresh(messagesEl, scrollTop);
|
|
3879
3946
|
if (r) {
|
|
3880
3947
|
applyLoadedConversationState(currentId, r, {
|
|
3881
3948
|
restoreScroll: !preserveBottom,
|
|
@@ -3951,7 +4018,7 @@ function getChatTitleElements() {
|
|
|
3951
4018
|
return {
|
|
3952
4019
|
wrap: document.getElementById('chat-title-wrap'),
|
|
3953
4020
|
title: document.getElementById('chat-title'),
|
|
3954
|
-
|
|
4021
|
+
menuBtn: document.getElementById('chat-actions-menu-btn'),
|
|
3955
4022
|
editor: document.getElementById('chat-title-editor'),
|
|
3956
4023
|
input: document.getElementById('chat-title-input'),
|
|
3957
4024
|
saveBtn: document.getElementById('chat-title-save-btn'),
|
|
@@ -3968,10 +4035,61 @@ function closeChatActionsMenu() {
|
|
|
3968
4035
|
if (backdrop && !window.__chatActionsMenuShieldTimer) backdrop.classList.remove('visible');
|
|
3969
4036
|
}
|
|
3970
4037
|
|
|
4038
|
+
// Writes one resolver decision to the DOM, the way the shared view hides its
|
|
4039
|
+
// entries: `hidden` alone for the text chips, plus `disabled`, `aria-hidden`
|
|
4040
|
+
// and the tab order for the buttons.
|
|
4041
|
+
function applyChatActionsMenuState(state) {
|
|
4042
|
+
const trigger = document.getElementById('chat-actions-menu-btn');
|
|
4043
|
+
if (trigger && state?.trigger) {
|
|
4044
|
+
trigger.hidden = !!state.trigger.hidden;
|
|
4045
|
+
trigger.disabled = !!state.trigger.disabled;
|
|
4046
|
+
}
|
|
4047
|
+
const entries = state?.entries || {};
|
|
4048
|
+
for (const id of CHAT_ACTIONS_MENU_ENTRY_IDS) {
|
|
4049
|
+
const element = document.getElementById(id);
|
|
4050
|
+
const decision = entries[id];
|
|
4051
|
+
if (!element || !decision) continue;
|
|
4052
|
+
element.hidden = !!decision.hidden;
|
|
4053
|
+
if (CHAT_ACTIONS_MENU_META_IDS.includes(id)) continue;
|
|
4054
|
+
if (decision.hidden) {
|
|
4055
|
+
element.setAttribute('aria-hidden', 'true');
|
|
4056
|
+
if ('disabled' in element) element.disabled = true;
|
|
4057
|
+
if ('tabIndex' in element) element.tabIndex = -1;
|
|
4058
|
+
continue;
|
|
4059
|
+
}
|
|
4060
|
+
element.removeAttribute('aria-hidden');
|
|
4061
|
+
if ('disabled' in element) element.disabled = !!decision.disabled;
|
|
4062
|
+
if ('tabIndex' in element) element.tabIndex = 0;
|
|
4063
|
+
}
|
|
4064
|
+
}
|
|
4065
|
+
|
|
4066
|
+
// The ⋯ menu as it should look right now: the trigger and every entry,
|
|
4067
|
+
// decided in chat-actions-menu-state.mjs from the conversation on screen.
|
|
4068
|
+
function syncChatActionsMenuState() {
|
|
4069
|
+
const convId = String(currentConvId || '').trim();
|
|
4070
|
+
const conversation = convId ? (conversations[convId] || null) : null;
|
|
4071
|
+
const isCloud = !!convId && isCurrentConversationClaudeCloud();
|
|
4072
|
+
const state = resolveChatActionsMenuState({
|
|
4073
|
+
sharedMode: isSharedReaderMode(),
|
|
4074
|
+
conversationId: convId,
|
|
4075
|
+
conversation,
|
|
4076
|
+
sdkSessionId: String(conversation?.sdkSessionId || '').trim(),
|
|
4077
|
+
isCloud,
|
|
4078
|
+
cloudSessionUrl: isCloud ? buildCloudLineModel(conversation?.cloud)?.sessionUrl || null : null,
|
|
4079
|
+
editing: !!convId && chatTitleEditingConversationId === convId,
|
|
4080
|
+
suspendVisible: isSuspendHostActionVisible(),
|
|
4081
|
+
});
|
|
4082
|
+
applyChatActionsMenuState(state);
|
|
4083
|
+
return state;
|
|
4084
|
+
}
|
|
4085
|
+
|
|
3971
4086
|
function toggleChatActionsMenu() {
|
|
3972
4087
|
const menu = document.getElementById('chat-actions-menu');
|
|
3973
4088
|
const trigger = document.getElementById('chat-actions-menu-btn');
|
|
3974
|
-
if (!menu || !trigger
|
|
4089
|
+
if (!menu || !trigger) return;
|
|
4090
|
+
// The entries match the moment of the tap, as Refresh history already does.
|
|
4091
|
+
syncChatActionsMenuState();
|
|
4092
|
+
if (trigger.hidden || trigger.disabled) return;
|
|
3975
4093
|
syncRefreshHistoryMenuState();
|
|
3976
4094
|
syncThemeMenuLabel();
|
|
3977
4095
|
const willOpen = !!menu.hidden;
|
|
@@ -4050,12 +4168,10 @@ function lockChatActionsMenuShield(ms = 300) {
|
|
|
4050
4168
|
|
|
4051
4169
|
|
|
4052
4170
|
function syncChatTitleControls() {
|
|
4053
|
-
const { title,
|
|
4171
|
+
const { title, editor, input } = getChatTitleElements();
|
|
4054
4172
|
const convId = String(currentConvId || '').trim();
|
|
4055
4173
|
// No conversation open (deleted, new chat): its 📍 button goes with it.
|
|
4056
4174
|
if (!convId) setPinsConversation(null);
|
|
4057
|
-
const killBtn = document.getElementById('chat-menu-kill-session');
|
|
4058
|
-
const sharedMode = isSharedReaderMode();
|
|
4059
4175
|
const conversation = convId ? (conversations[convId] || null) : null;
|
|
4060
4176
|
const sdkSessionId = String(conversation?.sdkSessionId || '').trim();
|
|
4061
4177
|
if (title) {
|
|
@@ -4079,17 +4195,11 @@ function syncChatTitleControls() {
|
|
|
4079
4195
|
const editing = convId && chatTitleEditingConversationId === convId;
|
|
4080
4196
|
document.body.classList.toggle('chat-title-editing', !!editing);
|
|
4081
4197
|
if (title) title.hidden = editing;
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
}
|
|
4086
|
-
if (killBtn) {
|
|
4087
|
-
killBtn.disabled = sharedMode || !convId || !sdkSessionId;
|
|
4088
|
-
killBtn.hidden = sharedMode || !convId;
|
|
4089
|
-
}
|
|
4198
|
+
// The ⋯ trigger and its entries: relay-wide ones stay with no conversation
|
|
4199
|
+
// open, so Settings is always one tap away.
|
|
4200
|
+
syncChatActionsMenuState();
|
|
4090
4201
|
const archiveBtn = document.getElementById('chat-menu-archive');
|
|
4091
4202
|
if (archiveBtn) {
|
|
4092
|
-
archiveBtn.hidden = sharedMode || !convId;
|
|
4093
4203
|
archiveBtn.textContent = conversation?.archived === true ? '📂 Unarchive conversation' : '🗄 Archive conversation';
|
|
4094
4204
|
}
|
|
4095
4205
|
if (editor) {
|
|
@@ -4112,19 +4222,21 @@ function syncChatTitleControls() {
|
|
|
4112
4222
|
syncClaudeCloudConversationUi();
|
|
4113
4223
|
// "via agent": the conversation an agent on this relay started this one from.
|
|
4114
4224
|
syncConversationOriginHeader();
|
|
4225
|
+
// Relay events: "watching N sessions" on a watcher, "watched" on a watched one.
|
|
4226
|
+
syncRelayEventHeader();
|
|
4115
4227
|
syncChatTitleWatcherIndicator();
|
|
4116
4228
|
}
|
|
4117
4229
|
|
|
4118
4230
|
function openChatTitleEditor() {
|
|
4119
4231
|
const convId = String(currentConvId || '').trim();
|
|
4120
4232
|
if (!convId) return;
|
|
4121
|
-
const { title,
|
|
4233
|
+
const { title, menuBtn, editor, input } = getChatTitleElements();
|
|
4122
4234
|
closeChatActionsMenu();
|
|
4123
4235
|
const currentTitle = String(conversations[convId]?.title || title?.textContent || convId).trim() || convId;
|
|
4124
4236
|
chatTitleEditingConversationId = convId;
|
|
4125
4237
|
document.body.classList.add('chat-title-editing');
|
|
4126
4238
|
if (title) title.hidden = true;
|
|
4127
|
-
if (
|
|
4239
|
+
if (menuBtn) menuBtn.disabled = true;
|
|
4128
4240
|
if (editor) editor.hidden = false;
|
|
4129
4241
|
if (input) {
|
|
4130
4242
|
input.maxLength = CHAT_TITLE_MAX_LENGTH;
|
|
@@ -4143,11 +4255,11 @@ function openChatTitleEditor() {
|
|
|
4143
4255
|
function closeChatTitleEditor() {
|
|
4144
4256
|
chatTitleEditingConversationId = null;
|
|
4145
4257
|
document.body.classList.remove('chat-title-editing');
|
|
4146
|
-
const { title,
|
|
4258
|
+
const { title, editor, input } = getChatTitleElements();
|
|
4147
4259
|
closeChatActionsMenu();
|
|
4148
4260
|
if (editor) editor.hidden = true;
|
|
4149
4261
|
if (title) title.hidden = !String(currentConvId || '').trim();
|
|
4150
|
-
|
|
4262
|
+
// The trigger is decided by the syncChatTitleControls() call below.
|
|
4151
4263
|
if (input) {
|
|
4152
4264
|
const convId = String(currentConvId || '').trim();
|
|
4153
4265
|
input.value = convId ? String(conversations[convId]?.title || title?.textContent || convId) : '';
|
|
@@ -4556,6 +4668,7 @@ async function initApp() {
|
|
|
4556
4668
|
initConversationHistoryLazyLoading();
|
|
4557
4669
|
initBubbleActionHandlers();
|
|
4558
4670
|
initMessageScrollPersistence();
|
|
4671
|
+
initTranscriptJumpToBottom();
|
|
4559
4672
|
syncChatTitleControls();
|
|
4560
4673
|
await initSharedConversationReader();
|
|
4561
4674
|
return;
|
|
@@ -4578,14 +4691,25 @@ async function initApp() {
|
|
|
4578
4691
|
const status = await refreshWorkspaceRootHints();
|
|
4579
4692
|
syncQueueStatusMenuEntry(status);
|
|
4580
4693
|
setSessionWorkerStatesFromStatusPayload(status?.sessionWorker || null);
|
|
4581
|
-
await refreshOpenAISettingsState();
|
|
4582
|
-
await refreshCursorSettingsState();
|
|
4583
|
-
await refreshGrokSettingsState();
|
|
4694
|
+
const openAISettings = await refreshOpenAISettingsState();
|
|
4695
|
+
const cursorSettings = await refreshCursorSettingsState();
|
|
4696
|
+
const grokSettings = await refreshGrokSettingsState();
|
|
4697
|
+
// Loaded here for provider readiness: whether any provider can run a chat
|
|
4698
|
+
// decides what the empty screen, New Chat and the composer offer.
|
|
4699
|
+
const claudeSettings = await refreshClaudeSettingsState();
|
|
4700
|
+
updateProviderReadinessInputs({
|
|
4701
|
+
openai: openAISettings || null,
|
|
4702
|
+
cursor: cursorSettings || null,
|
|
4703
|
+
grok: grokSettings || null,
|
|
4704
|
+
claude: claudeSettings || null,
|
|
4705
|
+
});
|
|
4584
4706
|
// Not awaited: only a Claude Cloud conversation needs it (its model list),
|
|
4585
4707
|
// and the subscription below rebuilds that list when the answer arrives. A
|
|
4586
4708
|
// relay without the provider answers nothing and nothing changes.
|
|
4587
4709
|
subscribeClaudeCloudSettings(refreshClaudeCloudComposerModels);
|
|
4588
|
-
void refreshClaudeCloudSettingsState()
|
|
4710
|
+
void refreshClaudeCloudSettingsState().then((state) => {
|
|
4711
|
+
updateProviderReadinessInputs({ claudeCloud: state || null });
|
|
4712
|
+
});
|
|
4589
4713
|
await refreshModelCatalog(true);
|
|
4590
4714
|
initFullscreenButton();
|
|
4591
4715
|
initInstallButton();
|
|
@@ -4599,8 +4723,10 @@ async function initApp() {
|
|
|
4599
4723
|
initConversationHistoryLazyLoading();
|
|
4600
4724
|
initBubbleActionHandlers();
|
|
4601
4725
|
initMessageScrollPersistence();
|
|
4726
|
+
initTranscriptJumpToBottom({ loadEnd: loadCurrentConversationEnd });
|
|
4602
4727
|
initComposerAttachmentInput();
|
|
4603
4728
|
initMessageSearchView({ openConversation });
|
|
4729
|
+
initSubagentViewer();
|
|
4604
4730
|
initPinnedMessagesView({
|
|
4605
4731
|
openConversation,
|
|
4606
4732
|
focusMessage: focusConversationMessageById,
|
|
@@ -4608,6 +4734,13 @@ async function initApp() {
|
|
|
4608
4734
|
});
|
|
4609
4735
|
initGitChangesView();
|
|
4610
4736
|
syncChatTitleControls();
|
|
4737
|
+
// A provider turned on or off (a Settings save, a catalog change) redraws
|
|
4738
|
+
// the empty screen and the composer without a reload.
|
|
4739
|
+
subscribeProviderReadiness(() => {
|
|
4740
|
+
renderNoConversationState();
|
|
4741
|
+
syncComposerButtonState();
|
|
4742
|
+
syncComposerPlaceholder();
|
|
4743
|
+
});
|
|
4611
4744
|
connectSocket();
|
|
4612
4745
|
startRelayConnectionWatchdog();
|
|
4613
4746
|
startDeviceVisibilityHeartbeat();
|
|
@@ -4619,6 +4752,9 @@ async function initApp() {
|
|
|
4619
4752
|
// Browsers without Background Sync replay the outbox from the page instead.
|
|
4620
4753
|
void initOutboxFallbackReplay();
|
|
4621
4754
|
await loadConversations();
|
|
4755
|
+
// The sidebar's "watched" badges follow the subscriptions.
|
|
4756
|
+
onRelayEventSubscriptionsChanged(() => renderConvList());
|
|
4757
|
+
void loadRelayEventSubscriptions();
|
|
4622
4758
|
const pushConversationId = consumePushConversationDeepLink();
|
|
4623
4759
|
if (pushConversationId) {
|
|
4624
4760
|
await openConversation(pushConversationId).catch(() => {});
|
|
@@ -4626,7 +4762,19 @@ async function initApp() {
|
|
|
4626
4762
|
await loadRelayQuestions(currentConvId);
|
|
4627
4763
|
await loadRelayBoards();
|
|
4628
4764
|
updateCompactButton();
|
|
4629
|
-
|
|
4765
|
+
// Nothing opened (fresh client, fresh relay, a last conversation that is
|
|
4766
|
+
// gone): the static markup gives way to the real empty screen.
|
|
4767
|
+
renderNoConversationState();
|
|
4768
|
+
syncComposerButtonState();
|
|
4769
|
+
syncComposerPlaceholder();
|
|
4770
|
+
// A phone shows no keyboard for this focus, but the next tap that keeps it
|
|
4771
|
+
// (the transcript's ⌄) would raise one.
|
|
4772
|
+
if (shouldAutofocusComposerOnLoad({
|
|
4773
|
+
coarsePointer: !!window.matchMedia?.('(pointer: coarse)')?.matches,
|
|
4774
|
+
phoneLayout: isMobileComposerViewport(),
|
|
4775
|
+
})) {
|
|
4776
|
+
document.getElementById('msg-input').focus();
|
|
4777
|
+
}
|
|
4630
4778
|
}
|
|
4631
4779
|
|
|
4632
4780
|
async function doAuth() {
|
|
@@ -4739,6 +4887,7 @@ window.previewTurnCeilingSetting = previewTurnCeilingSetting;
|
|
|
4739
4887
|
window.updateTurnCeilingSetting = updateTurnCeilingSetting;
|
|
4740
4888
|
window.previewBackgroundTaskTimeoutSetting = previewBackgroundTaskTimeoutSetting;
|
|
4741
4889
|
window.updateBackgroundTaskTimeoutSetting = updateBackgroundTaskTimeoutSetting;
|
|
4890
|
+
window.updateSubagentRecordRetentionSetting = updateSubagentRecordRetentionSetting;
|
|
4742
4891
|
window.togglePushOnThisDevice = togglePushOnThisDevice;
|
|
4743
4892
|
window.updatePushPreferencesFromControls = updatePushPreferencesFromControls;
|
|
4744
4893
|
window.openSettingsModal = openSettingsModal;
|
|
@@ -4815,6 +4964,7 @@ window.toggleRepoBrowserHidden = toggleRepoBrowserHidden;
|
|
|
4815
4964
|
window.toggleRepoBrowserHeavy = toggleRepoBrowserHeavy;
|
|
4816
4965
|
window.focusRepoTree = focusRepoTree;
|
|
4817
4966
|
window.setRepoCurrentPath = setRepoCurrentPath;
|
|
4967
|
+
window.startRepoNewFolder = startRepoNewFolder;
|
|
4818
4968
|
window.confirmRepoBrowserCwdPick = confirmRepoBrowserCwdPick;
|
|
4819
4969
|
window.toggleEmojiPicker = toggleEmojiPicker;
|
|
4820
4970
|
window.submitRelayQuestionChoice = submitRelayQuestionChoice;
|
|
@@ -4827,6 +4977,7 @@ window.setRelayQuestionMultiMode = setRelayQuestionMultiMode;
|
|
|
4827
4977
|
window.handleRelayQuestionKey = handleRelayQuestionKey;
|
|
4828
4978
|
window.openPendingQuestionFromBanner = openPendingQuestionFromBanner;
|
|
4829
4979
|
window.submitRelayBoardAction = submitRelayBoardAction;
|
|
4980
|
+
window.syncAgentExchangeFolds = syncAgentExchangeFoldsNow;
|
|
4830
4981
|
window.compactCurrentConversation = compactCurrentConversation;
|
|
4831
4982
|
window.sendMessage = sendMessage;
|
|
4832
4983
|
window.syncComposerButtonState = syncComposerButtonState;
|