@oar-sh/oar 0.9.19 → 0.9.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/extensions/web-relay/polling/polling-loop.mjs +2 -26
- package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +2 -26
- package/CHANGELOG.md +529 -0
- package/README.md +14 -14
- package/bin/oar.js +392 -98
- package/package.json +4 -2
- package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +60 -0
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +258 -1
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +22 -1
- package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
- package/server/claude-worker/claude-session-process.mjs +10 -0
- package/server/claude-worker/claude-session-worker-main.mjs +8 -1
- package/server/claude-worker/claude-turn-publisher.mjs +163 -8
- package/server/claude-worker/claude-usage-totals.mjs +58 -0
- package/server/copilot-worker/copilot-plan-board.mjs +4 -7
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +86 -3
- package/server/copilot-worker/copilot-sdk-session-process.mjs +131 -5
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -1
- package/server/cursor-worker/cursor-session-worker.mjs +12 -1
- package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
- package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
- package/server/db-schema.mjs +18 -0
- package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
- package/server/grok-worker/grok-background-tasks.mjs +9 -0
- package/server/grok-worker/grok-session-worker.mjs +12 -1
- package/server/grok-worker/grok-turn-runner.mjs +47 -6
- package/server/grok-worker/grok-worker-steering.mjs +23 -0
- package/server/migrations/0001-add-structured-answer.mjs +5 -7
- package/server/migrations/0002-recent-workspace-roots-path-key.mjs +5 -7
- package/server/migrations/0003-push-subscriptions.mjs +5 -7
- package/server/migrations/0004-steer-settle-markers.mjs +5 -7
- package/server/migrations/0005-remote-relays.mjs +5 -7
- package/server/migrations/0006-workspace-root-settings.mjs +5 -7
- package/server/migrations/0007-claude-cloud.mjs +5 -7
- package/server/migrations/0008-message-pins.mjs +5 -7
- package/server/migrations/0009-subagent-timeline.mjs +5 -7
- package/server/migrations/0010-subagent-timeline-pruning.mjs +5 -7
- package/server/migrations/0011-relay-event-subscriptions.mjs +5 -7
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +5 -7
- package/server/migrations/0013-previews.mjs +5 -7
- package/server/migrations/0014-conversation-groups.mjs +5 -7
- package/server/migrations/0015-conversation-group-position.mjs +5 -7
- package/server/migrations/0016-share-access-log.mjs +5 -7
- package/server/migrations/0017-conversation-history-indexes.mjs +5 -7
- package/server/migrations/0018-background-task-rows.mjs +5 -7
- package/server/migrations/0019-relay-event-subscription-options.mjs +5 -7
- package/server/migrations/0020-conversation-may-create-sessions.mjs +5 -7
- package/server/migrations/0021-conversation-group-agent-marks.mjs +5 -7
- package/server/migrations/0022-relay-schedules.mjs +5 -7
- package/server/migrations/0023-relay-asks.mjs +5 -7
- package/server/migrations/0024-deepseek-usage-totals.mjs +5 -7
- package/server/migrations/0025-deepseek-usage-requests-daily.mjs +5 -7
- package/server/migrations/0026-deepseek-usage-served-models.mjs +5 -7
- package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +5 -7
- package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +5 -7
- package/server/migrations/0029-deepseek-balance-rises.mjs +5 -7
- package/server/migrations/0030-deepseek-usage-actuals.mjs +5 -7
- package/server/migrations/0031-queue-provider.mjs +5 -7
- package/server/migrations/0032-runtime-session-billing-switch.mjs +5 -7
- package/server/migrations/0033-claude-api-usage-checkpoints.mjs +5 -7
- package/server/migrations/0034-claude-api-usage-totals.mjs +5 -7
- package/server/migrations/0035-conversation-plan-only.mjs +87 -0
- package/server/migrations/0036-token-usage.mjs +267 -0
- package/server/public/app/about-ui.js +1 -1
- package/server/public/app/about-view.mjs +106 -3
- package/server/public/app/action-confirmations.js +1 -1
- package/server/public/app/api-client.js +6 -3
- package/server/public/app/ask-user-view.js +20 -6
- package/server/public/app/attachments-view.js +1 -1
- package/server/public/app/back-guard.mjs +31 -5
- package/server/public/app/bootstrap.js +611 -70
- package/server/public/app/chat-actions-menu-state.mjs +139 -4
- package/server/public/app/chat-header-meta.mjs +27 -0
- package/server/public/app/claude-billing-toggle-ui.js +2 -1
- package/server/public/app/claude-cloud-conversation-ui.js +2 -2
- package/server/public/app/composer-control-state.mjs +12 -2
- package/server/public/app/composer-settings-label.mjs +90 -0
- package/server/public/app/composer-settings-ui.js +442 -0
- package/server/public/app/context-menu.mjs +36 -5
- package/server/public/app/context-usage-view.mjs +16 -0
- package/server/public/app/conversation-deep-link.mjs +15 -11
- package/server/public/app/conversation-group-drag.mjs +12 -2
- package/server/public/app/conversation-group-order.mjs +9 -2
- package/server/public/app/conversation-group-state.mjs +47 -10
- package/server/public/app/conversation-origin-ui.js +17 -1
- package/server/public/app/conversation-view.js +20 -2
- package/server/public/app/cwd-menu-placement.mjs +18 -6
- package/server/public/app/cwd-picker.js +36 -6
- package/server/public/app/emoji-view.js +1 -1
- package/server/public/app/font-scaling.js +26 -0
- package/server/public/app/header-priority-actions.mjs +234 -0
- package/server/public/app/hidden-messages-view.mjs +4 -0
- package/server/public/app/inbox-ui.js +50 -2
- package/server/public/app/inbox-view.mjs +141 -5
- package/server/public/app/journal-view.js +311 -58
- package/server/public/app/phone-landscape-ui.js +225 -0
- package/server/public/app/phone-landscape.mjs +99 -0
- package/server/public/app/phone-typing-state.mjs +104 -0
- package/server/public/app/pinned-messages-view.mjs +5 -0
- package/server/public/app/plan-board-shared.mjs +49 -0
- package/server/public/app/plan-usage-view.mjs +1 -1
- package/server/public/app/push-settings.js +2 -2
- package/server/public/app/pwa-install.js +24 -3
- package/server/public/app/relay-board-inline.mjs +4 -1
- package/server/public/app/relay-board-view.js +38 -10
- package/server/public/app/relay-event-subscriptions-ui.js +71 -22
- package/server/public/app/relay-event-view.mjs +42 -0
- package/server/public/app/relay-restart-ui.js +2 -2
- package/server/public/app/relay-schedule-view.mjs +32 -6
- package/server/public/app/relay-schedules-ui.js +69 -20
- package/server/public/app/remote-relay-origin-view.mjs +41 -23
- package/server/public/app/session-history-ui.js +250 -0
- package/server/public/app/session-history.mjs +599 -0
- package/server/public/app/settings-modal.js +61 -6
- package/server/public/app/settings-tabs.js +12 -0
- package/server/public/app/share-state.js +1 -1
- package/server/public/app/shared-conversations-settings.js +2 -2
- package/server/public/app/socket-handlers.js +15 -4
- package/server/public/app/status-view.mjs +18 -2
- package/server/public/app/store.js +87 -16
- package/server/public/app/subagent-viewer.mjs +27 -2
- package/server/public/app/tab-strip-fade.mjs +101 -0
- package/server/public/app/tap-actions.js +66 -0
- package/server/public/app/usage-limit-ui.js +63 -2
- package/server/public/app/viewer-history.mjs +13 -2
- package/server/public/index.html +2258 -1585
- package/server/public/sw.js +8 -0
- package/server/relay-heap-guard.mjs +14 -1
- package/server/relay-self-restart.mjs +83 -2
- package/server/relay-tools.md +5 -3
- package/server/repositories/message-repository.mjs +54 -1
- package/server/repositories/remote-relay-repository.mjs +10 -0
- package/server/repositories/session-repository.mjs +25 -1
- package/server/routes/claude-cloud-routes.mjs +19 -0
- package/server/routes/messages-routes.mjs +144 -1
- package/server/routes/relay-ask-routes.mjs +138 -8
- package/server/routes/relay-board-routes.mjs +55 -40
- package/server/routes/sessions-routes.mjs +144 -14
- package/server/server-runtime.mjs +322 -51
- package/server/services/about-service.mjs +81 -53
- package/server/services/cache-rebuild-service.mjs +7 -0
- package/server/services/claude-billing-swap-service.mjs +7 -20
- package/server/services/claude-cloud-session-service.mjs +47 -5
- package/server/services/conversation-preferences-service.mjs +31 -0
- package/server/services/database-file.mjs +413 -0
- package/server/services/db-backup-service.mjs +809 -0
- package/server/services/deepseek-usage-import.mjs +6 -9
- package/server/services/delete-archive-service.mjs +3 -0
- package/server/services/earlier-database-service.mjs +69 -126
- package/server/services/oar-cli-helpers.mjs +214 -12
- package/server/services/oar-cli-menu.mjs +1 -0
- package/server/services/oar-state-migration-service.mjs +33 -13
- package/server/services/package-data-rescue.mjs +4 -2
- package/server/services/plan-usage-claude-api.mjs +14 -15
- package/server/services/plan-usage-contract.mjs +6 -2
- package/server/services/plan-usage-deepseek.mjs +1 -1
- package/server/services/plan-usage-tokens.mjs +248 -0
- package/server/services/push-dispatch-service.mjs +134 -9
- package/server/services/relay-cli-launcher-service.mjs +3 -0
- package/server/services/relay-event-service.mjs +30 -0
- package/server/services/relay-question-context.mjs +11 -0
- package/server/services/relay-restart-picture.mjs +67 -22
- package/server/services/remote-inbox-watch-service.mjs +204 -0
- package/server/services/remote-relay-caller-context.mjs +80 -7
- package/server/services/remote-relay-dispatcher.mjs +101 -19
- package/server/services/restart-resume-service.mjs +5 -2
- package/server/services/session-activity-tracker.mjs +1 -1
- package/server/services/session-launch-mutex-service.mjs +23 -0
- package/server/services/session-worker-launch-service.mjs +12 -3
- package/server/services/session-worker-registry-service.mjs +36 -0
- package/server/services/session-worker-stop-service.mjs +27 -6
- package/server/services/session-worker-websocket-service.mjs +40 -16
- package/server/services/token-usage-service.mjs +430 -0
- package/server/services/update-install-service.mjs +13 -0
- package/server/services/usage-limit-pause-service.mjs +8 -1
- package/server/services/worker-recycle-service.mjs +772 -0
- package/server/services/worker-replacement.mjs +197 -0
- package/server/services/worker-scope.mjs +77 -0
- package/shared/build-id.mjs +128 -0
- package/shared/plan-board-actions.mjs +130 -0
- package/shared/plan-only.mjs +43 -0
- package/shared/relay-asks.mjs +3 -0
- package/shared/relay-events.mjs +38 -4
- package/shared/remote-relay-contract.mjs +66 -11
- package/shared/turn-token-usage.mjs +144 -0
- package/shared/worker-runtime/heartbeat.mjs +5 -0
- package/shared/worker-runtime/simple-steering-state.mjs +38 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +23 -0
- package/shared/worker-runtime/working-directory.mjs +1 -1
|
@@ -107,6 +107,7 @@ import {
|
|
|
107
107
|
openOriginConversation,
|
|
108
108
|
openConversationGroupPicker,
|
|
109
109
|
toggleConversationGroupFold,
|
|
110
|
+
toggleAllConversationGroupFolds,
|
|
110
111
|
isConversationWorking,
|
|
111
112
|
} from './journal-view.js';
|
|
112
113
|
import {
|
|
@@ -153,8 +154,8 @@ import {
|
|
|
153
154
|
import { loadKeepingLiveRows } from './transcript-reload-freshness.mjs';
|
|
154
155
|
import { backgroundTasksSnapshotMark } from './background-tasks-view.mjs';
|
|
155
156
|
import { newerPageReachesEnd } from './transcript-island.mjs';
|
|
156
|
-
import { initPinnedMessagesView, setPinsConversation } from './pinned-messages-view.mjs';
|
|
157
|
-
import { initHiddenMessagesView, setHiddenMessagesConversation } from './hidden-messages-view.mjs';
|
|
157
|
+
import { initPinnedMessagesView, openPinnedMessagesModal, setPinsConversation } from './pinned-messages-view.mjs';
|
|
158
|
+
import { initHiddenMessagesView, openHiddenMessagesModal, setHiddenMessagesConversation } from './hidden-messages-view.mjs';
|
|
158
159
|
import { bindChatSelectionGuard, chatSelectionGuard, isChatInteractionHeld } from './selection-guard.mjs';
|
|
159
160
|
import { loadRepoBrowserTree, openRepoBrowser, closeRepoBrowser, setRepoBrowserSessionInfo, resetWorkspaceRepoBrowserForRootChange } from './attachments-view.js';
|
|
160
161
|
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';
|
|
@@ -237,11 +238,26 @@ import {
|
|
|
237
238
|
isInstalledAppMode,
|
|
238
239
|
} from './pwa-install.js';
|
|
239
240
|
import { armBackGuardOnFirstInteraction } from './back-guard.mjs';
|
|
241
|
+
import {
|
|
242
|
+
armSessionHistoryOnFirstInteraction,
|
|
243
|
+
initSessionHistory,
|
|
244
|
+
sessionHistoryConversation,
|
|
245
|
+
sessionHistoryEntryAgain,
|
|
246
|
+
syncSessionDocumentTitle,
|
|
247
|
+
} from './session-history-ui.js';
|
|
248
|
+
// What back closes, beyond the closers imported elsewhere in this file.
|
|
249
|
+
import { closeAnnotateEditor } from './annotate-editor.js';
|
|
250
|
+
import { closeEmojiPicker } from './emoji-view.js';
|
|
251
|
+
import { closeChangeCwdMenu } from './cwd-picker.js';
|
|
252
|
+
import { closeRelayWatchPanel } from './relay-event-subscriptions-ui.js';
|
|
253
|
+
import { closeRelaySchedulePanel } from './relay-schedules-ui.js';
|
|
254
|
+
import { isInboxViewActive } from './inbox-ui.js';
|
|
240
255
|
import { initScrollbarHover } from './scrollbar-hover.mjs';
|
|
241
256
|
import {
|
|
242
257
|
autoCompactWindowNotes,
|
|
243
258
|
renderAutoCompactControlHtml,
|
|
244
259
|
renderAgentSessionsControlHtml,
|
|
260
|
+
renderPlanOnlyControlHtml,
|
|
245
261
|
renderAutoCompactNotesHtml,
|
|
246
262
|
renderContextUsageHtml,
|
|
247
263
|
renderThinkingControlHtml,
|
|
@@ -254,7 +270,8 @@ import {
|
|
|
254
270
|
formatAutoCompactWindowLabel,
|
|
255
271
|
} from './auto-compact-window-options.mjs';
|
|
256
272
|
import { renderPlanUsageHtml, planUsageSubtitle, normalizeUsageProvider } from './plan-usage-view.mjs';
|
|
257
|
-
import {
|
|
273
|
+
import { revealSelectedTabs, watchTabStripFades } from './tab-strip-fade.mjs';
|
|
274
|
+
import { FONT_SCALE_CHANGE_EVENT, getUiScale, initFontScaling, updateFontScaleFromSelect } from './font-scaling.js';
|
|
258
275
|
import { initClientDiagnostics, recordStatusEvent } from './status-store.mjs';
|
|
259
276
|
import { isStatusViewActive, toggleStatusView } from './status-view.mjs';
|
|
260
277
|
import { installExternalLinkPolicy, openExternalNavigation } from './external-link-policy.mjs';
|
|
@@ -383,6 +400,8 @@ import {
|
|
|
383
400
|
import { applyPendingActionsFromStatus, initHostSuspendUi } from './host-suspend-ui.js';
|
|
384
401
|
import { applyUsageLimitFromStatus, initUsageLimitUi } from './usage-limit-ui.js';
|
|
385
402
|
import { initClaudeBillingToggle, renderClaudeBillingToggle } from './claude-billing-toggle-ui.js';
|
|
403
|
+
import { closeComposerSettings, initComposerSettings, scheduleComposerSettingsSync } from './composer-settings-ui.js';
|
|
404
|
+
import { initPhoneLandscape } from './phone-landscape-ui.js';
|
|
386
405
|
import {
|
|
387
406
|
startClaudeRelogin,
|
|
388
407
|
submitClaudeLoginCodeFromInput,
|
|
@@ -410,16 +429,43 @@ import {
|
|
|
410
429
|
import { isCurrentConversationClaudeCloud, openCurrentCloudSession, syncClaudeCloudConversationUi } from './claude-cloud-conversation-ui.js';
|
|
411
430
|
import {
|
|
412
431
|
CHAT_ACTIONS_MENU_ENTRY_IDS,
|
|
432
|
+
CHAT_ACTIONS_MENU_META_CHIP_IDS,
|
|
413
433
|
CHAT_ACTIONS_MENU_META_IDS,
|
|
434
|
+
chatActionsMenuMetaChipLabels,
|
|
435
|
+
resolveChatActionsMenuPlacement,
|
|
414
436
|
resolveChatActionsMenuState,
|
|
415
437
|
} from './chat-actions-menu-state.mjs';
|
|
438
|
+
import {
|
|
439
|
+
HEADER_ACTIONS_CHANGE_EVENT,
|
|
440
|
+
HEADER_BANNER_OWN_ROW_QUERY,
|
|
441
|
+
HEADER_PRIORITY_ACTIONS,
|
|
442
|
+
HEADER_TITLE_MIN_REM,
|
|
443
|
+
fitHeaderPriorityActions,
|
|
444
|
+
headerPriorityMenuIdsFor,
|
|
445
|
+
headerReservedWidth,
|
|
446
|
+
resolveHeaderActionAvailability,
|
|
447
|
+
resolveQuestionBannerMode,
|
|
448
|
+
} from './header-priority-actions.mjs';
|
|
449
|
+
import { PHONE_LANDSCAPE_ATTRIBUTE, PHONE_LANDSCAPE_CHANGE_EVENT } from './phone-landscape.mjs';
|
|
416
450
|
import { subscribeProviderReadiness, updateProviderReadinessInputs } from './provider-readiness.mjs';
|
|
417
451
|
import { hasActiveShare, refreshShares, subscribeShares } from './share-state.js';
|
|
418
452
|
import { openStopSharingConfirmation } from './shared-conversations-settings.js';
|
|
419
453
|
import { showAbout, syncAboutMenuLabel } from './about-ui.js';
|
|
420
|
-
import { syncConversationOriginHeader } from './conversation-origin-ui.js';
|
|
421
|
-
import {
|
|
422
|
-
|
|
454
|
+
import { conversationOriginMenuEntry, syncConversationOriginHeader } from './conversation-origin-ui.js';
|
|
455
|
+
import {
|
|
456
|
+
loadRelayEventSubscriptions,
|
|
457
|
+
onRelayEventSubscriptionsChanged,
|
|
458
|
+
openRelayWatchPanel,
|
|
459
|
+
relayWatchingChipText,
|
|
460
|
+
syncRelayEventHeader,
|
|
461
|
+
} from './relay-event-subscriptions-ui.js';
|
|
462
|
+
import {
|
|
463
|
+
loadRelaySchedules,
|
|
464
|
+
onRelaySchedulesChanged,
|
|
465
|
+
openRelaySchedulePanel,
|
|
466
|
+
relayScheduleChipText,
|
|
467
|
+
syncRelayScheduleHeader,
|
|
468
|
+
} from './relay-schedules-ui.js';
|
|
423
469
|
import { leaveInboxView, startInbox, toggleInboxView } from './inbox-ui.js';
|
|
424
470
|
import {
|
|
425
471
|
previewAgentSessionsMaxWait,
|
|
@@ -441,6 +487,7 @@ import {
|
|
|
441
487
|
dismissUpdateOutcome,
|
|
442
488
|
} from './update-ui.js';
|
|
443
489
|
import { shouldAutofocusComposerOnLoad } from './composer-keyboard.mjs';
|
|
490
|
+
import { planOnlyModeHint } from './plan-board-shared.mjs';
|
|
444
491
|
|
|
445
492
|
const MODEL_STORAGE_KEY = 'copilot_selected_model';
|
|
446
493
|
// The New Chat modal used to keep its own model key, so a selection made there
|
|
@@ -814,8 +861,9 @@ function initPushNotificationClientHooks() {
|
|
|
814
861
|
});
|
|
815
862
|
}
|
|
816
863
|
|
|
817
|
-
// ?push_conv=<id> (a notification tap) and ?conv=<id> (
|
|
818
|
-
//
|
|
864
|
+
// ?push_conv=<id> (a notification tap, consumed) and ?conv=<id> (the open
|
|
865
|
+
// session's address: a reload, a copied link, another relay's provenance
|
|
866
|
+
// badge) open that conversation at load.
|
|
819
867
|
function consumePushConversationDeepLink() {
|
|
820
868
|
return consumeConversationDeepLink();
|
|
821
869
|
}
|
|
@@ -929,27 +977,58 @@ function initNetworkLifecycleHandling() {
|
|
|
929
977
|
}
|
|
930
978
|
|
|
931
979
|
function syncThemeMenuLabel() {
|
|
980
|
+
const isLight = document.documentElement.getAttribute('data-theme') === 'light';
|
|
932
981
|
const button = document.getElementById('chat-menu-theme-toggle');
|
|
933
|
-
if (
|
|
982
|
+
if (button) button.textContent = isLight ? '🌙 Night mode' : '☀️ Day mode';
|
|
983
|
+
// The header twin: the glyph alone, the words in its tooltip.
|
|
984
|
+
const inline = document.getElementById('theme-toggle-btn');
|
|
985
|
+
if (inline) {
|
|
986
|
+
const label = isLight ? 'Night mode' : 'Day mode';
|
|
987
|
+
inline.textContent = isLight ? '🌙' : '☀️';
|
|
988
|
+
inline.title = label;
|
|
989
|
+
inline.setAttribute('aria-label', label);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function toggleThemeFromHeader() {
|
|
934
994
|
const isLight = document.documentElement.getAttribute('data-theme') === 'light';
|
|
935
|
-
|
|
995
|
+
updateTheme(isLight ? 'dark' : 'light');
|
|
996
|
+
syncThemeMenuLabel();
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// One priority+ action (header-priority-actions.mjs): its header button and
|
|
1000
|
+
// its ︙ twin run the same handler. A header button that already carries its
|
|
1001
|
+
// own binding (an inline onclick, a view module's listener) passes
|
|
1002
|
+
// `inline: false`, and only the twin is bound here.
|
|
1003
|
+
function bindHeaderPriorityAction(key, run, { inline = true } = {}) {
|
|
1004
|
+
const action = HEADER_PRIORITY_ACTIONS.find((entry) => entry.key === key);
|
|
1005
|
+
if (!action) return;
|
|
1006
|
+
const inlineBtn = inline ? document.getElementById(action.inlineId) : null;
|
|
1007
|
+
if (inlineBtn && inlineBtn.dataset.headerActionBound !== '1') {
|
|
1008
|
+
inlineBtn.dataset.headerActionBound = '1';
|
|
1009
|
+
inlineBtn.addEventListener('click', (event) => {
|
|
1010
|
+
event.preventDefault();
|
|
1011
|
+
event.stopPropagation();
|
|
1012
|
+
run(event);
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
const twin = document.getElementById(action.menuId);
|
|
1016
|
+
if (twin && twin.dataset.headerActionBound !== '1') {
|
|
1017
|
+
twin.dataset.headerActionBound = '1';
|
|
1018
|
+
bindMenuAction(twin, (event) => {
|
|
1019
|
+
event.preventDefault();
|
|
1020
|
+
event.stopPropagation();
|
|
1021
|
+
lockChatActionsMenuShield(350);
|
|
1022
|
+
closeChatActionsMenu();
|
|
1023
|
+
run(event);
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
936
1026
|
}
|
|
937
1027
|
|
|
938
1028
|
function initThemeMenuToggle() {
|
|
939
|
-
const themeBtn = document.getElementById('chat-menu-theme-toggle');
|
|
940
|
-
if (!themeBtn) return;
|
|
941
|
-
themeBtn.hidden = false;
|
|
942
1029
|
syncThemeMenuLabel();
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
bindMenuAction(themeBtn, (event) => {
|
|
946
|
-
event.preventDefault();
|
|
947
|
-
event.stopPropagation();
|
|
948
|
-
const isLight = document.documentElement.getAttribute('data-theme') === 'light';
|
|
949
|
-
updateTheme(isLight ? 'dark' : 'light');
|
|
950
|
-
syncThemeMenuLabel();
|
|
951
|
-
closeChatActionsMenu();
|
|
952
|
-
});
|
|
1030
|
+
// Day/Night is the shared view's one action too, so it is bound in both.
|
|
1031
|
+
bindHeaderPriorityAction('theme', () => toggleThemeFromHeader());
|
|
953
1032
|
}
|
|
954
1033
|
|
|
955
1034
|
function disableSharedControl(element, {
|
|
@@ -1023,18 +1102,16 @@ function applySharedReaderUi() {
|
|
|
1023
1102
|
if (effortSelect) effortSelect.hidden = true;
|
|
1024
1103
|
if (modeSelect) modeSelect.hidden = true;
|
|
1025
1104
|
// One source for the menu: the resolver's shared branch (Day/Night only,
|
|
1026
|
-
//
|
|
1027
|
-
// does not know
|
|
1105
|
+
// in the header or in ︙, whichever fits). The loop stays as a guard for
|
|
1106
|
+
// entries the resolver does not know; the sync after it decides Day/Night
|
|
1107
|
+
// and the trigger.
|
|
1028
1108
|
applyChatActionsMenuState(resolveChatActionsMenuState({ sharedMode: true }));
|
|
1029
1109
|
if (chatMenu && themeBtn) {
|
|
1030
1110
|
for (const child of Array.from(chatMenu.children)) {
|
|
1031
1111
|
if (child !== themeBtn) hideSharedMenuEntry(child);
|
|
1032
1112
|
}
|
|
1033
|
-
themeBtn.hidden = false;
|
|
1034
|
-
themeBtn.removeAttribute('aria-hidden');
|
|
1035
|
-
themeBtn.disabled = false;
|
|
1036
|
-
themeBtn.tabIndex = 0;
|
|
1037
1113
|
syncThemeMenuLabel();
|
|
1114
|
+
syncChatActionsMenuState();
|
|
1038
1115
|
} else if (chatMenuBtn) {
|
|
1039
1116
|
chatMenuBtn.hidden = true;
|
|
1040
1117
|
}
|
|
@@ -1458,6 +1535,8 @@ function syncSessionLockNote({ pinnedModel = '' } = {}) {
|
|
|
1458
1535
|
}
|
|
1459
1536
|
// The billing toggle sits under the note and follows the same chat.
|
|
1460
1537
|
renderClaudeBillingToggle();
|
|
1538
|
+
// The composer's label shows the lock and the model.
|
|
1539
|
+
scheduleComposerSettingsSync();
|
|
1461
1540
|
}
|
|
1462
1541
|
|
|
1463
1542
|
function applyModelMetadataHardFail(message = '') {
|
|
@@ -2068,6 +2147,21 @@ function updateModeSelectorForProvider() {
|
|
|
2068
2147
|
select.value = modes.includes(selectedBefore)
|
|
2069
2148
|
? selectedBefore
|
|
2070
2149
|
: (modes.includes(FALLBACK_MODE) ? FALLBACK_MODE : modes[0]);
|
|
2150
|
+
syncModeSelectPlanOnlyHint();
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
// A plan-only session's mode select says so; the owner may still
|
|
2154
|
+
// pick any mode.
|
|
2155
|
+
function syncModeSelectPlanOnlyHint() {
|
|
2156
|
+
const select = document.getElementById('mode-select');
|
|
2157
|
+
if (!select) return;
|
|
2158
|
+
const planOnly = conversations[String(currentConvId || '').trim()]?.planOnly === true;
|
|
2159
|
+
const hint = planOnlyModeHint(planOnly);
|
|
2160
|
+
select.title = hint.title;
|
|
2161
|
+
if (planOnly) select.dataset.planOnly = 'true';
|
|
2162
|
+
else delete select.dataset.planOnly;
|
|
2163
|
+
const planOption = Array.from(select.options).find((option) => option.value === 'plan');
|
|
2164
|
+
if (planOption) planOption.textContent = planOnly ? hint.planLabel : (RELAY_MODE_LABELS.plan || hint.planLabel);
|
|
2071
2165
|
}
|
|
2072
2166
|
|
|
2073
2167
|
function modeOptions() {
|
|
@@ -2086,6 +2180,8 @@ async function persistCurrentConversationPreferences() {
|
|
|
2086
2180
|
if (!modeSelect || !modelSelect) return;
|
|
2087
2181
|
const mode = String(modeSelect.value || '').trim() || FALLBACK_MODE;
|
|
2088
2182
|
const model = String(modelSelect.value || '').trim();
|
|
2183
|
+
// Steer or Queue: a mode across plan from the live turn's queues.
|
|
2184
|
+
syncComposerButtonState();
|
|
2089
2185
|
// The raw value, not selectedReasoningEffortValue(): a model with no reasoning
|
|
2090
2186
|
// options leaves the selector empty, and that helper's 'none' fallback would
|
|
2091
2187
|
// record an effort the user never picked when they only changed the mode.
|
|
@@ -2203,6 +2299,9 @@ function applyConversationPreferences({
|
|
|
2203
2299
|
} finally {
|
|
2204
2300
|
suppressConversationPreferenceSync = false;
|
|
2205
2301
|
}
|
|
2302
|
+
syncModeSelectPlanOnlyHint();
|
|
2303
|
+
// Values set here fire no change event: the composer's label reads them.
|
|
2304
|
+
scheduleComposerSettingsSync();
|
|
2206
2305
|
// Nothing is written to the shared "last used" storage here. Applying a
|
|
2207
2306
|
// conversation's stored preferences is not a choice: it runs on every open,
|
|
2208
2307
|
// on the ~1s live poll, on a catalog refresh and on another client's edit,
|
|
@@ -2867,7 +2966,7 @@ function renderModelVariantCatalogBody() {
|
|
|
2867
2966
|
}
|
|
2868
2967
|
const visibleProviderOrder = providerOrder.filter((providerKey) => providerBelongsToTab(providerKey, modelVariantCatalogProviderTab));
|
|
2869
2968
|
const providerTabButtons = `
|
|
2870
|
-
<div class="summary-tab-strip" style="display:flex;flex-wrap:wrap;gap:
|
|
2969
|
+
<div class="summary-tab-strip" style="display:flex;flex-wrap:wrap;gap:calc(4px * var(--ui-scale)) calc(5px * var(--ui-scale));align-items:center;">
|
|
2871
2970
|
<button type="button" class="summary-btn model-provider-tab${modelVariantCatalogProviderTab === 'copilot' ? ' active' : ''}" data-model-provider-tab="copilot">Copilot</button>
|
|
2872
2971
|
<button type="button" class="summary-btn model-provider-tab${modelVariantCatalogProviderTab === 'openai' ? ' active' : ''}" data-model-provider-tab="openai" ${hasOpenAITab ? '' : 'disabled'}>OpenAI</button>
|
|
2873
2972
|
<button type="button" class="summary-btn model-provider-tab${modelVariantCatalogProviderTab === 'anthropic' ? ' active' : ''}" data-model-provider-tab="anthropic" ${hasAnthropicTab ? '' : 'disabled'}>Claude SDK</button>
|
|
@@ -2986,7 +3085,7 @@ function renderModelVariantCatalogBody() {
|
|
|
2986
3085
|
`;
|
|
2987
3086
|
}).join('');
|
|
2988
3087
|
const bodyHtml = `
|
|
2989
|
-
<div style="display:flex;flex-direction:column;gap:
|
|
3088
|
+
<div style="display:flex;flex-direction:column;gap:calc(5px * var(--ui-scale))">
|
|
2990
3089
|
${providerTabButtons}
|
|
2991
3090
|
<div style="font-size:0.8rem;color:var(--muted)">
|
|
2992
3091
|
Saved globally for this relay. Refreshed: <strong>${escHtml(refreshedLabel)}</strong>
|
|
@@ -2998,12 +3097,12 @@ function renderModelVariantCatalogBody() {
|
|
|
2998
3097
|
Each model shows all supported reasoning efforts above its selectable variants.
|
|
2999
3098
|
</div>
|
|
3000
3099
|
${warnings.map((line) => `<div style="font-size:0.78rem;color:var(--warn,#f7c873)">${line}</div>`).join('')}
|
|
3001
|
-
<div style="display:grid;gap:
|
|
3100
|
+
<div style="display:grid;gap:calc(5px * var(--ui-scale))">${groupsHtml || '<div style="color:var(--muted)">No models discovered yet. Click Refresh.</div>'}</div>
|
|
3002
3101
|
</div>
|
|
3003
3102
|
`;
|
|
3004
3103
|
renderSummaryModalContent({
|
|
3005
3104
|
title: '🤗 Select Models',
|
|
3006
|
-
subtitle:
|
|
3105
|
+
subtitle: "Choose model variants shown under Model in the composer's ⚙ settings",
|
|
3007
3106
|
bodyHtml,
|
|
3008
3107
|
refresh: async () => {
|
|
3009
3108
|
const refreshed = await refreshModelVariantCatalog();
|
|
@@ -3257,6 +3356,7 @@ function selectUsageTab(root, provider) {
|
|
|
3257
3356
|
for (const panel of root.querySelectorAll('[data-usage-panel]')) {
|
|
3258
3357
|
panel.hidden = panel.dataset.usagePanel !== target;
|
|
3259
3358
|
}
|
|
3359
|
+
revealSelectedTabs(root);
|
|
3260
3360
|
usageTabProviderOverride = target;
|
|
3261
3361
|
if (!usageCardIsFresh(target)) {
|
|
3262
3362
|
refreshUsageProvider(target).catch(() => {});
|
|
@@ -3316,6 +3416,10 @@ function renderUsageReport(report, account, { updating = '' } = {}) {
|
|
|
3316
3416
|
kind: 'usage',
|
|
3317
3417
|
});
|
|
3318
3418
|
if (updating) markUsageCardUpdating(updating, true);
|
|
3419
|
+
// A new strip each render: its selected tab in view, its fade marked.
|
|
3420
|
+
watchTabStripFades();
|
|
3421
|
+
const body = document.getElementById('summary-modal-body');
|
|
3422
|
+
if (body) revealSelectedTabs(body);
|
|
3319
3423
|
return true;
|
|
3320
3424
|
}
|
|
3321
3425
|
|
|
@@ -3536,6 +3640,12 @@ function initAgentSessionsControl() {
|
|
|
3536
3640
|
if (!body || body.dataset.agentSessionsControlBound === 'true') return;
|
|
3537
3641
|
body.dataset.agentSessionsControlBound = 'true';
|
|
3538
3642
|
body.addEventListener('change', async (event) => {
|
|
3643
|
+
// Plan-only, for every session.
|
|
3644
|
+
const planOnlyBox = event.target?.closest?.('#ctx-plan-only');
|
|
3645
|
+
if (planOnlyBox) {
|
|
3646
|
+
await changePlanOnly(planOnlyBox);
|
|
3647
|
+
return;
|
|
3648
|
+
}
|
|
3539
3649
|
const box = event.target?.closest?.('#ctx-may-create-sessions');
|
|
3540
3650
|
if (!box) return;
|
|
3541
3651
|
const convId = String(box.dataset.conversationId || '').trim();
|
|
@@ -3562,6 +3672,31 @@ function initAgentSessionsControl() {
|
|
|
3562
3672
|
});
|
|
3563
3673
|
}
|
|
3564
3674
|
|
|
3675
|
+
async function changePlanOnly(box) {
|
|
3676
|
+
const convId = String(box.dataset.conversationId || '').trim();
|
|
3677
|
+
if (!convId || agentSessionsControlUpdateInFlight) return;
|
|
3678
|
+
const wanted = box.checked === true;
|
|
3679
|
+
agentSessionsControlUpdateInFlight = true;
|
|
3680
|
+
box.disabled = true;
|
|
3681
|
+
try {
|
|
3682
|
+
const response = await updateConversationPreferences(convId, { clientId: CLIENT_ID, planOnly: wanted });
|
|
3683
|
+
if (!response) throw new Error('The relay refused the change');
|
|
3684
|
+
const stored = response.planOnly === true;
|
|
3685
|
+
if (conversations[convId]) conversations[convId] = { ...conversations[convId], planOnly: stored };
|
|
3686
|
+
box.checked = stored;
|
|
3687
|
+
if (convId === String(currentConvId || '').trim()) syncModeSelectPlanOnlyHint();
|
|
3688
|
+
showTransientRelayNotice(stored
|
|
3689
|
+
? 'Plan only: agents can no longer take this session out of plan mode.'
|
|
3690
|
+
: 'Plan only is off: agents may switch this session to another mode.');
|
|
3691
|
+
} catch (error) {
|
|
3692
|
+
box.checked = !wanted;
|
|
3693
|
+
showTransientRelayNotice(`Could not change the setting: ${error?.message || error}`);
|
|
3694
|
+
} finally {
|
|
3695
|
+
box.disabled = false;
|
|
3696
|
+
agentSessionsControlUpdateInFlight = false;
|
|
3697
|
+
}
|
|
3698
|
+
}
|
|
3699
|
+
|
|
3565
3700
|
function initThinkingControl() {
|
|
3566
3701
|
const body = document.getElementById('summary-modal-body');
|
|
3567
3702
|
if (!body || body.dataset.thinkingBound === '1') return;
|
|
@@ -3741,9 +3876,11 @@ async function loadContextSummaryAndRender(convId) {
|
|
|
3741
3876
|
// sessions? Only meaningful there, so only shown there.
|
|
3742
3877
|
const settingsConversationId = String(payload.resolvedConversationId || trimmedConvId || '').trim();
|
|
3743
3878
|
const settingsConversation = settingsConversationId ? conversations[settingsConversationId] : null;
|
|
3744
|
-
const agentSessionsHtml = settingsConversation?.origin
|
|
3879
|
+
const agentSessionsHtml = (settingsConversation?.origin
|
|
3745
3880
|
? renderAgentSessionsControlHtml({ conversationId: settingsConversationId, mayCreateSessions: settingsConversation.mayCreateSessions === true })
|
|
3746
|
-
: ''
|
|
3881
|
+
: '')
|
|
3882
|
+
// Plan-only, for every session.
|
|
3883
|
+
+ (settingsConversationId ? renderPlanOnlyControlHtml({ conversationId: settingsConversationId, planOnly: settingsConversation?.planOnly === true }) : '');
|
|
3747
3884
|
const detailText = String(payload.text || '').trim();
|
|
3748
3885
|
// The structured breakdown is the answer; the runtime's own text dump stays
|
|
3749
3886
|
// available underneath for the details it carries that categories don't.
|
|
@@ -3767,11 +3904,15 @@ async function showUsage() {
|
|
|
3767
3904
|
// A fresh open follows the current session's provider again; only a click
|
|
3768
3905
|
// inside the modal pins a tab (for the Refresh button's sake).
|
|
3769
3906
|
usageTabProviderOverride = null;
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
3907
|
+
// The header button and its ︙ twin both show the wait.
|
|
3908
|
+
const usageButtons = ['chat-menu-usage', 'usage-btn'].map((id) => document.getElementById(id)).filter(Boolean);
|
|
3909
|
+
const setUsageBusy = (busy) => {
|
|
3910
|
+
for (const button of usageButtons) {
|
|
3911
|
+
button.textContent = busy ? '⏳' : (button.id === 'chat-menu-usage' ? '📊 Check Usage' : '📊');
|
|
3912
|
+
button.disabled = busy;
|
|
3913
|
+
}
|
|
3914
|
+
};
|
|
3915
|
+
setUsageBusy(true);
|
|
3775
3916
|
// What was shown last time is shown again at once; only the open tab's
|
|
3776
3917
|
// provider is read live, and its card is swapped in when the answer comes.
|
|
3777
3918
|
const kept = lastUsageReport || readStoredUsageReport();
|
|
@@ -3785,10 +3926,7 @@ async function showUsage() {
|
|
|
3785
3926
|
});
|
|
3786
3927
|
try {
|
|
3787
3928
|
if (kept && renderUsageReport(kept, null, { updating: activeProvider })) {
|
|
3788
|
-
|
|
3789
|
-
btn.textContent = btn.id === 'chat-menu-usage' ? '📊 Check Usage' : '📊';
|
|
3790
|
-
btn.disabled = false;
|
|
3791
|
-
}
|
|
3929
|
+
setUsageBusy(false);
|
|
3792
3930
|
await refreshUsageProvider(activeProvider);
|
|
3793
3931
|
return;
|
|
3794
3932
|
}
|
|
@@ -3803,10 +3941,7 @@ async function showUsage() {
|
|
|
3803
3941
|
kind: 'usage',
|
|
3804
3942
|
});
|
|
3805
3943
|
} finally {
|
|
3806
|
-
|
|
3807
|
-
btn.textContent = btn.id === 'chat-menu-usage' ? '📊 Check Usage' : '📊';
|
|
3808
|
-
btn.disabled = false;
|
|
3809
|
-
}
|
|
3944
|
+
setUsageBusy(false);
|
|
3810
3945
|
}
|
|
3811
3946
|
}
|
|
3812
3947
|
|
|
@@ -3860,7 +3995,7 @@ function renderSessionInstructionDocs(docs) {
|
|
|
3860
3995
|
return '<div class="summary-loading">No session instruction files were found.</div>';
|
|
3861
3996
|
}
|
|
3862
3997
|
|
|
3863
|
-
return `<div style="display:flex;flex-direction:column;gap:
|
|
3998
|
+
return `<div style="display:flex;flex-direction:column;gap:calc(6px * var(--ui-scale))">${
|
|
3864
3999
|
items.map((doc) => {
|
|
3865
4000
|
const title = String(doc?.title || doc?.sessionId || 'Session instructions').trim();
|
|
3866
4001
|
const name = String(doc?.name || '').trim();
|
|
@@ -3870,13 +4005,13 @@ function renderSessionInstructionDocs(docs) {
|
|
|
3870
4005
|
const updatedAt = String(doc?.updatedAt || '').trim();
|
|
3871
4006
|
const content = escHtml(String(doc?.content || '').trim());
|
|
3872
4007
|
return `
|
|
3873
|
-
<details open style="border:1px solid var(--border);border-radius:10px;background:var(--bg3);padding:
|
|
4008
|
+
<details open style="border:1px solid var(--border);border-radius:calc(10px * var(--ui-scale));background:var(--bg3);padding:calc(5px * var(--ui-scale)) calc(7px * var(--ui-scale))">
|
|
3874
4009
|
<summary style="cursor:pointer;font-weight:600;outline:none">${escHtml(title)}</summary>
|
|
3875
|
-
<div style="margin-top:
|
|
4010
|
+
<div style="margin-top:calc(3px * var(--ui-scale));font-size:0.78rem;color:var(--muted)">
|
|
3876
4011
|
${escHtml(summary || sessionId)}
|
|
3877
4012
|
${updatedAt ? ` · ${escHtml(updatedAt)}` : ''}
|
|
3878
4013
|
</div>
|
|
3879
|
-
<pre style="margin-top:
|
|
4014
|
+
<pre style="margin-top:calc(5px * var(--ui-scale));white-space:pre-wrap;word-break:break-word">${content}</pre>
|
|
3880
4015
|
</details>
|
|
3881
4016
|
`;
|
|
3882
4017
|
}).join('')
|
|
@@ -4448,12 +4583,321 @@ function applyChatActionsMenuState(state) {
|
|
|
4448
4583
|
}
|
|
4449
4584
|
}
|
|
4450
4585
|
|
|
4451
|
-
//
|
|
4586
|
+
// ── Priority+ header actions ──
|
|
4587
|
+
// Pins, hidden, search, context, Check Usage, Day/Night and fullscreen sit in
|
|
4588
|
+
// the header while it has room and fall back into ︙ in reverse priority
|
|
4589
|
+
// order (header-priority-actions.mjs decides; this measures and applies).
|
|
4590
|
+
// The twin ids the last fit put into ︙; null before the header was measured,
|
|
4591
|
+
// which keeps the menu's old reach (Day/Night, Check Usage).
|
|
4592
|
+
let headerPriorityMenuIds = null;
|
|
4593
|
+
// The inputs the last full fit measured with (headerFitSignature); a pass
|
|
4594
|
+
// whose inputs are the same keeps the placement and measures nothing.
|
|
4595
|
+
let headerPriorityFitSignature = '';
|
|
4596
|
+
|
|
4597
|
+
function headerPriorityActionAvailability() {
|
|
4598
|
+
const pins = document.getElementById('pinned-messages-btn');
|
|
4599
|
+
const hiddenBtn = document.getElementById('hidden-messages-btn');
|
|
4600
|
+
const fullscreen = document.getElementById('fullscreen-btn');
|
|
4601
|
+
return resolveHeaderActionAvailability({
|
|
4602
|
+
sharedMode: isSharedReaderMode(),
|
|
4603
|
+
pinCount: Number(pins?.dataset.count || 0),
|
|
4604
|
+
hiddenCount: Number(hiddenBtn?.dataset.count || 0),
|
|
4605
|
+
fullscreenAvailable: fullscreen?.dataset.headerAvailable === '1',
|
|
4606
|
+
});
|
|
4607
|
+
}
|
|
4608
|
+
|
|
4609
|
+
function cssPixels(value) {
|
|
4610
|
+
const number = parseFloat(value);
|
|
4611
|
+
return Number.isFinite(number) ? number : 0;
|
|
4612
|
+
}
|
|
4613
|
+
|
|
4614
|
+
function isHeaderBannerOnOwnRow() {
|
|
4615
|
+
return typeof window.matchMedia === 'function' && window.matchMedia(HEADER_BANNER_OWN_ROW_QUERY).matches;
|
|
4616
|
+
}
|
|
4617
|
+
|
|
4618
|
+
// The question banner's two widths, measured with its compact chip shown
|
|
4619
|
+
// and then its full text: the chip ("❓ N", what the fit reserves) and the
|
|
4620
|
+
// least the full text needs ("❓ N open questions" plus padding and border;
|
|
4621
|
+
// the "latest in …" tail may end in an ellipsis). Zero when it does not show.
|
|
4622
|
+
// Leaves the banner in its full form; the caller sets the mode.
|
|
4623
|
+
function measureQuestionBanner(banner) {
|
|
4624
|
+
if (!banner) return { visible: false, compact: 0, fullMin: 0 };
|
|
4625
|
+
banner.removeAttribute('data-compact');
|
|
4626
|
+
if (!banner.offsetWidth) return { visible: false, compact: 0, fullMin: 0 };
|
|
4627
|
+
const count = banner.querySelector('.pending-question-count');
|
|
4628
|
+
const style = getComputedStyle(banner);
|
|
4629
|
+
const frame = cssPixels(style.paddingLeft) + cssPixels(style.paddingRight)
|
|
4630
|
+
+ cssPixels(style.borderLeftWidth) + cssPixels(style.borderRightWidth);
|
|
4631
|
+
// Rounded up: a fraction short would cut the last letter.
|
|
4632
|
+
const fullMin = Math.ceil((count ? count.getBoundingClientRect().width : banner.scrollWidth) + frame);
|
|
4633
|
+
banner.setAttribute('data-compact', '');
|
|
4634
|
+
const compact = Math.ceil(banner.getBoundingClientRect().width);
|
|
4635
|
+
banner.removeAttribute('data-compact');
|
|
4636
|
+
return { visible: true, compact, fullMin };
|
|
4637
|
+
}
|
|
4638
|
+
|
|
4639
|
+
// The width of everything on the header's first row that never overflows
|
|
4640
|
+
// (headerReservedWidth): its padding, ☰, the title's minimum, ︙, the
|
|
4641
|
+
// question banner's compact chip while it shares that row, and the flex gaps
|
|
4642
|
+
// between them. Read on every full pass: all of it moves with --font-scale.
|
|
4643
|
+
function headerFixedWidth(header, bannerCompactWidth) {
|
|
4644
|
+
const style = getComputedStyle(header);
|
|
4645
|
+
const rootFont = cssPixels(getComputedStyle(document.documentElement).fontSize) || 16;
|
|
4646
|
+
return headerReservedWidth({
|
|
4647
|
+
padding: cssPixels(style.paddingLeft) + cssPixels(style.paddingRight),
|
|
4648
|
+
titleMin: HEADER_TITLE_MIN_REM * rootFont,
|
|
4649
|
+
toggle: document.getElementById('sidebar-toggle')?.offsetWidth || 0,
|
|
4650
|
+
banner: bannerCompactWidth,
|
|
4651
|
+
menu: document.getElementById('chat-actions-menu-wrap')?.offsetWidth || 0,
|
|
4652
|
+
gap: cssPixels(style.columnGap || style.gap),
|
|
4653
|
+
// On a phone the banner has a row of its own (the ≤680px header CSS).
|
|
4654
|
+
bannerOwnRow: isHeaderBannerOnOwnRow(),
|
|
4655
|
+
});
|
|
4656
|
+
}
|
|
4657
|
+
|
|
4658
|
+
// Everything the fit depends on, in one string: the header's width, the
|
|
4659
|
+
// title, the banner, the counts and availability, the theme (the glyphs'
|
|
4660
|
+
// widths), the scale and the layout. Cheap to read: no element is shown or
|
|
4661
|
+
// hidden for it.
|
|
4662
|
+
function headerFitSignature(header, availability) {
|
|
4663
|
+
const root = document.documentElement;
|
|
4664
|
+
const rootStyle = getComputedStyle(root);
|
|
4665
|
+
const title = document.getElementById('chat-title');
|
|
4666
|
+
const banner = document.getElementById('pending-question-banner');
|
|
4667
|
+
return JSON.stringify([
|
|
4668
|
+
header.clientWidth,
|
|
4669
|
+
rootStyle.fontSize,
|
|
4670
|
+
rootStyle.getPropertyValue('--ui-scale'),
|
|
4671
|
+
root.getAttribute('data-theme'),
|
|
4672
|
+
root.hasAttribute(PHONE_LANDSCAPE_ATTRIBUTE),
|
|
4673
|
+
isHeaderBannerOnOwnRow(),
|
|
4674
|
+
document.body.classList.contains('chat-title-editing'),
|
|
4675
|
+
title?.hidden, title?.textContent,
|
|
4676
|
+
banner?.className, banner?.hidden, banner?.textContent,
|
|
4677
|
+
document.getElementById('sidebar-toggle')?.hidden,
|
|
4678
|
+
document.getElementById('chat-actions-menu-btn')?.hidden,
|
|
4679
|
+
document.getElementById('pinned-messages-btn')?.dataset.count,
|
|
4680
|
+
document.getElementById('hidden-messages-btn')?.dataset.count,
|
|
4681
|
+
document.getElementById('fullscreen-btn')?.textContent,
|
|
4682
|
+
document.getElementById('theme-toggle-btn')?.textContent,
|
|
4683
|
+
availability,
|
|
4684
|
+
]);
|
|
4685
|
+
}
|
|
4686
|
+
|
|
4687
|
+
// The last placement, minus whatever is no longer available. Measures nothing.
|
|
4688
|
+
function keepHeaderPriorityPlacement(availability) {
|
|
4689
|
+
for (const action of HEADER_PRIORITY_ACTIONS) {
|
|
4690
|
+
const inline = document.getElementById(action.inlineId);
|
|
4691
|
+
if (inline && !availability[action.key]) inline.hidden = true;
|
|
4692
|
+
}
|
|
4693
|
+
if (Array.isArray(headerPriorityMenuIds)) {
|
|
4694
|
+
const availableMenuIds = new Set(headerPriorityMenuIdsFor(Object.keys(availability).filter((key) => availability[key])));
|
|
4695
|
+
headerPriorityMenuIds = headerPriorityMenuIds.filter((id) => availableMenuIds.has(id));
|
|
4696
|
+
}
|
|
4697
|
+
return headerPriorityMenuIds;
|
|
4698
|
+
}
|
|
4699
|
+
|
|
4700
|
+
// Shows inline what fits and hides the rest; returns the twin ids for ︙.
|
|
4701
|
+
// A header that cannot be measured (hidden, not laid out) keeps the last
|
|
4702
|
+
// placement, minus whatever is no longer available. Unless `force`, a pass
|
|
4703
|
+
// whose inputs are those of the last one keeps its placement too: the hide
|
|
4704
|
+
// and measure below runs only when something it reads has changed.
|
|
4705
|
+
function syncHeaderPriorityFit({ force = false } = {}) {
|
|
4706
|
+
const availability = headerPriorityActionAvailability();
|
|
4707
|
+
const header = document.getElementById('chat-header');
|
|
4708
|
+
const actions = document.getElementById('chat-header-actions');
|
|
4709
|
+
const measurable = !!(header && actions && header.clientWidth && typeof getComputedStyle === 'function');
|
|
4710
|
+
if (!measurable) return keepHeaderPriorityPlacement(availability);
|
|
4711
|
+
const signature = headerFitSignature(header, availability);
|
|
4712
|
+
if (!force && signature === headerPriorityFitSignature && Array.isArray(headerPriorityMenuIds)) {
|
|
4713
|
+
return keepHeaderPriorityPlacement(availability);
|
|
4714
|
+
}
|
|
4715
|
+
headerPriorityFitSignature = signature;
|
|
4716
|
+
// Un-hide every available button before measuring: a hidden one is 0 wide.
|
|
4717
|
+
// All of this runs in one task, so nothing is painted in between.
|
|
4718
|
+
const widths = {};
|
|
4719
|
+
for (const action of HEADER_PRIORITY_ACTIONS) {
|
|
4720
|
+
const inline = document.getElementById(action.inlineId);
|
|
4721
|
+
if (!inline) continue;
|
|
4722
|
+
inline.hidden = !availability[action.key];
|
|
4723
|
+
if (availability[action.key]) widths[action.key] = inline.offsetWidth;
|
|
4724
|
+
}
|
|
4725
|
+
const gap = cssPixels(getComputedStyle(actions).columnGap || getComputedStyle(actions).gap);
|
|
4726
|
+
// The question banner sits between the title and the actions. The fit
|
|
4727
|
+
// reserves its compact chip only, so an action goes into ︙ before the
|
|
4728
|
+
// banner could push it; then the banner takes what the row has left.
|
|
4729
|
+
const banner = document.getElementById('pending-question-banner');
|
|
4730
|
+
const phoneOwnRow = isHeaderBannerOnOwnRow();
|
|
4731
|
+
const phoneLandscape = document.documentElement.hasAttribute(PHONE_LANDSCAPE_ATTRIBUTE);
|
|
4732
|
+
const bannerSize = measureQuestionBanner(banner);
|
|
4733
|
+
const reserved = headerFixedWidth(header, phoneOwnRow ? 0 : bannerSize.compact);
|
|
4734
|
+
// Where ☰, the title's minimum, the chip and ︙ alone overflow the first
|
|
4735
|
+
// row (a narrow #main at a large zoom), the banner takes a row of its own,
|
|
4736
|
+
// as on a phone, and the fit no longer reserves it.
|
|
4737
|
+
const ownRow = phoneOwnRow || resolveQuestionBannerMode({
|
|
4738
|
+
visible: bannerSize.visible,
|
|
4739
|
+
phoneLandscape,
|
|
4740
|
+
room: header.clientWidth - reserved + bannerSize.compact,
|
|
4741
|
+
compact: bannerSize.compact,
|
|
4742
|
+
}) === 'row';
|
|
4743
|
+
const available = header.clientWidth - (ownRow ? headerFixedWidth(header, 0) : reserved);
|
|
4744
|
+
const fit = fitHeaderPriorityActions({
|
|
4745
|
+
widths,
|
|
4746
|
+
gap,
|
|
4747
|
+
available,
|
|
4748
|
+
availability,
|
|
4749
|
+
});
|
|
4750
|
+
const inlineKeys = new Set(fit.inline);
|
|
4751
|
+
for (const action of HEADER_PRIORITY_ACTIONS) {
|
|
4752
|
+
const inline = document.getElementById(action.inlineId);
|
|
4753
|
+
if (inline) inline.hidden = !inlineKeys.has(action.key);
|
|
4754
|
+
}
|
|
4755
|
+
if (banner) {
|
|
4756
|
+
const headerStyle = getComputedStyle(header);
|
|
4757
|
+
const mode = resolveQuestionBannerMode({
|
|
4758
|
+
visible: bannerSize.visible,
|
|
4759
|
+
ownRow,
|
|
4760
|
+
phoneLandscape,
|
|
4761
|
+
room: available - fit.used + bannerSize.compact,
|
|
4762
|
+
compact: bannerSize.compact,
|
|
4763
|
+
contentWidth: header.clientWidth - cssPixels(headerStyle.paddingLeft) - cssPixels(headerStyle.paddingRight),
|
|
4764
|
+
fullMin: bannerSize.fullMin,
|
|
4765
|
+
});
|
|
4766
|
+
banner.toggleAttribute('data-compact', mode === 'compact');
|
|
4767
|
+
// The phone's header CSS places the banner on its own row by itself; a
|
|
4768
|
+
// wider header needs this attribute for it.
|
|
4769
|
+
banner.toggleAttribute('data-own-row', mode === 'row' && !phoneOwnRow);
|
|
4770
|
+
}
|
|
4771
|
+
headerPriorityMenuIds = headerPriorityMenuIdsFor(fit.menu);
|
|
4772
|
+
// Measured at least once (the e2e helpers wait for it).
|
|
4773
|
+
actions.dataset.headerFit = '1';
|
|
4774
|
+
return headerPriorityMenuIds;
|
|
4775
|
+
}
|
|
4776
|
+
|
|
4777
|
+
// The pins and hidden twins carry the count their header button shows.
|
|
4778
|
+
function syncHeaderPriorityTwinLabels() {
|
|
4779
|
+
const label = (buttonId, twinId, text, icon) => {
|
|
4780
|
+
const twin = document.getElementById(twinId);
|
|
4781
|
+
if (!twin) return;
|
|
4782
|
+
const count = Number(document.getElementById(buttonId)?.dataset.count || 0);
|
|
4783
|
+
twin.textContent = count ? `${icon} ${text} (${count})` : `${icon} ${text}`;
|
|
4784
|
+
};
|
|
4785
|
+
label('pinned-messages-btn', 'chat-menu-pins', 'Pinned messages', '📍');
|
|
4786
|
+
label('hidden-messages-btn', 'chat-menu-hidden', 'Hidden from shared viewers', '🙈');
|
|
4787
|
+
}
|
|
4788
|
+
|
|
4789
|
+
// At most one fit per frame. A pass asked for by an observer of one of the
|
|
4790
|
+
// fit's inputs (`force`) measures; one asked for by a header sync (every list
|
|
4791
|
+
// paint ends in one) measures only if headerFitSignature says something
|
|
4792
|
+
// changed. Either way it runs before the next paint.
|
|
4793
|
+
let headerPriorityFrame = 0;
|
|
4794
|
+
let headerPriorityForce = false;
|
|
4795
|
+
function scheduleHeaderPrioritySync({ force = false } = {}) {
|
|
4796
|
+
if (force) headerPriorityForce = true;
|
|
4797
|
+
if (headerPriorityFrame) return;
|
|
4798
|
+
headerPriorityFrame = requestAnimationFrame(() => {
|
|
4799
|
+
headerPriorityFrame = 0;
|
|
4800
|
+
const forced = headerPriorityForce;
|
|
4801
|
+
headerPriorityForce = false;
|
|
4802
|
+
syncChatActionsMenuState({ fit: forced ? 'force' : 'now' });
|
|
4803
|
+
// The header moved or changed size: the open menu follows it.
|
|
4804
|
+
if (forced) placeChatActionsMenu();
|
|
4805
|
+
});
|
|
4806
|
+
}
|
|
4807
|
+
|
|
4808
|
+
// Every reason the fit can change: the header's own box (window, drawer,
|
|
4809
|
+
// font scale), the title, the banner, the theme (the glyph's width), a view's
|
|
4810
|
+
// counts or fullscreen verdict (HEADER_ACTIONS_CHANGE_EVENT), the font scale
|
|
4811
|
+
// and the phone turning. Conversation switches reach it through
|
|
4812
|
+
// syncChatTitleControls.
|
|
4813
|
+
function initHeaderPriorityActions({ sharedMode = false } = {}) {
|
|
4814
|
+
if (!sharedMode) {
|
|
4815
|
+
bindHeaderPriorityAction('pins', () => openPinnedMessagesModal(), { inline: false });
|
|
4816
|
+
bindHeaderPriorityAction('hidden', () => openHiddenMessagesModal(), { inline: false });
|
|
4817
|
+
bindHeaderPriorityAction('search', () => openMessageSearchModal(), { inline: false });
|
|
4818
|
+
bindHeaderPriorityAction('context', () => { void showContext(); }, { inline: false });
|
|
4819
|
+
bindHeaderPriorityAction('usage', () => {
|
|
4820
|
+
showUsage().catch((error) => {
|
|
4821
|
+
alert(error?.message || 'Failed to load usage');
|
|
4822
|
+
});
|
|
4823
|
+
});
|
|
4824
|
+
bindHeaderPriorityAction('fullscreen', () => { void toggleFullscreen(); }, { inline: false });
|
|
4825
|
+
}
|
|
4826
|
+
if (window.__headerPriorityObserversBound) return;
|
|
4827
|
+
window.__headerPriorityObserversBound = true;
|
|
4828
|
+
const forced = () => scheduleHeaderPrioritySync({ force: true });
|
|
4829
|
+
const header = document.getElementById('chat-header');
|
|
4830
|
+
if (header && typeof ResizeObserver === 'function') {
|
|
4831
|
+
new ResizeObserver(forced).observe(header);
|
|
4832
|
+
} else {
|
|
4833
|
+
window.addEventListener('resize', forced);
|
|
4834
|
+
}
|
|
4835
|
+
// The open ︙ menu stays inside the window (a turn, the keyboard).
|
|
4836
|
+
window.addEventListener('resize', () => placeChatActionsMenu());
|
|
4837
|
+
if (typeof MutationObserver === 'function') {
|
|
4838
|
+
new MutationObserver(forced)
|
|
4839
|
+
.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
|
4840
|
+
const title = document.getElementById('chat-title');
|
|
4841
|
+
if (title) {
|
|
4842
|
+
new MutationObserver(forced)
|
|
4843
|
+
.observe(title, { childList: true, characterData: true, subtree: true });
|
|
4844
|
+
}
|
|
4845
|
+
// The pending-question banner shares the first row on a wide screen: it
|
|
4846
|
+
// takes room when it shows (on a phone it has a row of its own).
|
|
4847
|
+
const banner = document.getElementById('pending-question-banner');
|
|
4848
|
+
if (banner) {
|
|
4849
|
+
new MutationObserver(forced)
|
|
4850
|
+
.observe(banner, { attributes: true, attributeFilter: ['class', 'hidden'], childList: true, characterData: true, subtree: true });
|
|
4851
|
+
}
|
|
4852
|
+
}
|
|
4853
|
+
window.addEventListener(HEADER_ACTIONS_CHANGE_EVENT, forced);
|
|
4854
|
+
window.addEventListener(FONT_SCALE_CHANGE_EVENT, forced);
|
|
4855
|
+
window.addEventListener(PHONE_LANDSCAPE_CHANGE_EVENT, forced);
|
|
4856
|
+
document.addEventListener('fullscreenchange', forced);
|
|
4857
|
+
syncChatActionsMenuState({ fit: 'force' });
|
|
4858
|
+
}
|
|
4859
|
+
|
|
4860
|
+
// The meta line's chips the conversation on screen has, for their ︙
|
|
4861
|
+
// stand-ins in phone landscape (where that line is hidden), with the labels.
|
|
4862
|
+
function chatActionsMenuMetaChips(convId) {
|
|
4863
|
+
if (!convId || isSharedReaderMode()) return { chips: {}, labels: null };
|
|
4864
|
+
const watching = relayWatchingChipText(convId);
|
|
4865
|
+
const schedules = relayScheduleChipText(convId);
|
|
4866
|
+
const origin = conversationOriginMenuEntry();
|
|
4867
|
+
return {
|
|
4868
|
+
chips: { watching: !!watching, schedules: !!schedules, origin: !!origin },
|
|
4869
|
+
labels: chatActionsMenuMetaChipLabels({ watching, origin: origin?.label || '' }),
|
|
4870
|
+
};
|
|
4871
|
+
}
|
|
4872
|
+
|
|
4873
|
+
function syncChatActionsMenuMetaChipLabels(labels) {
|
|
4874
|
+
if (!labels) return;
|
|
4875
|
+
for (const [chip, id] of Object.entries(CHAT_ACTIONS_MENU_META_CHIP_IDS)) {
|
|
4876
|
+
const entry = document.getElementById(id);
|
|
4877
|
+
if (entry && labels[chip] && entry.textContent !== labels[chip]) entry.textContent = labels[chip];
|
|
4878
|
+
}
|
|
4879
|
+
}
|
|
4880
|
+
|
|
4881
|
+
// The ︙ menu as it should look right now: the trigger and every entry,
|
|
4452
4882
|
// decided in chat-actions-menu-state.mjs from the conversation on screen.
|
|
4453
|
-
|
|
4883
|
+
// `fit`: 'schedule' (the default) keeps the twins the last fit chose and asks
|
|
4884
|
+
// for a fit before the next paint; 'now' fits first if any of its inputs
|
|
4885
|
+
// changed; 'force' fits first whatever.
|
|
4886
|
+
function syncChatActionsMenuState({ fit = 'schedule' } = {}) {
|
|
4454
4887
|
const convId = String(currentConvId || '').trim();
|
|
4455
4888
|
const conversation = convId ? (conversations[convId] || null) : null;
|
|
4456
4889
|
const isCloud = !!convId && isCurrentConversationClaudeCloud();
|
|
4890
|
+
let headerMenuIds;
|
|
4891
|
+
if (fit === 'now' || fit === 'force') {
|
|
4892
|
+
headerMenuIds = syncHeaderPriorityFit({ force: fit === 'force' });
|
|
4893
|
+
} else {
|
|
4894
|
+
headerMenuIds = keepHeaderPriorityPlacement(headerPriorityActionAvailability());
|
|
4895
|
+
scheduleHeaderPrioritySync();
|
|
4896
|
+
}
|
|
4897
|
+
syncHeaderPriorityTwinLabels();
|
|
4898
|
+
const metaChips = chatActionsMenuMetaChips(convId);
|
|
4899
|
+
syncChatActionsMenuMetaChipLabels(metaChips.labels);
|
|
4900
|
+
const fullscreenBtn = document.getElementById('fullscreen-btn');
|
|
4457
4901
|
const state = resolveChatActionsMenuState({
|
|
4458
4902
|
sharedMode: isSharedReaderMode(),
|
|
4459
4903
|
conversationId: convId,
|
|
@@ -4464,20 +4908,60 @@ function syncChatActionsMenuState() {
|
|
|
4464
4908
|
editing: !!convId && chatTitleEditingConversationId === convId,
|
|
4465
4909
|
suspendVisible: isSuspendHostActionVisible(),
|
|
4466
4910
|
hasActiveShare: hasActiveShare(convId),
|
|
4911
|
+
headerMenuIds,
|
|
4912
|
+
// As the header button: disabled where the browser cannot go fullscreen.
|
|
4913
|
+
fullscreenSupported: !fullscreenBtn?.disabled,
|
|
4914
|
+
phoneLandscape: document.documentElement.hasAttribute(PHONE_LANDSCAPE_ATTRIBUTE),
|
|
4915
|
+
metaChips: metaChips.chips,
|
|
4467
4916
|
});
|
|
4917
|
+
const trigger = document.getElementById('chat-actions-menu-btn');
|
|
4918
|
+
const triggerWasHidden = !!trigger?.hidden;
|
|
4468
4919
|
applyChatActionsMenuState(state);
|
|
4920
|
+
// ︙ coming or going changes the room the fit has (the shared view's menu
|
|
4921
|
+
// empties once Day/Night fits): one more pass settles it.
|
|
4922
|
+
if (trigger && !!trigger.hidden !== triggerWasHidden) scheduleHeaderPrioritySync({ force: true });
|
|
4469
4923
|
return state;
|
|
4470
4924
|
}
|
|
4471
4925
|
|
|
4472
4926
|
// The menu follows the relay's share list (made, renewed, stopped anywhere).
|
|
4473
4927
|
subscribeShares(() => syncChatActionsMenuState());
|
|
4474
4928
|
|
|
4929
|
+
// Keeps the open ︙ menu inside the viewport and #main (never off-screen,
|
|
4930
|
+
// never under the sidebar) and caps its height at what is left below it,
|
|
4931
|
+
// where it scrolls (resolveChatActionsMenuPlacement decides).
|
|
4932
|
+
function placeChatActionsMenu() {
|
|
4933
|
+
const menu = document.getElementById('chat-actions-menu');
|
|
4934
|
+
if (!menu || menu.hidden) return;
|
|
4935
|
+
// Measured as CSS places it; where the reader scrolled to stays.
|
|
4936
|
+
const scrollTop = menu.scrollTop;
|
|
4937
|
+
menu.style.right = '';
|
|
4938
|
+
menu.style.maxWidth = '';
|
|
4939
|
+
menu.style.maxHeight = '';
|
|
4940
|
+
const rect = menu.getBoundingClientRect();
|
|
4941
|
+
if (!rect.width && !rect.height) return;
|
|
4942
|
+
const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
|
|
4943
|
+
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
|
4944
|
+
const main = document.getElementById('main')?.getBoundingClientRect?.();
|
|
4945
|
+
const bounds = {
|
|
4946
|
+
left: Math.max(0, main && main.width ? main.left : 0),
|
|
4947
|
+
right: Math.min(viewportWidth, main && main.width ? main.right : viewportWidth),
|
|
4948
|
+
bottom: Math.min(viewportHeight, main && main.height ? main.bottom : viewportHeight),
|
|
4949
|
+
};
|
|
4950
|
+
// 8px from the edges and a 120px floor at 100 %, scaled with the zoom.
|
|
4951
|
+
const scale = getUiScale();
|
|
4952
|
+
const placement = resolveChatActionsMenuPlacement({ rect, bounds, margin: 8 * scale, minHeight: 120 * scale });
|
|
4953
|
+
if (placement.right) menu.style.right = `${placement.right}px`;
|
|
4954
|
+
if (placement.maxWidth !== null) menu.style.maxWidth = `${placement.maxWidth}px`;
|
|
4955
|
+
menu.style.maxHeight = `${placement.maxHeight}px`;
|
|
4956
|
+
if (scrollTop) menu.scrollTop = scrollTop;
|
|
4957
|
+
}
|
|
4958
|
+
|
|
4475
4959
|
function toggleChatActionsMenu() {
|
|
4476
4960
|
const menu = document.getElementById('chat-actions-menu');
|
|
4477
4961
|
const trigger = document.getElementById('chat-actions-menu-btn');
|
|
4478
4962
|
if (!menu || !trigger) return;
|
|
4479
4963
|
// The entries match the moment of the tap, as Refresh history already does.
|
|
4480
|
-
syncChatActionsMenuState();
|
|
4964
|
+
syncChatActionsMenuState({ fit: 'now' });
|
|
4481
4965
|
if (trigger.hidden || trigger.disabled) return;
|
|
4482
4966
|
syncRefreshHistoryMenuState();
|
|
4483
4967
|
syncThemeMenuLabel();
|
|
@@ -4485,6 +4969,10 @@ function toggleChatActionsMenu() {
|
|
|
4485
4969
|
const willOpen = !!menu.hidden;
|
|
4486
4970
|
menu.hidden = !willOpen;
|
|
4487
4971
|
trigger.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
|
|
4972
|
+
if (willOpen) {
|
|
4973
|
+
placeChatActionsMenu();
|
|
4974
|
+
menu.scrollTop = 0;
|
|
4975
|
+
}
|
|
4488
4976
|
}
|
|
4489
4977
|
|
|
4490
4978
|
function isConversationLocallyProcessing(conversation) {
|
|
@@ -4591,12 +5079,14 @@ function syncChatTitleControls() {
|
|
|
4591
5079
|
const editing = convId && chatTitleEditingConversationId === convId;
|
|
4592
5080
|
document.body.classList.toggle('chat-title-editing', !!editing);
|
|
4593
5081
|
if (title) title.hidden = editing;
|
|
4594
|
-
// The
|
|
4595
|
-
// open, so Settings is always one tap away.
|
|
5082
|
+
// The ︙ trigger and its entries: relay-wide ones stay with no conversation
|
|
5083
|
+
// open, so Settings is always one tap away. The header's fit is only asked
|
|
5084
|
+
// for here (once per frame at most, and it measures only when its inputs
|
|
5085
|
+
// changed): every list paint ends in this call.
|
|
4596
5086
|
syncChatActionsMenuState();
|
|
4597
5087
|
const archiveBtn = document.getElementById('chat-menu-archive');
|
|
4598
5088
|
if (archiveBtn) {
|
|
4599
|
-
archiveBtn.textContent = conversation?.archived === true ? '📂 Unarchive conversation' : '
|
|
5089
|
+
archiveBtn.textContent = conversation?.archived === true ? '📂 Unarchive conversation' : '🗄️ Archive conversation';
|
|
4600
5090
|
}
|
|
4601
5091
|
if (editor) {
|
|
4602
5092
|
editor.hidden = !editing;
|
|
@@ -4678,6 +5168,7 @@ function applyConversationTitleUpdate(conversationId, title, updatedAt) {
|
|
|
4678
5168
|
if (currentConvId === id) {
|
|
4679
5169
|
const titleEl = document.getElementById('chat-title');
|
|
4680
5170
|
if (titleEl) titleEl.textContent = nextTitle;
|
|
5171
|
+
syncSessionDocumentTitle();
|
|
4681
5172
|
}
|
|
4682
5173
|
renderConvList();
|
|
4683
5174
|
}
|
|
@@ -4854,6 +5345,11 @@ async function initApp() {
|
|
|
4854
5345
|
renderConvList();
|
|
4855
5346
|
},
|
|
4856
5347
|
});
|
|
5348
|
+
// The ⚙ panel and the label row above the message box.
|
|
5349
|
+
initComposerSettings({ isSharedReaderMode, showContext: () => showContext() });
|
|
5350
|
+
// A phone turned sideways: its typing mode folds the label row and the
|
|
5351
|
+
// task line into floating buttons while the keyboard is up.
|
|
5352
|
+
initPhoneLandscape({ isSharedReaderMode });
|
|
4857
5353
|
}
|
|
4858
5354
|
setupViewportTracking();
|
|
4859
5355
|
bindChatSelectionGuard();
|
|
@@ -4885,24 +5381,37 @@ async function initApp() {
|
|
|
4885
5381
|
event.stopPropagation();
|
|
4886
5382
|
toggleChatActionsMenu();
|
|
4887
5383
|
});
|
|
5384
|
+
// Header buttons and their ︙ twins (Check Usage among them), and the
|
|
5385
|
+
// passes that decide which of them fit.
|
|
5386
|
+
initHeaderPriorityActions({ sharedMode });
|
|
4888
5387
|
if (!sharedMode) {
|
|
4889
5388
|
const chatMenuEditBtn = document.getElementById('chat-menu-edit-title');
|
|
4890
|
-
|
|
4891
|
-
bindMenuAction(chatMenuUsageBtn, (event) => {
|
|
5389
|
+
bindMenuAction(chatMenuEditBtn, (event) => {
|
|
4892
5390
|
event.preventDefault();
|
|
4893
5391
|
event.stopPropagation();
|
|
4894
5392
|
lockChatActionsMenuShield(350);
|
|
4895
5393
|
closeChatActionsMenu();
|
|
4896
|
-
|
|
4897
|
-
alert(error?.message || 'Failed to load usage');
|
|
4898
|
-
});
|
|
5394
|
+
openChatTitleEditor();
|
|
4899
5395
|
});
|
|
4900
|
-
|
|
5396
|
+
// Phone landscape hides the meta line: these stand in for its chips and
|
|
5397
|
+
// do what a tap on the chip does. The lists open under ︙, the chip
|
|
5398
|
+
// having no box to hang from.
|
|
5399
|
+
const metaChipAction = (id, run) => bindMenuAction(document.getElementById(id), (event) => {
|
|
4901
5400
|
event.preventDefault();
|
|
4902
5401
|
event.stopPropagation();
|
|
4903
5402
|
lockChatActionsMenuShield(350);
|
|
4904
5403
|
closeChatActionsMenu();
|
|
4905
|
-
|
|
5404
|
+
run();
|
|
5405
|
+
});
|
|
5406
|
+
metaChipAction(CHAT_ACTIONS_MENU_META_CHIP_IDS.watching, () => {
|
|
5407
|
+
openRelayWatchPanel({ anchor: document.getElementById('chat-actions-menu-btn') });
|
|
5408
|
+
});
|
|
5409
|
+
metaChipAction(CHAT_ACTIONS_MENU_META_CHIP_IDS.schedules, () => {
|
|
5410
|
+
openRelaySchedulePanel({ anchor: document.getElementById('chat-actions-menu-btn') });
|
|
5411
|
+
});
|
|
5412
|
+
metaChipAction(CHAT_ACTIONS_MENU_META_CHIP_IDS.origin, () => {
|
|
5413
|
+
const origin = conversationOriginMenuEntry();
|
|
5414
|
+
if (origin) void openOriginConversation(null, { dataset: { originConversationId: origin.conversationId } });
|
|
4906
5415
|
});
|
|
4907
5416
|
const chatMenuCompactBtn = document.getElementById('chat-menu-compact');
|
|
4908
5417
|
if (chatMenuCompactBtn && !chatMenuCompactBtn.hidden && !chatMenuCompactBtn.disabled) {
|
|
@@ -5214,7 +5723,9 @@ async function initApp() {
|
|
|
5214
5723
|
initPushNotificationClientHooks();
|
|
5215
5724
|
// Browsers without Background Sync replay the outbox from the page instead.
|
|
5216
5725
|
void initOutboxFallbackReplay();
|
|
5217
|
-
|
|
5726
|
+
// Read before anything opens: the address then follows the open session.
|
|
5727
|
+
const pushConversationId = consumePushConversationDeepLink();
|
|
5728
|
+
await loadConversations({ openLast: !pushConversationId });
|
|
5218
5729
|
// The sidebar's "watched" badges follow the subscriptions.
|
|
5219
5730
|
onRelayEventSubscriptionsChanged(() => renderConvList());
|
|
5220
5731
|
void loadRelayEventSubscriptions();
|
|
@@ -5223,9 +5734,8 @@ async function initApp() {
|
|
|
5223
5734
|
void loadRelaySchedules();
|
|
5224
5735
|
// The Inbox's count, and the cards of questions that don't block.
|
|
5225
5736
|
startInbox();
|
|
5226
|
-
const pushConversationId = consumePushConversationDeepLink();
|
|
5227
5737
|
if (pushConversationId) {
|
|
5228
|
-
await openConversation(pushConversationId).catch(() => {});
|
|
5738
|
+
await openConversation(pushConversationId, { history: 'replace' }).catch(() => {});
|
|
5229
5739
|
}
|
|
5230
5740
|
await loadRelayQuestions(currentConvId);
|
|
5231
5741
|
await loadRelayBoards();
|
|
@@ -5280,6 +5790,31 @@ async function bootstrap() {
|
|
|
5280
5790
|
}
|
|
5281
5791
|
const urlToken = getTokenFromUrl();
|
|
5282
5792
|
if (urlToken) stripTokenFromUrl();
|
|
5793
|
+
// Back and forward walk the sessions opened, and back closes an open
|
|
5794
|
+
// overlay first (session-history.mjs).
|
|
5795
|
+
initSessionHistory({
|
|
5796
|
+
openConversation,
|
|
5797
|
+
openSidebar,
|
|
5798
|
+
closeSidebar,
|
|
5799
|
+
closeComposerSettings,
|
|
5800
|
+
closeChatActionsMenu,
|
|
5801
|
+
closeSummaryModal,
|
|
5802
|
+
closeSettingsModal,
|
|
5803
|
+
closeMessageSearchModal,
|
|
5804
|
+
closeRepoBrowser,
|
|
5805
|
+
closeNewConversationModelModal,
|
|
5806
|
+
// Leaving the Inbox goes back to the conversation it was opened from.
|
|
5807
|
+
// Opened from Status it stored none: `returnTo` is the session history stands on.
|
|
5808
|
+
closeInboxView: ({ returnTo = '' } = {}) => { if (isInboxViewActive()) void toggleInboxView({ returnTo }); },
|
|
5809
|
+
closeChangeCwdMenu: () => closeChangeCwdMenu({ focusTrigger: true }),
|
|
5810
|
+
closeGitChangesModal,
|
|
5811
|
+
closeGitDiffViewer,
|
|
5812
|
+
closeTmuxInspector: closeTmuxInspectorView,
|
|
5813
|
+
closeAnnotateEditor,
|
|
5814
|
+
closeRelayWatchPanel,
|
|
5815
|
+
closeRelaySchedulePanel,
|
|
5816
|
+
closeEmojiPicker,
|
|
5817
|
+
});
|
|
5283
5818
|
// In the installed app the system back button must never close the app;
|
|
5284
5819
|
// a tab keeps its own back button. After the token is gone from the
|
|
5285
5820
|
// address, so the entries the guard lays down carry the clean one.
|
|
@@ -5288,7 +5823,10 @@ async function bootstrap() {
|
|
|
5288
5823
|
enabled: isInstalledAppMode(),
|
|
5289
5824
|
target: window,
|
|
5290
5825
|
onFloor: () => showTransientRelayNotice('Press back again to close the app', 2500),
|
|
5826
|
+
entryAgain: sessionHistoryEntryAgain,
|
|
5291
5827
|
});
|
|
5828
|
+
// Armed after the guard: on a phone the session list lies above its floor.
|
|
5829
|
+
armSessionHistoryOnFirstInteraction(window);
|
|
5292
5830
|
const persistedToken = consumeLegacyPersistedAuthToken();
|
|
5293
5831
|
const bootstrapToken = String(urlToken || persistedToken || '').trim();
|
|
5294
5832
|
const existingSession = await verifyExistingSession(bootstrapToken);
|
|
@@ -5427,17 +5965,20 @@ window.deleteConv = deleteConv;
|
|
|
5427
5965
|
window.archiveConv = archiveConv;
|
|
5428
5966
|
window.openConversationGroupPicker = openConversationGroupPicker;
|
|
5429
5967
|
window.toggleConversationGroupFold = toggleConversationGroupFold;
|
|
5968
|
+
window.toggleAllConversationGroupFolds = toggleAllConversationGroupFolds;
|
|
5430
5969
|
window.unarchiveConv = unarchiveConv;
|
|
5431
5970
|
window.toggleArchivedConversations = toggleArchivedConversations;
|
|
5432
5971
|
window.refreshNewConversationCloudRepoList = refreshNewConversationCloudRepoList;
|
|
5433
5972
|
window.openConversation = openConversation;
|
|
5434
5973
|
window.openOriginConversation = openOriginConversation;
|
|
5435
5974
|
// The Inbox and Status share the transcript's place: opening one leaves the other.
|
|
5975
|
+
// Closed by its button, one opened from the other goes back to the session
|
|
5976
|
+
// history stands on.
|
|
5436
5977
|
window.toggleStatusView = () => {
|
|
5437
5978
|
leaveInboxView();
|
|
5438
|
-
return toggleStatusView();
|
|
5979
|
+
return toggleStatusView({ returnTo: sessionHistoryConversation() });
|
|
5439
5980
|
};
|
|
5440
|
-
window.toggleInboxView = toggleInboxView;
|
|
5981
|
+
window.toggleInboxView = () => toggleInboxView({ returnTo: sessionHistoryConversation() });
|
|
5441
5982
|
window.refreshConversations = refreshConversations;
|
|
5442
5983
|
window.renderConvList = renderConvList;
|
|
5443
5984
|
window.handleAttachmentInput = handleAttachmentInput;
|