@oar-sh/oar 0.9.8 → 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 +478 -0
- package/README.md +129 -39
- package/bin/bat/oar.bat +46 -46
- package/bin/oar.js +814 -228
- package/package.json +5 -6
- package/server/README.md +306 -4
- 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 +44 -0
- package/server/migrations/0008-message-pins.mjs +85 -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 +91 -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 +1024 -131
- package/server/public/app/back-guard.mjs +58 -0
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +230 -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 +471 -110
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/external-link-policy.mjs +4 -1
- package/server/public/app/file-version.mjs +58 -0
- package/server/public/app/host-suspend-ui.js +6 -3
- package/server/public/app/journal-view.js +161 -27
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/pinned-messages-view.mjs +253 -0
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/pwa-install.js +1 -1
- package/server/public/app/relay-board-view.js +3 -0
- package/server/public/app/relay-dot-state.mjs +8 -1
- 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/router.js +8 -2
- package/server/public/app/settings-modal.js +53 -1
- package/server/public/app/socket-handlers.js +65 -28
- package/server/public/app/store.js +25 -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-chat-gallery.mjs +59 -0
- package/server/public/app/viewer-controls-state.mjs +59 -0
- package/server/public/app/viewer-gallery.mjs +46 -0
- package/server/public/app/viewer-gestures.mjs +105 -0
- package/server/public/app/viewer-history.mjs +45 -0
- package/server/public/app/viewer-tap.mjs +112 -0
- package/server/public/index.html +700 -59
- package/server/repositories/message-pin-statements.mjs +39 -0
- package/server/repositories/message-repository.mjs +34 -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 +548 -111
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +164 -24
- package/server/server-runtime.mjs +274 -62
- package/server/services/cloudflared-tunnel-service.mjs +139 -41
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -0
- package/server/services/file-serving.mjs +155 -0
- package/server/services/message-pin-list.mjs +52 -0
- package/server/services/oar-cli-helpers.mjs +139 -4
- package/server/services/oar-cli-menu.mjs +240 -0
- package/server/services/oar-cli-wsl.mjs +129 -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/safe-served-content.mjs +61 -11
- package/server/services/session-activity-tracker.mjs +487 -0
- package/server/services/session-history-refresh-service.mjs +31 -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
|
@@ -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,
|
|
@@ -122,10 +124,18 @@ import {
|
|
|
122
124
|
jumpToImageParent,
|
|
123
125
|
flushDeferredMessageRender,
|
|
124
126
|
conversationHasActiveTurn,
|
|
127
|
+
focusConversationMessageById,
|
|
128
|
+
syncRenderedPinState,
|
|
129
|
+
isConversationWindowAtTail,
|
|
130
|
+
getTranscriptJumpEpoch,
|
|
131
|
+
getTranscriptLiveRowEpoch,
|
|
132
|
+
syncAgentExchangeFoldsNow,
|
|
125
133
|
} from './conversation-view.js';
|
|
134
|
+
import { loadKeepingLiveRows } from './transcript-reload-freshness.mjs';
|
|
135
|
+
import { initPinnedMessagesView, setPinsConversation } from './pinned-messages-view.mjs';
|
|
126
136
|
import { bindChatSelectionGuard, chatSelectionGuard, isChatInteractionHeld } from './selection-guard.mjs';
|
|
127
137
|
import { loadRepoBrowserTree, openRepoBrowser, closeRepoBrowser, setRepoBrowserSessionInfo, resetWorkspaceRepoBrowserForRootChange } from './attachments-view.js';
|
|
128
|
-
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';
|
|
129
139
|
import { initEmojiPicker, toggleEmojiPicker } from './emoji-view.js';
|
|
130
140
|
import { initSlashAutocomplete } from './slash-autocomplete.mjs';
|
|
131
141
|
import { initMentionAutocomplete } from './mention-autocomplete.mjs';
|
|
@@ -168,6 +178,7 @@ import {
|
|
|
168
178
|
openMessageSearchModal,
|
|
169
179
|
closeMessageSearchModal,
|
|
170
180
|
} from './message-search-view.js';
|
|
181
|
+
import { initSubagentViewer } from './subagent-viewer.mjs';
|
|
171
182
|
import {
|
|
172
183
|
initGitChangesView,
|
|
173
184
|
openGitChangesModal,
|
|
@@ -198,7 +209,9 @@ import {
|
|
|
198
209
|
adoptLegacyPwaAppName,
|
|
199
210
|
registerPwaShell,
|
|
200
211
|
updatePwaAppName,
|
|
212
|
+
isInstalledAppMode,
|
|
201
213
|
} from './pwa-install.js';
|
|
214
|
+
import { armBackGuardOnFirstInteraction } from './back-guard.mjs';
|
|
202
215
|
import { initScrollbarHover } from './scrollbar-hover.mjs';
|
|
203
216
|
import {
|
|
204
217
|
autoCompactWindowNotes,
|
|
@@ -267,6 +280,7 @@ import {
|
|
|
267
280
|
updateTheme,
|
|
268
281
|
openSettingsModal,
|
|
269
282
|
closeSettingsModal,
|
|
283
|
+
isSuspendHostActionVisible,
|
|
270
284
|
syncSuspendHostVisibility,
|
|
271
285
|
updateShowSuspendHostSetting,
|
|
272
286
|
syncDefaultSessionWorkspaceRootInput,
|
|
@@ -302,6 +316,7 @@ import {
|
|
|
302
316
|
updateTurnCeilingSetting,
|
|
303
317
|
previewBackgroundTaskTimeoutSetting,
|
|
304
318
|
updateBackgroundTaskTimeoutSetting,
|
|
319
|
+
updateSubagentRecordRetentionSetting,
|
|
305
320
|
} from './settings-modal.js';
|
|
306
321
|
import {
|
|
307
322
|
togglePushOnThisDevice,
|
|
@@ -349,14 +364,21 @@ import {
|
|
|
349
364
|
subscribeClaudeCloudSettings,
|
|
350
365
|
toggleClaudeCloudProvider,
|
|
351
366
|
} from './claude-cloud-settings-ui.js';
|
|
352
|
-
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';
|
|
353
374
|
import { syncConversationOriginHeader } from './conversation-origin-ui.js';
|
|
375
|
+
import { loadRelayEventSubscriptions, onRelayEventSubscriptionsChanged, syncRelayEventHeader } from './relay-event-subscriptions-ui.js';
|
|
354
376
|
import {
|
|
355
377
|
previewAgentSessionsMaxWait,
|
|
356
378
|
saveAgentSessionsMaxWait,
|
|
357
379
|
toggleAgentSessions,
|
|
358
380
|
} from './agent-sessions-settings-ui.js';
|
|
359
|
-
import { CLAUDE_CLOUD_PROVIDER, claudeCloudComposerModelIds } from './claude-cloud-ui.mjs';
|
|
381
|
+
import { CLAUDE_CLOUD_PROVIDER, buildCloudLineModel, claudeCloudComposerModelIds } from './claude-cloud-ui.mjs';
|
|
360
382
|
import {
|
|
361
383
|
confirmCliInstall,
|
|
362
384
|
runCliInstall,
|
|
@@ -370,6 +392,7 @@ import {
|
|
|
370
392
|
dismissAvailableUpdate,
|
|
371
393
|
dismissUpdateOutcome,
|
|
372
394
|
} from './update-ui.js';
|
|
395
|
+
import { shouldAutofocusComposerOnLoad } from './composer-keyboard.mjs';
|
|
373
396
|
|
|
374
397
|
const MODEL_STORAGE_KEY = 'copilot_selected_model';
|
|
375
398
|
// The New Chat modal used to keep its own model key, so a selection made there
|
|
@@ -946,6 +969,10 @@ function applySharedReaderUi() {
|
|
|
946
969
|
if (modelSelect) modelSelect.hidden = true;
|
|
947
970
|
if (effortSelect) effortSelect.hidden = true;
|
|
948
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 }));
|
|
949
976
|
if (chatMenu && sharedThemeBtn) {
|
|
950
977
|
for (const child of Array.from(chatMenu.children)) {
|
|
951
978
|
if (child !== sharedThemeBtn) hideSharedMenuEntry(child);
|
|
@@ -999,6 +1026,15 @@ function resolveStableScrollTopForLiveRefresh(messagesEl, capturedScrollTop) {
|
|
|
999
1026
|
return captured;
|
|
1000
1027
|
}
|
|
1001
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
|
+
|
|
1002
1038
|
async function refreshSharedConversation() {
|
|
1003
1039
|
if (sharedConversationPollInFlight) return;
|
|
1004
1040
|
const sharedToken = resolveSharedTokenFromLocation();
|
|
@@ -1006,11 +1042,17 @@ async function refreshSharedConversation() {
|
|
|
1006
1042
|
sharedConversationPollInFlight = true;
|
|
1007
1043
|
const requestSeq = ++sharedConversationRequestSeq;
|
|
1008
1044
|
const messagesEl = document.getElementById('messages');
|
|
1009
|
-
const
|
|
1045
|
+
const wasAtBottom = isMessagesAtBottom();
|
|
1010
1046
|
const savedScrollTop = messagesEl?.scrollTop || 0;
|
|
1011
1047
|
try {
|
|
1012
|
-
|
|
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
|
+
);
|
|
1013
1054
|
if (requestSeq < sharedConversationAppliedSeq) return;
|
|
1055
|
+
if (stale) return;
|
|
1014
1056
|
if (!response?.ok) {
|
|
1015
1057
|
const message = String(response?.error || 'Could not load shared conversation.').trim();
|
|
1016
1058
|
if (message && message !== sharedConversationLastError) {
|
|
@@ -1043,6 +1085,7 @@ async function refreshSharedConversation() {
|
|
|
1043
1085
|
return;
|
|
1044
1086
|
}
|
|
1045
1087
|
sharedConversationRenderKey = renderKey;
|
|
1088
|
+
const preserveBottom = resolveLiveRefreshAtBottom(messagesEl, savedScrollTop, wasAtBottom);
|
|
1046
1089
|
const stableSavedScrollTop = resolveStableScrollTopForLiveRefresh(messagesEl, savedScrollTop);
|
|
1047
1090
|
applyLoadedConversationState(convId, response, {
|
|
1048
1091
|
restoreScroll: !preserveBottom,
|
|
@@ -1375,6 +1418,8 @@ function syncComposerPlaceholder() {
|
|
|
1375
1418
|
if (!input) return;
|
|
1376
1419
|
if (isSharedReaderMode()) return;
|
|
1377
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;
|
|
1378
1423
|
const rawValue = String(document.getElementById('model-select')?.value || '');
|
|
1379
1424
|
const { baseModelId } = splitModelVariantId(rawValue);
|
|
1380
1425
|
input.placeholder = composerPlaceholderFor({
|
|
@@ -1609,6 +1654,8 @@ function updateModelCatalogState(payload) {
|
|
|
1609
1654
|
// Shared readers never load the catalog, so this would only surface
|
|
1610
1655
|
// "metadata unavailable" warnings for a picker they cannot see.
|
|
1611
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 });
|
|
1612
1659
|
const select = document.getElementById('model-select');
|
|
1613
1660
|
if (!select) return;
|
|
1614
1661
|
const models = Array.isArray(payload?.models)
|
|
@@ -2173,6 +2220,8 @@ function initModeSelector() {
|
|
|
2173
2220
|
function refreshModelCatalog(force = false) {
|
|
2174
2221
|
return loadModelCatalog().then((r) => {
|
|
2175
2222
|
if (!r) {
|
|
2223
|
+
// A failed load leaves provider readiness unknown: no setup wall on a blip.
|
|
2224
|
+
updateProviderReadinessInputs({ catalog: null });
|
|
2176
2225
|
if (isModelMetadataHealthy(modelCatalogState) || isModelMetadataHealthy(lastHealthyModelCatalogState)) {
|
|
2177
2226
|
setModelBanner('⚠️ Could not refresh model metadata; using last known good catalog.');
|
|
2178
2227
|
syncModelMetadataBlocker();
|
|
@@ -3592,6 +3641,15 @@ function startSessionWorkerStatusPolling() {
|
|
|
3592
3641
|
}, 4000);
|
|
3593
3642
|
}
|
|
3594
3643
|
|
|
3644
|
+
// The live poll and refreshCurrentView both reload the END of the
|
|
3645
|
+
// conversation. After a jump into the history (a search result, a pinned
|
|
3646
|
+
// message) that would put the end back over the window the user asked for,
|
|
3647
|
+
// until they have scrolled down to the newest message again. A message sent
|
|
3648
|
+
// from that window brings the present back at once: its bubble needs the poll.
|
|
3649
|
+
function transcriptIsInHistory(conversationId) {
|
|
3650
|
+
return !isConversationWindowAtTail() && !hasPendingUserMessageForConversation(conversationId);
|
|
3651
|
+
}
|
|
3652
|
+
|
|
3595
3653
|
async function pollAuthenticatedCurrentConversationLive() {
|
|
3596
3654
|
if (liveConversationPollInFlight) return;
|
|
3597
3655
|
if (isSharedReaderMode()) return;
|
|
@@ -3609,17 +3667,30 @@ async function pollAuthenticatedCurrentConversationLive() {
|
|
|
3609
3667
|
// Refreshing while the user selects or drags in the chat would rebuild the
|
|
3610
3668
|
// DOM under the selection; the guard's release callback re-polls right away.
|
|
3611
3669
|
if (isChatInteractionHeld()) return;
|
|
3670
|
+
if (transcriptIsInHistory(currentId)) return;
|
|
3612
3671
|
|
|
3613
3672
|
liveConversationPollInFlight = true;
|
|
3614
3673
|
try {
|
|
3615
3674
|
const messagesEl = document.getElementById('messages');
|
|
3616
|
-
const
|
|
3675
|
+
const wasAtBottom = isMessagesAtBottom();
|
|
3617
3676
|
const savedScrollTop = messagesEl?.scrollTop || 0;
|
|
3618
3677
|
const savedLoadedCount = loadConversationLoadedMessageCount(currentId);
|
|
3619
3678
|
const requestLimit = Math.max(20, getConversationLoadedMessageCount() || 0, savedLoadedCount || 0);
|
|
3620
|
-
const
|
|
3679
|
+
const jumpEpoch = getTranscriptJumpEpoch();
|
|
3680
|
+
const { response, stale } = await loadKeepingLiveRows(
|
|
3681
|
+
() => loadConversation(currentId, { limit: requestLimit }),
|
|
3682
|
+
getTranscriptLiveRowEpoch,
|
|
3683
|
+
);
|
|
3621
3684
|
if (!response) return;
|
|
3622
3685
|
if (String(currentConvId || '').trim() !== currentId) return;
|
|
3686
|
+
// The user jumped into the history while this request was on its way: the
|
|
3687
|
+
// answer is the end of the conversation as it was asked for before that,
|
|
3688
|
+
// and would overwrite the window they jumped to. The next round asks again.
|
|
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);
|
|
3623
3694
|
const stableSavedScrollTop = resolveStableScrollTopForLiveRefresh(messagesEl, savedScrollTop);
|
|
3624
3695
|
applyLoadedConversationState(currentId, response, {
|
|
3625
3696
|
restoreScroll: !preserveBottom,
|
|
@@ -3771,6 +3842,19 @@ function initMessageScrollPersistence() {
|
|
|
3771
3842
|
}, { passive: true });
|
|
3772
3843
|
}
|
|
3773
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
|
+
|
|
3774
3858
|
async function runConversationHistoryRefresh({ source = 'menu' } = {}) {
|
|
3775
3859
|
const currentId = String(currentConvId || '').trim();
|
|
3776
3860
|
if (!currentId) {
|
|
@@ -3827,10 +3911,10 @@ let refreshViewVersion = 0;
|
|
|
3827
3911
|
|
|
3828
3912
|
async function refreshCurrentView() {
|
|
3829
3913
|
const capturedVersion = ++refreshViewVersion;
|
|
3914
|
+
const jumpEpoch = getTranscriptJumpEpoch();
|
|
3830
3915
|
const messagesEl = document.getElementById('messages');
|
|
3831
|
-
const
|
|
3916
|
+
const wasAtBottom = isMessagesAtBottom();
|
|
3832
3917
|
const scrollTop = messagesEl?.scrollTop || 0;
|
|
3833
|
-
const stableSavedScrollTop = resolveStableScrollTopForLiveRefresh(messagesEl, scrollTop);
|
|
3834
3918
|
|
|
3835
3919
|
await refreshConversations();
|
|
3836
3920
|
|
|
@@ -3846,14 +3930,25 @@ async function refreshCurrentView() {
|
|
|
3846
3930
|
|
|
3847
3931
|
const savedLoadedCount = loadConversationLoadedMessageCount(currentId);
|
|
3848
3932
|
const requestLimit = Math.max(20, getConversationLoadedMessageCount() || 0, savedLoadedCount || 0);
|
|
3849
|
-
const r = await
|
|
3933
|
+
const { response: r, stale } = await loadKeepingLiveRows(
|
|
3934
|
+
() => loadConversation(currentId, { limit: requestLimit }),
|
|
3935
|
+
getTranscriptLiveRowEpoch,
|
|
3936
|
+
);
|
|
3850
3937
|
if (capturedVersion < refreshViewVersion) return;
|
|
3851
3938
|
if (String(currentConvId || '').trim() !== currentId) return;
|
|
3939
|
+
// The user jumped into the history, before this refresh or while it was on
|
|
3940
|
+
// its way: title, tasks, pins and the rest are refreshed, the transcript
|
|
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);
|
|
3852
3946
|
if (r) {
|
|
3853
3947
|
applyLoadedConversationState(currentId, r, {
|
|
3854
3948
|
restoreScroll: !preserveBottom,
|
|
3855
3949
|
savedScrollTop: preserveBottom ? null : stableSavedScrollTop,
|
|
3856
|
-
followLiveUpdates: preserveBottom,
|
|
3950
|
+
followLiveUpdates: preserveBottom && !keepTranscript,
|
|
3951
|
+
keepTranscript,
|
|
3857
3952
|
});
|
|
3858
3953
|
} else {
|
|
3859
3954
|
setRepoBrowserSessionInfo('', '');
|
|
@@ -3862,7 +3957,7 @@ async function refreshCurrentView() {
|
|
|
3862
3957
|
await loadRelayQuestions(currentId);
|
|
3863
3958
|
await loadRelayBoards();
|
|
3864
3959
|
scheduleContextUsageRefresh(currentId, 0);
|
|
3865
|
-
if (messagesEl && String(currentConvId || '').trim() === currentId) {
|
|
3960
|
+
if (messagesEl && !keepTranscript && String(currentConvId || '').trim() === currentId) {
|
|
3866
3961
|
if (!preserveBottom && Number.isFinite(stableSavedScrollTop) && stableSavedScrollTop >= 0) {
|
|
3867
3962
|
messagesEl.scrollTop = stableSavedScrollTop;
|
|
3868
3963
|
}
|
|
@@ -3923,7 +4018,7 @@ function getChatTitleElements() {
|
|
|
3923
4018
|
return {
|
|
3924
4019
|
wrap: document.getElementById('chat-title-wrap'),
|
|
3925
4020
|
title: document.getElementById('chat-title'),
|
|
3926
|
-
|
|
4021
|
+
menuBtn: document.getElementById('chat-actions-menu-btn'),
|
|
3927
4022
|
editor: document.getElementById('chat-title-editor'),
|
|
3928
4023
|
input: document.getElementById('chat-title-input'),
|
|
3929
4024
|
saveBtn: document.getElementById('chat-title-save-btn'),
|
|
@@ -3940,10 +4035,61 @@ function closeChatActionsMenu() {
|
|
|
3940
4035
|
if (backdrop && !window.__chatActionsMenuShieldTimer) backdrop.classList.remove('visible');
|
|
3941
4036
|
}
|
|
3942
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
|
+
|
|
3943
4086
|
function toggleChatActionsMenu() {
|
|
3944
4087
|
const menu = document.getElementById('chat-actions-menu');
|
|
3945
4088
|
const trigger = document.getElementById('chat-actions-menu-btn');
|
|
3946
|
-
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;
|
|
3947
4093
|
syncRefreshHistoryMenuState();
|
|
3948
4094
|
syncThemeMenuLabel();
|
|
3949
4095
|
const willOpen = !!menu.hidden;
|
|
@@ -4022,10 +4168,10 @@ function lockChatActionsMenuShield(ms = 300) {
|
|
|
4022
4168
|
|
|
4023
4169
|
|
|
4024
4170
|
function syncChatTitleControls() {
|
|
4025
|
-
const { title,
|
|
4171
|
+
const { title, editor, input } = getChatTitleElements();
|
|
4026
4172
|
const convId = String(currentConvId || '').trim();
|
|
4027
|
-
|
|
4028
|
-
|
|
4173
|
+
// No conversation open (deleted, new chat): its 📍 button goes with it.
|
|
4174
|
+
if (!convId) setPinsConversation(null);
|
|
4029
4175
|
const conversation = convId ? (conversations[convId] || null) : null;
|
|
4030
4176
|
const sdkSessionId = String(conversation?.sdkSessionId || '').trim();
|
|
4031
4177
|
if (title) {
|
|
@@ -4049,17 +4195,11 @@ function syncChatTitleControls() {
|
|
|
4049
4195
|
const editing = convId && chatTitleEditingConversationId === convId;
|
|
4050
4196
|
document.body.classList.toggle('chat-title-editing', !!editing);
|
|
4051
4197
|
if (title) title.hidden = editing;
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
}
|
|
4056
|
-
if (killBtn) {
|
|
4057
|
-
killBtn.disabled = sharedMode || !convId || !sdkSessionId;
|
|
4058
|
-
killBtn.hidden = sharedMode || !convId;
|
|
4059
|
-
}
|
|
4198
|
+
// The ⋯ trigger and its entries: relay-wide ones stay with no conversation
|
|
4199
|
+
// open, so Settings is always one tap away.
|
|
4200
|
+
syncChatActionsMenuState();
|
|
4060
4201
|
const archiveBtn = document.getElementById('chat-menu-archive');
|
|
4061
4202
|
if (archiveBtn) {
|
|
4062
|
-
archiveBtn.hidden = sharedMode || !convId;
|
|
4063
4203
|
archiveBtn.textContent = conversation?.archived === true ? '📂 Unarchive conversation' : '🗄 Archive conversation';
|
|
4064
4204
|
}
|
|
4065
4205
|
if (editor) {
|
|
@@ -4082,19 +4222,21 @@ function syncChatTitleControls() {
|
|
|
4082
4222
|
syncClaudeCloudConversationUi();
|
|
4083
4223
|
// "via agent": the conversation an agent on this relay started this one from.
|
|
4084
4224
|
syncConversationOriginHeader();
|
|
4225
|
+
// Relay events: "watching N sessions" on a watcher, "watched" on a watched one.
|
|
4226
|
+
syncRelayEventHeader();
|
|
4085
4227
|
syncChatTitleWatcherIndicator();
|
|
4086
4228
|
}
|
|
4087
4229
|
|
|
4088
4230
|
function openChatTitleEditor() {
|
|
4089
4231
|
const convId = String(currentConvId || '').trim();
|
|
4090
4232
|
if (!convId) return;
|
|
4091
|
-
const { title,
|
|
4233
|
+
const { title, menuBtn, editor, input } = getChatTitleElements();
|
|
4092
4234
|
closeChatActionsMenu();
|
|
4093
4235
|
const currentTitle = String(conversations[convId]?.title || title?.textContent || convId).trim() || convId;
|
|
4094
4236
|
chatTitleEditingConversationId = convId;
|
|
4095
4237
|
document.body.classList.add('chat-title-editing');
|
|
4096
4238
|
if (title) title.hidden = true;
|
|
4097
|
-
if (
|
|
4239
|
+
if (menuBtn) menuBtn.disabled = true;
|
|
4098
4240
|
if (editor) editor.hidden = false;
|
|
4099
4241
|
if (input) {
|
|
4100
4242
|
input.maxLength = CHAT_TITLE_MAX_LENGTH;
|
|
@@ -4113,11 +4255,11 @@ function openChatTitleEditor() {
|
|
|
4113
4255
|
function closeChatTitleEditor() {
|
|
4114
4256
|
chatTitleEditingConversationId = null;
|
|
4115
4257
|
document.body.classList.remove('chat-title-editing');
|
|
4116
|
-
const { title,
|
|
4258
|
+
const { title, editor, input } = getChatTitleElements();
|
|
4117
4259
|
closeChatActionsMenu();
|
|
4118
4260
|
if (editor) editor.hidden = true;
|
|
4119
4261
|
if (title) title.hidden = !String(currentConvId || '').trim();
|
|
4120
|
-
|
|
4262
|
+
// The trigger is decided by the syncChatTitleControls() call below.
|
|
4121
4263
|
if (input) {
|
|
4122
4264
|
const convId = String(currentConvId || '').trim();
|
|
4123
4265
|
input.value = convId ? String(conversations[convId]?.title || title?.textContent || convId) : '';
|
|
@@ -4526,6 +4668,7 @@ async function initApp() {
|
|
|
4526
4668
|
initConversationHistoryLazyLoading();
|
|
4527
4669
|
initBubbleActionHandlers();
|
|
4528
4670
|
initMessageScrollPersistence();
|
|
4671
|
+
initTranscriptJumpToBottom();
|
|
4529
4672
|
syncChatTitleControls();
|
|
4530
4673
|
await initSharedConversationReader();
|
|
4531
4674
|
return;
|
|
@@ -4548,14 +4691,25 @@ async function initApp() {
|
|
|
4548
4691
|
const status = await refreshWorkspaceRootHints();
|
|
4549
4692
|
syncQueueStatusMenuEntry(status);
|
|
4550
4693
|
setSessionWorkerStatesFromStatusPayload(status?.sessionWorker || null);
|
|
4551
|
-
await refreshOpenAISettingsState();
|
|
4552
|
-
await refreshCursorSettingsState();
|
|
4553
|
-
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
|
+
});
|
|
4554
4706
|
// Not awaited: only a Claude Cloud conversation needs it (its model list),
|
|
4555
4707
|
// and the subscription below rebuilds that list when the answer arrives. A
|
|
4556
4708
|
// relay without the provider answers nothing and nothing changes.
|
|
4557
4709
|
subscribeClaudeCloudSettings(refreshClaudeCloudComposerModels);
|
|
4558
|
-
void refreshClaudeCloudSettingsState()
|
|
4710
|
+
void refreshClaudeCloudSettingsState().then((state) => {
|
|
4711
|
+
updateProviderReadinessInputs({ claudeCloud: state || null });
|
|
4712
|
+
});
|
|
4559
4713
|
await refreshModelCatalog(true);
|
|
4560
4714
|
initFullscreenButton();
|
|
4561
4715
|
initInstallButton();
|
|
@@ -4569,10 +4723,24 @@ async function initApp() {
|
|
|
4569
4723
|
initConversationHistoryLazyLoading();
|
|
4570
4724
|
initBubbleActionHandlers();
|
|
4571
4725
|
initMessageScrollPersistence();
|
|
4726
|
+
initTranscriptJumpToBottom({ loadEnd: loadCurrentConversationEnd });
|
|
4572
4727
|
initComposerAttachmentInput();
|
|
4573
4728
|
initMessageSearchView({ openConversation });
|
|
4729
|
+
initSubagentViewer();
|
|
4730
|
+
initPinnedMessagesView({
|
|
4731
|
+
openConversation,
|
|
4732
|
+
focusMessage: focusConversationMessageById,
|
|
4733
|
+
syncRenderedPinState,
|
|
4734
|
+
});
|
|
4574
4735
|
initGitChangesView();
|
|
4575
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
|
+
});
|
|
4576
4744
|
connectSocket();
|
|
4577
4745
|
startRelayConnectionWatchdog();
|
|
4578
4746
|
startDeviceVisibilityHeartbeat();
|
|
@@ -4584,6 +4752,9 @@ async function initApp() {
|
|
|
4584
4752
|
// Browsers without Background Sync replay the outbox from the page instead.
|
|
4585
4753
|
void initOutboxFallbackReplay();
|
|
4586
4754
|
await loadConversations();
|
|
4755
|
+
// The sidebar's "watched" badges follow the subscriptions.
|
|
4756
|
+
onRelayEventSubscriptionsChanged(() => renderConvList());
|
|
4757
|
+
void loadRelayEventSubscriptions();
|
|
4587
4758
|
const pushConversationId = consumePushConversationDeepLink();
|
|
4588
4759
|
if (pushConversationId) {
|
|
4589
4760
|
await openConversation(pushConversationId).catch(() => {});
|
|
@@ -4591,7 +4762,19 @@ async function initApp() {
|
|
|
4591
4762
|
await loadRelayQuestions(currentConvId);
|
|
4592
4763
|
await loadRelayBoards();
|
|
4593
4764
|
updateCompactButton();
|
|
4594
|
-
|
|
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
|
+
}
|
|
4595
4778
|
}
|
|
4596
4779
|
|
|
4597
4780
|
async function doAuth() {
|
|
@@ -4627,6 +4810,15 @@ async function bootstrap() {
|
|
|
4627
4810
|
}
|
|
4628
4811
|
const urlToken = getTokenFromUrl();
|
|
4629
4812
|
if (urlToken) stripTokenFromUrl();
|
|
4813
|
+
// In the installed app the system back button must never close the app;
|
|
4814
|
+
// a tab keeps its own back button. After the token is gone from the
|
|
4815
|
+
// address, so the entries the guard lays down carry the clean one.
|
|
4816
|
+
armBackGuardOnFirstInteraction({
|
|
4817
|
+
history: window.history,
|
|
4818
|
+
enabled: isInstalledAppMode(),
|
|
4819
|
+
target: window,
|
|
4820
|
+
onFloor: () => showTransientRelayNotice('Press back again to close the app', 2500),
|
|
4821
|
+
});
|
|
4630
4822
|
const persistedToken = consumeLegacyPersistedAuthToken();
|
|
4631
4823
|
const bootstrapToken = String(urlToken || persistedToken || '').trim();
|
|
4632
4824
|
const existingSession = await verifyExistingSession(bootstrapToken);
|
|
@@ -4695,6 +4887,7 @@ window.previewTurnCeilingSetting = previewTurnCeilingSetting;
|
|
|
4695
4887
|
window.updateTurnCeilingSetting = updateTurnCeilingSetting;
|
|
4696
4888
|
window.previewBackgroundTaskTimeoutSetting = previewBackgroundTaskTimeoutSetting;
|
|
4697
4889
|
window.updateBackgroundTaskTimeoutSetting = updateBackgroundTaskTimeoutSetting;
|
|
4890
|
+
window.updateSubagentRecordRetentionSetting = updateSubagentRecordRetentionSetting;
|
|
4698
4891
|
window.togglePushOnThisDevice = togglePushOnThisDevice;
|
|
4699
4892
|
window.updatePushPreferencesFromControls = updatePushPreferencesFromControls;
|
|
4700
4893
|
window.openSettingsModal = openSettingsModal;
|
|
@@ -4771,6 +4964,7 @@ window.toggleRepoBrowserHidden = toggleRepoBrowserHidden;
|
|
|
4771
4964
|
window.toggleRepoBrowserHeavy = toggleRepoBrowserHeavy;
|
|
4772
4965
|
window.focusRepoTree = focusRepoTree;
|
|
4773
4966
|
window.setRepoCurrentPath = setRepoCurrentPath;
|
|
4967
|
+
window.startRepoNewFolder = startRepoNewFolder;
|
|
4774
4968
|
window.confirmRepoBrowserCwdPick = confirmRepoBrowserCwdPick;
|
|
4775
4969
|
window.toggleEmojiPicker = toggleEmojiPicker;
|
|
4776
4970
|
window.submitRelayQuestionChoice = submitRelayQuestionChoice;
|
|
@@ -4783,6 +4977,7 @@ window.setRelayQuestionMultiMode = setRelayQuestionMultiMode;
|
|
|
4783
4977
|
window.handleRelayQuestionKey = handleRelayQuestionKey;
|
|
4784
4978
|
window.openPendingQuestionFromBanner = openPendingQuestionFromBanner;
|
|
4785
4979
|
window.submitRelayBoardAction = submitRelayBoardAction;
|
|
4980
|
+
window.syncAgentExchangeFolds = syncAgentExchangeFoldsNow;
|
|
4786
4981
|
window.compactCurrentConversation = compactCurrentConversation;
|
|
4787
4982
|
window.sendMessage = sendMessage;
|
|
4788
4983
|
window.syncComposerButtonState = syncComposerButtonState;
|