@oar-sh/oar 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/extensions/web-relay/README.md +190 -0
- package/.github/extensions/web-relay/extension.mjs +972 -0
- package/.github/extensions/web-relay/model-api/model-switching.mjs +338 -0
- package/.github/extensions/web-relay/polling/heartbeat.mjs +55 -0
- package/.github/extensions/web-relay/polling/polling-loop.mjs +1105 -0
- package/.github/extensions/web-relay/runtime/api-client.mjs +34 -0
- package/.github/extensions/web-relay/runtime/banner-state.mjs +126 -0
- package/.github/extensions/web-relay/runtime/config-loader.mjs +104 -0
- package/.github/extensions/web-relay/runtime/debug-log.mjs +27 -0
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +170 -0
- package/.github/extensions/web-relay/runtime/session-binding.mjs +78 -0
- package/.github/extensions/web-relay/runtime/session-io.mjs +349 -0
- package/.github/extensions/web-relay/runtime/session-join-retry.mjs +44 -0
- package/.github/extensions/web-relay/runtime/session-registry.mjs +29 -0
- package/.github/extensions/web-relay/runtime/session-runtime-manager.mjs +156 -0
- package/.github/extensions/web-relay/runtime/session-sync-bridge.mjs +74 -0
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +325 -0
- package/.github/extensions/web-relay/runtime/workspace-root.mjs +55 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +386 -0
- package/.github/extensions/web-relay/skills/prompt-context.mjs +160 -0
- package/.github/extensions/web-relay/skills/question-bridge.mjs +141 -0
- package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +463 -0
- package/.github/extensions/web-relay/skills/reasoning-stream.mjs +285 -0
- package/.github/extensions/web-relay/skills/subagent-lifecycle.mjs +345 -0
- package/.github/extensions/web-relay/skills/tool-activity.mjs +243 -0
- package/.github/extensions/web-relay/utils/process-utils.mjs +25 -0
- package/.github/extensions/web-relay/utils/token-generation.mjs +58 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +717 -0
- package/bin/bat/oar.bat +46 -0
- package/bin/oar.js +665 -0
- package/package.json +68 -0
- package/server/README.md +1379 -0
- package/server/claude-worker/claude-attachments.mjs +134 -0
- package/server/claude-worker/claude-preview-tool.mjs +57 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +420 -0
- package/server/claude-worker/claude-session-process.mjs +2391 -0
- package/server/claude-worker/claude-session-test-harness.mjs +236 -0
- package/server/claude-worker/claude-session-worker.mjs +186 -0
- package/server/claude-worker/claude-transcript-relocator.mjs +189 -0
- package/server/claude-worker/claude-turn-publisher.mjs +291 -0
- package/server/claude-worker/fixtures/workflow-run-record.json +1 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +629 -0
- package/server/claude-worker/workflow-progress-digest.mjs +269 -0
- package/server/copilot-sdk-runtime.mjs +126 -0
- package/server/copilot-worker/copilot-attachments.mjs +97 -0
- package/server/copilot-worker/copilot-byok-provider.mjs +121 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +349 -0
- package/server/copilot-worker/copilot-plan-board.mjs +110 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +86 -0
- package/server/copilot-worker/copilot-question-bridge.mjs +216 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +494 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +800 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +1729 -0
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +167 -0
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +329 -0
- package/server/copilot-worker/fixtures/abort-turn.json +588 -0
- package/server/copilot-worker/fixtures/ask-user-turn.json +1154 -0
- package/server/copilot-worker/fixtures/background-timer-continuation.json +445 -0
- package/server/copilot-worker/fixtures/background-timer-turn.json +497 -0
- package/server/copilot-worker/fixtures/happy-turn.json +566 -0
- package/server/copilot-worker/fixtures/quota-turn.json +647 -0
- package/server/copilot-worker/fixtures/reasoning-turn.json +625 -0
- package/server/copilot-worker/fixtures/resume-turn.json +700 -0
- package/server/copilot-worker/fixtures/subagent-turn.json +1378 -0
- package/server/copilot-worker/fixtures/tool-permission-turn.json +1293 -0
- package/server/cursor-worker/cursor-ask-user-tool.mjs +83 -0
- package/server/cursor-worker/cursor-attachments.mjs +88 -0
- package/server/cursor-worker/cursor-context-usage.mjs +85 -0
- package/server/cursor-worker/cursor-preview-tool.mjs +44 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +554 -0
- package/server/cursor-worker/cursor-session-worker.mjs +145 -0
- package/server/cursor-worker/cursor-subagent-roster.mjs +81 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +717 -0
- package/server/cursor-worker/sdk-message-normalizer.mjs +485 -0
- package/server/db-schema.mjs +932 -0
- package/server/features.mjs +106 -0
- package/server/grok-worker/acp-client.mjs +434 -0
- package/server/grok-worker/acp-host-services.mjs +277 -0
- package/server/grok-worker/grok-context-usage.mjs +77 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +334 -0
- package/server/grok-worker/grok-session-worker.mjs +145 -0
- package/server/grok-worker/grok-turn-runner.mjs +523 -0
- package/server/grok-worker/sdk-message-normalizer.mjs +311 -0
- package/server/migrations/0001-add-structured-answer.mjs +80 -0
- package/server/migrations/0002-recent-workspace-roots-path-key.mjs +121 -0
- package/server/migrations/0003-push-subscriptions.mjs +87 -0
- package/server/public/app/action-confirmations.js +267 -0
- package/server/public/app/activity-replay-state.mjs +149 -0
- package/server/public/app/api-client.js +1082 -0
- package/server/public/app/ask-user-view.js +545 -0
- package/server/public/app/attachments-view.js +2112 -0
- package/server/public/app/auto-compact-window-options.mjs +59 -0
- package/server/public/app/background-tasks-view.mjs +494 -0
- package/server/public/app/bootstrap.js +4347 -0
- package/server/public/app/claude-auth-ui.js +394 -0
- package/server/public/app/claude-thinking-options.mjs +38 -0
- package/server/public/app/cli-install-ui.js +455 -0
- package/server/public/app/code-copy.mjs +85 -0
- package/server/public/app/composer-attachment-cache.mjs +151 -0
- package/server/public/app/composer-control-state.mjs +107 -0
- package/server/public/app/composer-paste.mjs +132 -0
- package/server/public/app/context-tier-options.mjs +68 -0
- package/server/public/app/context-usage-view.mjs +338 -0
- package/server/public/app/conversation-draft-timestamp-utils.mjs +11 -0
- package/server/public/app/conversation-preferences.mjs +91 -0
- package/server/public/app/conversation-provider-indicator.mjs +88 -0
- package/server/public/app/conversation-view.js +2898 -0
- package/server/public/app/cwd-menu-placement.mjs +85 -0
- package/server/public/app/cwd-picker.js +605 -0
- package/server/public/app/emoji-view.js +101 -0
- package/server/public/app/external-link-policy.mjs +80 -0
- package/server/public/app/file-preview-navigation.mjs +51 -0
- package/server/public/app/font-scaling.js +262 -0
- package/server/public/app/git-changes-view.js +339 -0
- package/server/public/app/git-diff-model.mjs +109 -0
- package/server/public/app/grok-auth-ui.js +387 -0
- package/server/public/app/image-downscale.mjs +174 -0
- package/server/public/app/in-flight-snapshot.mjs +40 -0
- package/server/public/app/infinite-loader.js +164 -0
- package/server/public/app/journal-view.js +1141 -0
- package/server/public/app/known-cwd-options.mjs +41 -0
- package/server/public/app/launch-cwd-path.mjs +23 -0
- package/server/public/app/live-message-dedupe.mjs +57 -0
- package/server/public/app/message-search-view.js +242 -0
- package/server/public/app/model-selector-options.mjs +31 -0
- package/server/public/app/model-vision-support.mjs +56 -0
- package/server/public/app/new-conversation-model-choice.mjs +67 -0
- package/server/public/app/plan-usage-view.mjs +307 -0
- package/server/public/app/preview-cards.mjs +273 -0
- package/server/public/app/preview-command.mjs +103 -0
- package/server/public/app/push-settings.js +370 -0
- package/server/public/app/pwa-install.js +456 -0
- package/server/public/app/question-schema-view.mjs +71 -0
- package/server/public/app/reasoning-effort-labels.mjs +42 -0
- package/server/public/app/relay-board-view.js +117 -0
- package/server/public/app/relay-dot-state.mjs +69 -0
- package/server/public/app/relay-error-ctas.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +114 -0
- package/server/public/app/relay-thoughts.mjs +98 -0
- package/server/public/app/relay-watchdog-policy.mjs +29 -0
- package/server/public/app/repo-browser-preferences.mjs +79 -0
- package/server/public/app/repo-browser-tree-state.mjs +97 -0
- package/server/public/app/router.js +657 -0
- package/server/public/app/selection-guard.mjs +93 -0
- package/server/public/app/settings-modal.js +1484 -0
- package/server/public/app/settings-tabs.js +129 -0
- package/server/public/app/slash-autocomplete.mjs +188 -0
- package/server/public/app/slash-commands.mjs +183 -0
- package/server/public/app/socket-handlers.js +703 -0
- package/server/public/app/status-store.mjs +242 -0
- package/server/public/app/status-view.mjs +315 -0
- package/server/public/app/store.js +1454 -0
- package/server/public/app/stream-state.mjs +73 -0
- package/server/public/app/streaming-dom-patch.mjs +23 -0
- package/server/public/app/sync-outbox.mjs +208 -0
- package/server/public/app/tap-actions.js +62 -0
- package/server/public/app/thread-order.mjs +54 -0
- package/server/public/app/tmux-inspector-view.js +422 -0
- package/server/public/app/transcript-separators.mjs +282 -0
- package/server/public/app-icon-192.png +0 -0
- package/server/public/app-icon-512.png +0 -0
- package/server/public/app-icon.svg +4 -0
- package/server/public/favicon.ico +0 -0
- package/server/public/index.html +5743 -0
- package/server/public/manifest.webmanifest +32 -0
- package/server/public/sw.js +307 -0
- package/server/relay-exit-codes.mjs +4 -0
- package/server/relay-self-restart.mjs +170 -0
- package/server/relay-tools.md +27 -0
- package/server/relay.mjs +1026 -0
- package/server/repositories/image-conversation-repository.mjs +317 -0
- package/server/repositories/message-repository.mjs +434 -0
- package/server/repositories/question-repository.mjs +161 -0
- package/server/repositories/session-repository.mjs +320 -0
- package/server/repositories/share-visibility-statements.mjs +24 -0
- package/server/routes/ask-user-routes.mjs +292 -0
- package/server/routes/cache-routes.mjs +44 -0
- package/server/routes/git-routes.mjs +92 -0
- package/server/routes/messages-routes-test-harness.mjs +111 -0
- package/server/routes/messages-routes.mjs +7107 -0
- package/server/routes/preview-routes.mjs +108 -0
- package/server/routes/push-routes.mjs +59 -0
- package/server/routes/relay-board-routes.mjs +268 -0
- package/server/routes/sessions-routes.mjs +6150 -0
- package/server/server-runtime.mjs +7463 -0
- package/server/server.js +24 -0
- package/server/services/ask-user-routing-service.mjs +117 -0
- package/server/services/cache-rebuild-service.mjs +236 -0
- package/server/services/claude-auth-service.mjs +578 -0
- package/server/services/claude-context-usage.mjs +240 -0
- package/server/services/claude-session-root-service.mjs +228 -0
- package/server/services/cli-install-service.mjs +1152 -0
- package/server/services/cli-process-runner.mjs +181 -0
- package/server/services/cloudflared-tunnel-service.mjs +358 -0
- package/server/services/context-snapshot-service.mjs +499 -0
- package/server/services/context-usage-view.mjs +160 -0
- package/server/services/conversation-preferences-service.mjs +208 -0
- package/server/services/cursor-dashboard-usage.mjs +217 -0
- package/server/services/cursor-session-root-service.mjs +62 -0
- package/server/services/delete-archive-service.mjs +154 -0
- package/server/services/drives-path-helpers.mjs +66 -0
- package/server/services/fixtures/claude-auth-stub.sh +62 -0
- package/server/services/fixtures/cli-install-stub.sh +75 -0
- package/server/services/fixtures/grok-stub.sh +86 -0
- package/server/services/generated-image-cleanup-service.mjs +114 -0
- package/server/services/git-changes-service.mjs +195 -0
- package/server/services/github-billing-usage.mjs +133 -0
- package/server/services/grok-auth-service.mjs +593 -0
- package/server/services/grok-billing-usage.mjs +120 -0
- package/server/services/image-context-service.mjs +106 -0
- package/server/services/image-operation-service.mjs +135 -0
- package/server/services/keyed-mutex-service.mjs +82 -0
- package/server/services/mime-sniffer.mjs +118 -0
- package/server/services/oar-cli-helpers.mjs +109 -0
- package/server/services/oar-state-migration-service.mjs +243 -0
- package/server/services/plan-usage-claude.mjs +387 -0
- package/server/services/plan-usage-contract.mjs +271 -0
- package/server/services/plan-usage-copilot.mjs +421 -0
- package/server/services/plan-usage-cursor.mjs +566 -0
- package/server/services/plan-usage-grok.mjs +436 -0
- package/server/services/plan-usage-service.mjs +487 -0
- package/server/services/preview-health-probe.mjs +82 -0
- package/server/services/preview-proxy-server.mjs +498 -0
- package/server/services/preview-registry-service.mjs +457 -0
- package/server/services/preview-static-handler.mjs +237 -0
- package/server/services/provider-model-selection.mjs +91 -0
- package/server/services/provider-reasoning-effort.mjs +94 -0
- package/server/services/push-active-device-service.mjs +57 -0
- package/server/services/push-dispatch-service.mjs +459 -0
- package/server/services/rate-limit-service.mjs +63 -0
- package/server/services/relay-bridge-owner-service.mjs +84 -0
- package/server/services/relay-cli-launcher-service.mjs +209 -0
- package/server/services/relay-prompt-sanitizer.mjs +137 -0
- package/server/services/relay-queue-gate-service.mjs +38 -0
- package/server/services/relay-restart-orchestrator-service.mjs +935 -0
- package/server/services/relay-singleton-guard.mjs +124 -0
- package/server/services/safe-served-content.mjs +57 -0
- package/server/services/sdk-session-import-service.mjs +271 -0
- package/server/services/sdk-session-sync-service.mjs +207 -0
- package/server/services/session-history-refresh-service.mjs +497 -0
- package/server/services/session-transcript-service.mjs +419 -0
- package/server/services/session-worker-launch-service.mjs +786 -0
- package/server/services/session-worker-process-service.mjs +319 -0
- package/server/services/session-worker-registry-service.mjs +194 -0
- package/server/services/session-worker-stop-service.mjs +141 -0
- package/server/services/session-worker-supervisor-service.mjs +898 -0
- package/server/services/session-worker-websocket-service.mjs +483 -0
- package/server/services/ssh-tunnel-manager-service.mjs +423 -0
- package/server/services/status-event-service.mjs +158 -0
- package/server/services/tmux-inspector-access-policy.mjs +77 -0
- package/server/services/tmux-inspector-socket-service.mjs +191 -0
- package/server/services/tmux-inspector-stream-service.mjs +552 -0
- package/server/services/tunnel-worker-path-guard.mjs +107 -0
- package/server/services/upload-sweep.mjs +60 -0
- package/server/services/usage-snapshot-helpers.mjs +209 -0
- package/server/services/windows-autostart-service.mjs +115 -0
- package/server/services/workspace-root-defaults-service.mjs +34 -0
- package/server/services/workspace-root-path-policy.mjs +196 -0
- package/server/services/workspace-root-relaunch-service.mjs +132 -0
- package/server/services/workspace-symlink-guard.mjs +54 -0
- package/server/tools/capture-proxy.mjs +173 -0
- package/server/tty-console-bootstrap.mjs +75 -0
- package/server/uploads/README.md +36 -0
- package/server/uploads/image_input.md +540 -0
- package/server/windows-terminal-launcher.mjs +29 -0
- package/server/workspace-root.mjs +87 -0
- package/shared/ask-user-bridge.mjs +131 -0
- package/shared/auto-compact-window.mjs +116 -0
- package/shared/background-task-timeout.mjs +52 -0
- package/shared/claude-thinking.mjs +101 -0
- package/shared/context-window-fallbacks.mjs +148 -0
- package/shared/control-poller.mjs +115 -0
- package/shared/empty-turn-completion.mjs +20 -0
- package/shared/model-catalog-freshness.mjs +11 -0
- package/shared/model-descriptors.mjs +118 -0
- package/shared/model-id.mjs +147 -0
- package/shared/model-refresh.mjs +24 -0
- package/shared/openai-reasoning.mjs +13 -0
- package/shared/plan-lines.mjs +13 -0
- package/shared/preview-instructions.mjs +77 -0
- package/shared/preview-tool-core.mjs +191 -0
- package/shared/provider-routing.mjs +29 -0
- package/shared/question-schema.mjs +315 -0
- package/shared/question-timeout.mjs +18 -0
- package/shared/stream-emit-gating.mjs +28 -0
- package/shared/subagent-run-id.mjs +17 -0
- package/shared/thought-cap.mjs +13 -0
- package/shared/tool-activity.mjs +98 -0
- package/shared/turn-ceiling.mjs +53 -0
- package/shared/worker-bootstrap.mjs +44 -0
- package/shared/worker-crash-guard.mjs +60 -0
|
@@ -0,0 +1,4347 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CLIENT_ID,
|
|
3
|
+
currentConvId,
|
|
4
|
+
conversations,
|
|
5
|
+
repoBrowserState,
|
|
6
|
+
workspaceRootPath,
|
|
7
|
+
defaultSessionWorkspaceRootPath,
|
|
8
|
+
defaultSessionWorkspaceRootWarning,
|
|
9
|
+
getConversationWorkspaceState,
|
|
10
|
+
escHtml,
|
|
11
|
+
setToken,
|
|
12
|
+
setCliOnline,
|
|
13
|
+
setRelayOnline,
|
|
14
|
+
setSessionWorkerStatesFromStatusPayload,
|
|
15
|
+
setCurrentConv,
|
|
16
|
+
updateWorkspaceRootHints,
|
|
17
|
+
updateCliStatus,
|
|
18
|
+
cliOnline,
|
|
19
|
+
openSidebar,
|
|
20
|
+
closeSidebar,
|
|
21
|
+
updateCompactButton,
|
|
22
|
+
updateSessionPill,
|
|
23
|
+
setModelBanner,
|
|
24
|
+
showTransientRelayNotice,
|
|
25
|
+
applyContextUsageBar,
|
|
26
|
+
scrollBottom,
|
|
27
|
+
isMessagesAtBottom,
|
|
28
|
+
setHistoryRefreshInFlight,
|
|
29
|
+
isHistoryRefreshInFlight,
|
|
30
|
+
setSummaryModalLoading,
|
|
31
|
+
renderSummaryModalContent,
|
|
32
|
+
openSummaryModal,
|
|
33
|
+
closeSummaryModal,
|
|
34
|
+
refreshSummaryModal,
|
|
35
|
+
syncViewportMetrics,
|
|
36
|
+
isMobileComposerViewport,
|
|
37
|
+
releaseComposerFocusAfterSend,
|
|
38
|
+
autoResize,
|
|
39
|
+
initSidebarLayout,
|
|
40
|
+
toggleSidebar,
|
|
41
|
+
loadConversationScrollTop,
|
|
42
|
+
loadConversationLoadedMessageCount,
|
|
43
|
+
saveConversationScrollTop,
|
|
44
|
+
getSessionWorkerState,
|
|
45
|
+
resolveConversationUiState,
|
|
46
|
+
summaryModalState,
|
|
47
|
+
IS_SHARED_VIEW,
|
|
48
|
+
SHARED_CONVERSATION_TOKEN,
|
|
49
|
+
getConversationWatcherCount,
|
|
50
|
+
generateId,
|
|
51
|
+
} from './store.js';
|
|
52
|
+
import {
|
|
53
|
+
verifyExistingSession,
|
|
54
|
+
verifyToken,
|
|
55
|
+
refreshWorkspaceRootHints,
|
|
56
|
+
loadUsageSummary,
|
|
57
|
+
getClaudeAuthStatus,
|
|
58
|
+
loadContextSummary,
|
|
59
|
+
loadModelCatalog,
|
|
60
|
+
loadModelVariantCatalog,
|
|
61
|
+
refreshModelVariantCatalog,
|
|
62
|
+
saveEnabledModelVariants,
|
|
63
|
+
updateClaudeSettings,
|
|
64
|
+
updateCursorSettings,
|
|
65
|
+
updateGrokSettings,
|
|
66
|
+
loadConversation,
|
|
67
|
+
refreshConversationHistory,
|
|
68
|
+
updateConversationTitle,
|
|
69
|
+
updateConversationPreferences,
|
|
70
|
+
createConversationShareLink,
|
|
71
|
+
updateDefaultSessionWorkspaceRoot,
|
|
72
|
+
scheduleContextUsageRefresh,
|
|
73
|
+
loadSharedConversation,
|
|
74
|
+
reportSharedViewerPresence,
|
|
75
|
+
setNetworkRequestsEnabled,
|
|
76
|
+
} from './api-client.js';
|
|
77
|
+
import { loadConversations, refreshConversations, openConversation, renderConvList, applyLoadedConversationState, initConversationListLazyLoading } from './journal-view.js';
|
|
78
|
+
import {
|
|
79
|
+
newConversation,
|
|
80
|
+
confirmNewConversationModel,
|
|
81
|
+
closeNewConversationModelModal,
|
|
82
|
+
deleteConv,
|
|
83
|
+
} from './journal-view.js';
|
|
84
|
+
import {
|
|
85
|
+
loadRelayQuestions,
|
|
86
|
+
getPendingQuestionCountsByConversation,
|
|
87
|
+
} from './ask-user-view.js';
|
|
88
|
+
import { openPendingQuestionFromBanner, submitRelayQuestionChoice, submitRelayQuestionAnswer, submitRelayStructuredAnswer, onRelayQuestionDraftInput, handleRelayQuestionKey } from './ask-user-view.js';
|
|
89
|
+
import { loadRelayBoards, submitRelayBoardAction } from './relay-board-view.js';
|
|
90
|
+
import {
|
|
91
|
+
restoreInFlightThinking,
|
|
92
|
+
renderMessages,
|
|
93
|
+
appendMessage,
|
|
94
|
+
compactCurrentConversation,
|
|
95
|
+
sendMessage,
|
|
96
|
+
handleKey,
|
|
97
|
+
updateComposerSlashMenu,
|
|
98
|
+
getConversationLoadedMessageCount,
|
|
99
|
+
loadOlderConversationMessages,
|
|
100
|
+
syncComposerControlState,
|
|
101
|
+
persistComposerAttachments,
|
|
102
|
+
flushConversationDraft,
|
|
103
|
+
initConversationHistoryLazyLoading,
|
|
104
|
+
initBubbleActionHandlers,
|
|
105
|
+
isSendInFlight,
|
|
106
|
+
setImageEditTarget,
|
|
107
|
+
clearImageEditTarget,
|
|
108
|
+
jumpToImageParent,
|
|
109
|
+
flushDeferredMessageRender,
|
|
110
|
+
} from './conversation-view.js';
|
|
111
|
+
import { bindChatSelectionGuard, chatSelectionGuard, isChatInteractionHeld } from './selection-guard.mjs';
|
|
112
|
+
import { loadRepoBrowserTree, openRepoBrowser, closeRepoBrowser, setRepoBrowserSessionInfo, resetWorkspaceRepoBrowserForRootChange } from './attachments-view.js';
|
|
113
|
+
import { handleAttachmentInput, retryAttachmentUpload, handleComposerPaste, handleComposerDrop, refreshComposerAttachmentWarning, removeAttachment, clearAttachments, openUploadedAttachmentViewer, setFilePreviewMode, toggleFilePreviewHtml, closeFilePreview, goBackFilePreview, openWorkspaceFilePreview, openWorkspaceFilePreviewFromRepo, setRepoBrowserRoot, setRepoBrowserViewMode, toggleRepoBrowserHidden, toggleRepoBrowserHeavy, refreshRepoBrowser, focusRepoTree, setRepoCurrentPath, confirmRepoBrowserCwdPick } from './attachments-view.js';
|
|
114
|
+
import { initEmojiPicker, toggleEmojiPicker } from './emoji-view.js';
|
|
115
|
+
import { initSlashAutocomplete } from './slash-autocomplete.mjs';
|
|
116
|
+
import { dataTransferHasFiles } from './composer-paste.mjs';
|
|
117
|
+
import { isReasoningOffUnsupported, reasoningEffortOptionLabel, reasoningEffortOptionTitle } from './reasoning-effort-labels.mjs';
|
|
118
|
+
import {
|
|
119
|
+
firstDefinedPreference,
|
|
120
|
+
normalizePreferenceValue,
|
|
121
|
+
resolveComposerReasoningEffort,
|
|
122
|
+
resolveConversationComposerSelection,
|
|
123
|
+
} from './conversation-preferences.mjs';
|
|
124
|
+
import {
|
|
125
|
+
modelSelectorOptionsEqual,
|
|
126
|
+
normalizeModelSelectorOptions,
|
|
127
|
+
} from './model-selector-options.mjs';
|
|
128
|
+
import {
|
|
129
|
+
buildContextTierOptions,
|
|
130
|
+
resolveContextTierValue,
|
|
131
|
+
} from './context-tier-options.mjs';
|
|
132
|
+
import {
|
|
133
|
+
isOpenAIImageModelId,
|
|
134
|
+
sessionLockNoteText,
|
|
135
|
+
sessionLockProviderKey,
|
|
136
|
+
} from './conversation-provider-indicator.mjs';
|
|
137
|
+
import {
|
|
138
|
+
initMessageSearchView,
|
|
139
|
+
openMessageSearchModal,
|
|
140
|
+
closeMessageSearchModal,
|
|
141
|
+
} from './message-search-view.js';
|
|
142
|
+
import {
|
|
143
|
+
initGitChangesView,
|
|
144
|
+
openGitChangesModal,
|
|
145
|
+
closeGitChangesModal,
|
|
146
|
+
openGitDiffViewer,
|
|
147
|
+
closeGitDiffViewer,
|
|
148
|
+
setGitDiffMode,
|
|
149
|
+
} from './git-changes-view.js';
|
|
150
|
+
|
|
151
|
+
import {
|
|
152
|
+
initSocketHandlers,
|
|
153
|
+
connectSocket,
|
|
154
|
+
setSocketActivityEnabled,
|
|
155
|
+
ensureSocketConnected,
|
|
156
|
+
hardResetSocket,
|
|
157
|
+
verifySocketLiveness,
|
|
158
|
+
emitDeviceVisibility,
|
|
159
|
+
} from './socket-handlers.js';
|
|
160
|
+
import {
|
|
161
|
+
adviseWatchdogTick,
|
|
162
|
+
RELAY_WATCHDOG_HARD_RESET_TICKS,
|
|
163
|
+
} from './relay-watchdog-policy.mjs';
|
|
164
|
+
import {
|
|
165
|
+
initInstallButton,
|
|
166
|
+
initFullscreenButton,
|
|
167
|
+
promptInstallApp,
|
|
168
|
+
toggleFullscreen,
|
|
169
|
+
applyPwaManifestFromSettings,
|
|
170
|
+
registerPwaShell,
|
|
171
|
+
updatePwaAppName,
|
|
172
|
+
} from './pwa-install.js';
|
|
173
|
+
import {
|
|
174
|
+
renderAutoCompactControlHtml,
|
|
175
|
+
renderContextUsageHtml,
|
|
176
|
+
renderThinkingControlHtml,
|
|
177
|
+
thinkingEnabledFromKey,
|
|
178
|
+
thinkingEnabledToKey,
|
|
179
|
+
} from './context-usage-view.mjs';
|
|
180
|
+
import {
|
|
181
|
+
autoCompactWindowFromIndex,
|
|
182
|
+
autoCompactWindowToIndex,
|
|
183
|
+
formatAutoCompactWindowLabel,
|
|
184
|
+
} from './auto-compact-window-options.mjs';
|
|
185
|
+
import { renderPlanUsageHtml, planUsageSubtitle, normalizeUsageProvider } from './plan-usage-view.mjs';
|
|
186
|
+
import { initFontScaling, updateFontScaleFromSelect } from './font-scaling.js';
|
|
187
|
+
import { initClientDiagnostics, recordStatusEvent } from './status-store.mjs';
|
|
188
|
+
import { isStatusViewActive, toggleStatusView } from './status-view.mjs';
|
|
189
|
+
import { installExternalLinkPolicy, openExternalNavigation } from './external-link-policy.mjs';
|
|
190
|
+
import {
|
|
191
|
+
initCwdPicker,
|
|
192
|
+
openChangeCwdModal,
|
|
193
|
+
confirmChangeCwd,
|
|
194
|
+
confirmChangeCwdAndLaunch,
|
|
195
|
+
syncChatHeaderWorkspaceLabel,
|
|
196
|
+
normalizeKnownCwdPath,
|
|
197
|
+
clearLegacyKnownCwdHistoryStorage,
|
|
198
|
+
} from './cwd-picker.js';
|
|
199
|
+
import { bindTapAction, bindMenuAction } from './tap-actions.js';
|
|
200
|
+
|
|
201
|
+
let pendingExternalLinkUrl = '';
|
|
202
|
+
|
|
203
|
+
function showExternalLinkFallback(url) {
|
|
204
|
+
pendingExternalLinkUrl = String(url || '').trim();
|
|
205
|
+
if (!pendingExternalLinkUrl) return;
|
|
206
|
+
openSummaryModal({
|
|
207
|
+
title: 'Open external link',
|
|
208
|
+
subtitle: 'Your browser blocked opening a separate tab',
|
|
209
|
+
bodyHtml: `
|
|
210
|
+
<p>The OAR app remains open. Copy this link and open it in your system browser:</p>
|
|
211
|
+
<pre><code>${escHtml(pendingExternalLinkUrl)}</code></pre>
|
|
212
|
+
<div class="summary-actions">
|
|
213
|
+
<button class="summary-btn" type="button" onclick="copyExternalLinkUrl()">Copy link</button>
|
|
214
|
+
<button class="summary-btn" type="button" onclick="retryExternalLinkOpen()">Open again</button>
|
|
215
|
+
<button class="summary-close" type="button" onclick="closeSummaryModal()">Close</button>
|
|
216
|
+
</div>
|
|
217
|
+
`,
|
|
218
|
+
kind: 'external-link-fallback',
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function copyExternalLinkUrl() {
|
|
223
|
+
if (!pendingExternalLinkUrl) return;
|
|
224
|
+
await copyTextToClipboard(pendingExternalLinkUrl);
|
|
225
|
+
showTransientRelayNotice('External link copied.');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function retryExternalLinkOpen() {
|
|
229
|
+
if (!pendingExternalLinkUrl) return;
|
|
230
|
+
openExternalNavigation(pendingExternalLinkUrl, showExternalLinkFallback);
|
|
231
|
+
}
|
|
232
|
+
import { initTmuxInspectorView, closeTmuxInspectorView } from './tmux-inspector-view.js';
|
|
233
|
+
import {
|
|
234
|
+
initTheme,
|
|
235
|
+
updateTheme,
|
|
236
|
+
openSettingsModal,
|
|
237
|
+
closeSettingsModal,
|
|
238
|
+
syncSuspendHostVisibility,
|
|
239
|
+
updateShowSuspendHostSetting,
|
|
240
|
+
syncDefaultSessionWorkspaceRootInput,
|
|
241
|
+
updateDefaultSessionWorkspaceRootSetting,
|
|
242
|
+
saveOpenAISettings,
|
|
243
|
+
removeOpenAISettings,
|
|
244
|
+
toggleOpenAIProvider,
|
|
245
|
+
applyOpenAISettingsState,
|
|
246
|
+
refreshOpenAISettingsState,
|
|
247
|
+
saveClaudeSettings,
|
|
248
|
+
toggleClaudeProvider,
|
|
249
|
+
applyClaudeSettingsState,
|
|
250
|
+
refreshClaudeSettingsState,
|
|
251
|
+
saveCopilotSettings,
|
|
252
|
+
applyCopilotSettingsState,
|
|
253
|
+
saveGrokSettings,
|
|
254
|
+
toggleGrokProvider,
|
|
255
|
+
applyGrokSettingsState,
|
|
256
|
+
refreshGrokSettingsState,
|
|
257
|
+
saveCursorSettings,
|
|
258
|
+
removeCursorSettings,
|
|
259
|
+
saveCursorAllowanceSettings,
|
|
260
|
+
resetCursorAllowanceAccounting,
|
|
261
|
+
saveCursorDashboardToken,
|
|
262
|
+
removeCursorDashboardToken,
|
|
263
|
+
saveGrokAllowanceSettings,
|
|
264
|
+
resetGrokAllowanceAccounting,
|
|
265
|
+
toggleCursorProvider,
|
|
266
|
+
applyCursorSettingsState,
|
|
267
|
+
refreshCursorSettingsState,
|
|
268
|
+
updateWindowsAutostartSettingFromToggle,
|
|
269
|
+
previewTurnCeilingSetting,
|
|
270
|
+
updateTurnCeilingSetting,
|
|
271
|
+
previewBackgroundTaskTimeoutSetting,
|
|
272
|
+
updateBackgroundTaskTimeoutSetting,
|
|
273
|
+
} from './settings-modal.js';
|
|
274
|
+
import {
|
|
275
|
+
togglePushOnThisDevice,
|
|
276
|
+
updatePushPreferencesFromControls,
|
|
277
|
+
} from './push-settings.js';
|
|
278
|
+
import {
|
|
279
|
+
enqueueDraftFlushForBackgroundSync,
|
|
280
|
+
initOutboxFallbackReplay,
|
|
281
|
+
} from './sync-outbox.mjs';
|
|
282
|
+
import {
|
|
283
|
+
initActionConfirmations,
|
|
284
|
+
openKillSessionConfirmation,
|
|
285
|
+
confirmKillCurrentSession,
|
|
286
|
+
openRestartRelayConfirmation,
|
|
287
|
+
confirmRestartWebRelay,
|
|
288
|
+
openEmptyQueueConfirmation,
|
|
289
|
+
confirmEmptyQueue,
|
|
290
|
+
openSuspendHostConfirmation,
|
|
291
|
+
confirmSuspendHost,
|
|
292
|
+
} from './action-confirmations.js';
|
|
293
|
+
import {
|
|
294
|
+
startClaudeRelogin,
|
|
295
|
+
submitClaudeLoginCodeFromInput,
|
|
296
|
+
handleClaudeLoginCodeKey,
|
|
297
|
+
cancelClaudeRelogin,
|
|
298
|
+
copyClaudeLoginUrl,
|
|
299
|
+
openClaudeLogoutConfirmation,
|
|
300
|
+
confirmClaudeLogout,
|
|
301
|
+
} from './claude-auth-ui.js';
|
|
302
|
+
import {
|
|
303
|
+
startGrokSignIn,
|
|
304
|
+
cancelGrokSignIn,
|
|
305
|
+
copyGrokLoginUrl,
|
|
306
|
+
openGrokLogoutConfirmation,
|
|
307
|
+
confirmGrokLogout,
|
|
308
|
+
} from './grok-auth-ui.js';
|
|
309
|
+
import {
|
|
310
|
+
confirmCliInstall,
|
|
311
|
+
runCliInstall,
|
|
312
|
+
cancelCliInstallRun,
|
|
313
|
+
} from './cli-install-ui.js';
|
|
314
|
+
|
|
315
|
+
const MODEL_STORAGE_KEY = 'copilot_selected_model';
|
|
316
|
+
// The New Chat modal used to keep its own model key, so a selection made there
|
|
317
|
+
// never reached the composer. Both now read MODEL_STORAGE_KEY.
|
|
318
|
+
const LEGACY_MODEL_STORAGE_KEY = 'copilot_model';
|
|
319
|
+
const REASONING_STORAGE_KEY = 'copilot_selected_reasoning_effort';
|
|
320
|
+
const MODE_STORAGE_KEY = 'copilot_selected_mode';
|
|
321
|
+
const AUTO_MODEL_OPTION = 'auto';
|
|
322
|
+
// Claude "[1m]" long-context variants surface as the base model plus the
|
|
323
|
+
// long_context tier in the context-size dropdown, never as separate entries.
|
|
324
|
+
const CLAUDE_LONG_CONTEXT_PATTERN = /\[1m\]$/i;
|
|
325
|
+
const FALLBACK_MODEL = 'gpt-5.4-mini';
|
|
326
|
+
const FALLBACK_REASONING_EFFORT = 'none';
|
|
327
|
+
const FALLBACK_MODE = 'agent';
|
|
328
|
+
const PROVIDER_LABELS = {
|
|
329
|
+
openai: 'OpenAI',
|
|
330
|
+
'openai-byok': 'OpenAI (BYOK)',
|
|
331
|
+
claude: 'Claude SDK',
|
|
332
|
+
cursor: 'Cursor SDK',
|
|
333
|
+
grok: 'Grok',
|
|
334
|
+
'github-copilot': 'GitHub Copilot',
|
|
335
|
+
// Vendor grouping for Copilot-served rows, distinct from the Claude SDK runtime.
|
|
336
|
+
anthropic: 'Anthropic',
|
|
337
|
+
google: 'Google',
|
|
338
|
+
microsoft: 'Microsoft',
|
|
339
|
+
other: 'Other',
|
|
340
|
+
};
|
|
341
|
+
const CHAT_TITLE_MAX_LENGTH = 120;
|
|
342
|
+
const LOCAL_PROCESSING_STALE_MS = 5 * 60 * 1000;
|
|
343
|
+
const FOREGROUND_RECOVERY_DEBOUNCE_MS = 1000;
|
|
344
|
+
// Upper bound on how long the in-flight latch may stay set. A recovery request
|
|
345
|
+
// that never settles (frozen renderer, half-open mobile socket) must not be able
|
|
346
|
+
// to keep the latch closed, because that would suppress every later recovery.
|
|
347
|
+
const FOREGROUND_RECOVERY_TIMEOUT_MS = 20000;
|
|
348
|
+
const RELAY_WATCHDOG_INTERVAL_MS = 5000;
|
|
349
|
+
// The server treats a visibility report older than 90s as stale, so a foregrounded
|
|
350
|
+
// device has to keep re-asserting itself or it silently stops counting as active
|
|
351
|
+
// and push suppression stops working mid-turn. 30s leaves room for two dropped
|
|
352
|
+
// beats inside that window. Tests shorten this rather than waiting it out.
|
|
353
|
+
const DEVICE_VISIBILITY_HEARTBEAT_MS = Number.isFinite(Number(window.__COPILOT_VISIBILITY_HEARTBEAT_MS))
|
|
354
|
+
? Math.max(50, Number(window.__COPILOT_VISIBILITY_HEARTBEAT_MS))
|
|
355
|
+
: 30_000;
|
|
356
|
+
// How long a hidden page keeps its transport before suspending it. Brief
|
|
357
|
+
// app switches then never drop the socket at all. 45s matches the server's
|
|
358
|
+
// ping window (pingInterval 25s + pingTimeout 20s): past that the server has
|
|
359
|
+
// already dropped the client, so waiting longer would be inert. Tests override
|
|
360
|
+
// this to avoid waiting out the real grace period.
|
|
361
|
+
const BACKGROUND_SUSPEND_GRACE_MS = Number.isFinite(Number(window.__COPILOT_BACKGROUND_GRACE_MS))
|
|
362
|
+
? Math.max(0, Number(window.__COPILOT_BACKGROUND_GRACE_MS))
|
|
363
|
+
: 45_000;
|
|
364
|
+
|
|
365
|
+
let relayQuestionPollTimer = null;
|
|
366
|
+
let relayBoardPollTimer = null;
|
|
367
|
+
let sessionWorkerStatusPollTimer = null;
|
|
368
|
+
let sharedConversationPollTimer = null;
|
|
369
|
+
let sharedPresencePollTimer = null;
|
|
370
|
+
let sharedConversationPollInFlight = false;
|
|
371
|
+
let sharedConversationRequestSeq = 0;
|
|
372
|
+
let sharedConversationAppliedSeq = 0;
|
|
373
|
+
let liveConversationPollTimer = null;
|
|
374
|
+
let liveConversationPollInFlight = false;
|
|
375
|
+
let sharedViewerIdFallback = '';
|
|
376
|
+
let sharedConversationRenderKey = '';
|
|
377
|
+
let sharedConversationLastError = '';
|
|
378
|
+
let networkLifecycleBound = false;
|
|
379
|
+
let foregroundRecoveryTimer = null;
|
|
380
|
+
let foregroundRecoveryInFlight = false;
|
|
381
|
+
let foregroundRecoveryWatchdogTimer = null;
|
|
382
|
+
let foregroundRecoveryGeneration = 0;
|
|
383
|
+
let relayConnectionWatchdogTimer = null;
|
|
384
|
+
let relayWatchdogDisconnectedTicks = 0;
|
|
385
|
+
let deviceVisibilityHeartbeatTimer = null;
|
|
386
|
+
let backgroundSuspendTimer = null;
|
|
387
|
+
let appSharedMode = false;
|
|
388
|
+
let viewportBaseHeight = window.innerHeight || document.documentElement.clientHeight || 0;
|
|
389
|
+
let chatTitleEditingConversationId = null;
|
|
390
|
+
let relayQuestionRenderHash = '';
|
|
391
|
+
let modelCatalogState = {
|
|
392
|
+
models: [FALLBACK_MODEL],
|
|
393
|
+
currentModel: FALLBACK_MODEL,
|
|
394
|
+
defaultModel: FALLBACK_MODEL,
|
|
395
|
+
reasoningByModel: {},
|
|
396
|
+
reasoningByProvider: {},
|
|
397
|
+
providersByModel: {},
|
|
398
|
+
reasoningEfforts: [],
|
|
399
|
+
modelMetadataByModel: {},
|
|
400
|
+
claudeContextTiersByModel: {},
|
|
401
|
+
stale: true,
|
|
402
|
+
metadataValid: false,
|
|
403
|
+
reasoningMetadataValid: false,
|
|
404
|
+
warning: null,
|
|
405
|
+
error: null,
|
|
406
|
+
refreshedAt: null,
|
|
407
|
+
};
|
|
408
|
+
let lastHealthyModelCatalogState = null;
|
|
409
|
+
let deferredModelCatalogPayload = null;
|
|
410
|
+
let modelMetadataBlocked = true;
|
|
411
|
+
let modelMetadataRetryInFlight = false;
|
|
412
|
+
let modelVariantCatalogState = {
|
|
413
|
+
variants: [],
|
|
414
|
+
enabledVariantIds: [],
|
|
415
|
+
reasoningByModel: {},
|
|
416
|
+
source: null,
|
|
417
|
+
refreshedAt: null,
|
|
418
|
+
warning: null,
|
|
419
|
+
error: null,
|
|
420
|
+
reasoningEfforts: [],
|
|
421
|
+
};
|
|
422
|
+
let modelVariantCatalogProviderTab = 'copilot';
|
|
423
|
+
let suppressConversationPreferenceSync = false;
|
|
424
|
+
let conversationPreferenceWriteVersion = 0;
|
|
425
|
+
let latestQueueStatus = {
|
|
426
|
+
pendingCount: 0,
|
|
427
|
+
processingCount: 0,
|
|
428
|
+
parkedCount: 0,
|
|
429
|
+
};
|
|
430
|
+
const SHARED_VIEWER_ID_STORAGE_KEY = 'copilot_shared_viewer_id';
|
|
431
|
+
const AUTH_TOKEN_STORAGE_KEY = 'copilot_auth_token';
|
|
432
|
+
const AUTH_COOKIE_NAME = 'copilot_auth';
|
|
433
|
+
|
|
434
|
+
function resolveSharedTokenFromLocation() {
|
|
435
|
+
const configured = String(window.__COPILOT_APP_CONFIG?.sharedToken || SHARED_CONVERSATION_TOKEN || '').trim().toLowerCase();
|
|
436
|
+
if (configured) return configured;
|
|
437
|
+
const pathToken = String(window.location.pathname || '').match(/\/shared\/([^/?#]+)\/?$/i);
|
|
438
|
+
if (!pathToken) return '';
|
|
439
|
+
return String(pathToken[1] || '').trim().toLowerCase();
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function isSharedReaderMode() {
|
|
443
|
+
if (IS_SHARED_VIEW) return true;
|
|
444
|
+
return !!resolveSharedTokenFromLocation();
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function getTokenFromUrl() {
|
|
448
|
+
return new URLSearchParams(window.location.search).get('token');
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function resolveAuthCookiePath() {
|
|
452
|
+
const configuredBase = typeof window.__COPILOT_APP_CONFIG?.basePath === 'string'
|
|
453
|
+
? window.__COPILOT_APP_CONFIG.basePath.trim()
|
|
454
|
+
: '';
|
|
455
|
+
if (configuredBase && configuredBase !== '/') {
|
|
456
|
+
return configuredBase.startsWith('/')
|
|
457
|
+
? configuredBase.replace(/\/+$/, '') || '/'
|
|
458
|
+
: `/${configuredBase.replace(/\/+$/, '')}`;
|
|
459
|
+
}
|
|
460
|
+
const pathname = String(window.location.pathname || '/');
|
|
461
|
+
const sharedIndex = pathname.indexOf('/shared/');
|
|
462
|
+
const base = sharedIndex >= 0
|
|
463
|
+
? pathname.slice(0, sharedIndex).replace(/\/+$/, '')
|
|
464
|
+
: pathname.replace(/\/+$/, '');
|
|
465
|
+
return base || '/';
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function clearLegacyClientAuthCookie() {
|
|
469
|
+
const path = resolveAuthCookiePath();
|
|
470
|
+
document.cookie = `${AUTH_COOKIE_NAME}=; Path=${path}; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax`;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function consumeLegacyPersistedAuthToken() {
|
|
474
|
+
let sessionValue = '';
|
|
475
|
+
let localValue = '';
|
|
476
|
+
try {
|
|
477
|
+
sessionValue = String(sessionStorage.getItem(AUTH_TOKEN_STORAGE_KEY) || '').trim();
|
|
478
|
+
sessionStorage.removeItem(AUTH_TOKEN_STORAGE_KEY);
|
|
479
|
+
} catch {
|
|
480
|
+
sessionValue = '';
|
|
481
|
+
}
|
|
482
|
+
try {
|
|
483
|
+
localValue = String(localStorage.getItem(AUTH_TOKEN_STORAGE_KEY) || '').trim();
|
|
484
|
+
localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY);
|
|
485
|
+
} catch {
|
|
486
|
+
localValue = '';
|
|
487
|
+
}
|
|
488
|
+
clearLegacyClientAuthCookie();
|
|
489
|
+
return sessionValue || localValue;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function stripTokenFromUrl() {
|
|
493
|
+
const url = new URL(window.location.href);
|
|
494
|
+
if (!url.searchParams.has('token')) return;
|
|
495
|
+
url.searchParams.delete('token');
|
|
496
|
+
history.replaceState(null, document.title, `${url.pathname}${url.search}${url.hash}`);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function ensureTrailingSlashPath() {
|
|
500
|
+
if (isSharedReaderMode()) return false;
|
|
501
|
+
const url = new URL(window.location.href);
|
|
502
|
+
const path = url.pathname || '/';
|
|
503
|
+
if (path === '/' || path.endsWith('/')) return false;
|
|
504
|
+
const lastSegment = path.split('/').filter(Boolean).pop() || '';
|
|
505
|
+
if (lastSegment.includes('.')) return false;
|
|
506
|
+
url.pathname = `${path}/`;
|
|
507
|
+
window.location.replace(url.toString());
|
|
508
|
+
return true;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function sharedViewerId() {
|
|
512
|
+
let viewerId = '';
|
|
513
|
+
try {
|
|
514
|
+
viewerId = String(sessionStorage.getItem(SHARED_VIEWER_ID_STORAGE_KEY) || '').trim();
|
|
515
|
+
} catch {
|
|
516
|
+
viewerId = sharedViewerIdFallback;
|
|
517
|
+
}
|
|
518
|
+
if (!viewerId) {
|
|
519
|
+
viewerId = generateId();
|
|
520
|
+
sharedViewerIdFallback = viewerId;
|
|
521
|
+
try {
|
|
522
|
+
sessionStorage.setItem(SHARED_VIEWER_ID_STORAGE_KEY, viewerId);
|
|
523
|
+
} catch {}
|
|
524
|
+
}
|
|
525
|
+
return viewerId;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function stopSharedModeTimers() {
|
|
529
|
+
if (sharedConversationPollTimer) {
|
|
530
|
+
clearInterval(sharedConversationPollTimer);
|
|
531
|
+
sharedConversationPollTimer = null;
|
|
532
|
+
}
|
|
533
|
+
if (sharedPresencePollTimer) {
|
|
534
|
+
clearInterval(sharedPresencePollTimer);
|
|
535
|
+
sharedPresencePollTimer = null;
|
|
536
|
+
}
|
|
537
|
+
if (liveConversationPollTimer) {
|
|
538
|
+
clearInterval(liveConversationPollTimer);
|
|
539
|
+
liveConversationPollTimer = null;
|
|
540
|
+
}
|
|
541
|
+
sharedConversationPollInFlight = false;
|
|
542
|
+
sharedConversationRequestSeq = 0;
|
|
543
|
+
sharedConversationAppliedSeq = 0;
|
|
544
|
+
liveConversationPollInFlight = false;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function isAppForegrounded() {
|
|
548
|
+
return document.visibilityState === 'visible';
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Polling gates on foreground state alone. It deliberately ignores the recovery
|
|
552
|
+
// latch: polling is the fallback that keeps the UI current while the socket is
|
|
553
|
+
// down, so a slow or stalled recovery pass must not silence it.
|
|
554
|
+
function shouldRunForegroundNetworkWork() {
|
|
555
|
+
return isAppForegrounded();
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function setForegroundNetworkWorkEnabled(enabled) {
|
|
559
|
+
const next = !!enabled;
|
|
560
|
+
setNetworkRequestsEnabled(next);
|
|
561
|
+
setSocketActivityEnabled(next);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function clearForegroundRecoveryLatch() {
|
|
565
|
+
if (foregroundRecoveryWatchdogTimer) {
|
|
566
|
+
clearTimeout(foregroundRecoveryWatchdogTimer);
|
|
567
|
+
foregroundRecoveryWatchdogTimer = null;
|
|
568
|
+
}
|
|
569
|
+
foregroundRecoveryInFlight = false;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
async function runForegroundRecovery(reason = 'visible') {
|
|
573
|
+
if (!isAppForegrounded()) return;
|
|
574
|
+
// Re-enabling the transport happens ahead of the stampede guard. An explicit
|
|
575
|
+
// socket.disconnect() drops socket.io's own reconnect subscriptions, so this is
|
|
576
|
+
// the only route back online and it must never be skippable.
|
|
577
|
+
setForegroundNetworkWorkEnabled(true);
|
|
578
|
+
if (foregroundRecoveryInFlight) {
|
|
579
|
+
recordStatusEvent('foreground-recovery-skipped', { reason });
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
const generation = ++foregroundRecoveryGeneration;
|
|
583
|
+
foregroundRecoveryInFlight = true;
|
|
584
|
+
foregroundRecoveryWatchdogTimer = setTimeout(() => {
|
|
585
|
+
foregroundRecoveryWatchdogTimer = null;
|
|
586
|
+
foregroundRecoveryInFlight = false;
|
|
587
|
+
recordStatusEvent('foreground-recovery-timeout', {
|
|
588
|
+
reason,
|
|
589
|
+
timeoutMs: FOREGROUND_RECOVERY_TIMEOUT_MS,
|
|
590
|
+
});
|
|
591
|
+
}, FOREGROUND_RECOVERY_TIMEOUT_MS);
|
|
592
|
+
try {
|
|
593
|
+
if (appSharedMode) {
|
|
594
|
+
await refreshSharedConversation();
|
|
595
|
+
await pulseSharedViewerPresence();
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
// Each step recovers a different slice of missed state; isolate their
|
|
599
|
+
// failures so one fetch losing a race with a restarting relay cannot
|
|
600
|
+
// skip the rest — refreshCurrentView in particular is the only
|
|
601
|
+
// correction path for change events this client missed while suspended
|
|
602
|
+
// (an emptied background-task store never re-announces itself).
|
|
603
|
+
const recoverySteps = [
|
|
604
|
+
['session-worker-status', () => refreshSessionWorkerStatus()],
|
|
605
|
+
['current-view', () => refreshCurrentView()],
|
|
606
|
+
['model-catalog', () => refreshModelCatalog(true)],
|
|
607
|
+
['relay-questions', () => loadRelayQuestions(currentConvId)],
|
|
608
|
+
['relay-boards', () => loadRelayBoards()],
|
|
609
|
+
];
|
|
610
|
+
for (const [step, run] of recoverySteps) {
|
|
611
|
+
try {
|
|
612
|
+
await run();
|
|
613
|
+
} catch (error) {
|
|
614
|
+
console.warn(`[foreground-recovery:${reason}] ${step}`, error?.message || error);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
} catch (error) {
|
|
618
|
+
console.warn(`[foreground-recovery:${reason}]`, error?.message || error);
|
|
619
|
+
} finally {
|
|
620
|
+
// The watchdog may already have released the latch and let a newer pass take
|
|
621
|
+
// ownership; only the pass that still owns it may clear it.
|
|
622
|
+
if (foregroundRecoveryGeneration === generation) clearForegroundRecoveryLatch();
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function scheduleForegroundRecovery(reason = 'visible', { immediate = false } = {}) {
|
|
627
|
+
if (foregroundRecoveryTimer) {
|
|
628
|
+
clearTimeout(foregroundRecoveryTimer);
|
|
629
|
+
foregroundRecoveryTimer = null;
|
|
630
|
+
}
|
|
631
|
+
if (immediate) {
|
|
632
|
+
void runForegroundRecovery(reason);
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
foregroundRecoveryTimer = setTimeout(() => {
|
|
636
|
+
foregroundRecoveryTimer = null;
|
|
637
|
+
void runForegroundRecovery(reason);
|
|
638
|
+
}, FOREGROUND_RECOVERY_DEBOUNCE_MS);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function cancelBackgroundSuspend() {
|
|
642
|
+
if (!backgroundSuspendTimer) return;
|
|
643
|
+
clearTimeout(backgroundSuspendTimer);
|
|
644
|
+
backgroundSuspendTimer = null;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Defer the background suspend by the grace period so a quick app switch keeps
|
|
648
|
+
// the socket alive (and connectionStateRecovery never even has to replay).
|
|
649
|
+
function scheduleBackgroundSuspend() {
|
|
650
|
+
cancelBackgroundSuspend();
|
|
651
|
+
if (BACKGROUND_SUSPEND_GRACE_MS <= 0) {
|
|
652
|
+
setForegroundNetworkWorkEnabled(false);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
backgroundSuspendTimer = setTimeout(() => {
|
|
656
|
+
backgroundSuspendTimer = null;
|
|
657
|
+
// A throttled or sleep-delayed timer can fire at the exact moment the user
|
|
658
|
+
// returns; re-check visibility so it cannot tear down a healthy connection.
|
|
659
|
+
if (document.visibilityState !== 'hidden') return;
|
|
660
|
+
setForegroundNetworkWorkEnabled(false);
|
|
661
|
+
}, BACKGROUND_SUSPEND_GRACE_MS);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// A notification tap in the service worker either messages an existing window
|
|
665
|
+
// (copilot-open-conversation) or opens a fresh one with ?push_conv=<id>.
|
|
666
|
+
function initPushNotificationClientHooks() {
|
|
667
|
+
if (!('serviceWorker' in navigator)) return;
|
|
668
|
+
if (window.__pushNotificationHooksBound) return;
|
|
669
|
+
window.__pushNotificationHooksBound = true;
|
|
670
|
+
navigator.serviceWorker.addEventListener('message', (event) => {
|
|
671
|
+
const data = event.data || {};
|
|
672
|
+
if (data.type !== 'copilot-open-conversation') return;
|
|
673
|
+
const conversationId = String(data.conversationId || '').trim();
|
|
674
|
+
if (!conversationId) return;
|
|
675
|
+
void openConversation(conversationId).catch(() => {});
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function consumePushConversationDeepLink() {
|
|
680
|
+
const url = new URL(window.location.href);
|
|
681
|
+
const conversationId = String(url.searchParams.get('push_conv') || '').trim();
|
|
682
|
+
if (!conversationId) return '';
|
|
683
|
+
url.searchParams.delete('push_conv');
|
|
684
|
+
history.replaceState(null, document.title, `${url.pathname}${url.search}${url.hash}`);
|
|
685
|
+
return conversationId;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function handleForegroundTransition(reason, { immediate = false } = {}) {
|
|
689
|
+
if (!isAppForegrounded()) return;
|
|
690
|
+
// The app came back before the grace period elapsed; keep the socket.
|
|
691
|
+
cancelBackgroundSuspend();
|
|
692
|
+
// The transport comes back right away; only the data refresh is debounced.
|
|
693
|
+
setForegroundNetworkWorkEnabled(true);
|
|
694
|
+
// Tell the server this device is foregrounded again so push suppression
|
|
695
|
+
// resumes. If the socket is still reconnecting this is a no-op; the
|
|
696
|
+
// connect-time heartbeat covers that case.
|
|
697
|
+
emitDeviceVisibility(true);
|
|
698
|
+
// A socket that reports connected after a freeze may be a zombie whose
|
|
699
|
+
// ping-timeout timer died with the freeze; probe it instead of trusting it.
|
|
700
|
+
verifySocketLiveness();
|
|
701
|
+
scheduleForegroundRecovery(reason, { immediate });
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// socket.io gives up permanently after an explicit disconnect or a rejected
|
|
705
|
+
// handshake, so a dropped relay never comes back on its own while the app stays in
|
|
706
|
+
// the foreground. This is the safety net for that. It deliberately does not
|
|
707
|
+
// consult navigator.onLine: Android Chrome can report a stale `false` after a
|
|
708
|
+
// standby resume while fetches work fine, and that guard once muzzled the
|
|
709
|
+
// watchdog for the rest of the page's life. A failed connect attempt is cheap;
|
|
710
|
+
// a silently disabled watchdog is not.
|
|
711
|
+
//
|
|
712
|
+
// connect() alone cannot escape every wedged manager state (see
|
|
713
|
+
// relay-watchdog-policy.mjs), so ticks spent disconnected escalate to a hard
|
|
714
|
+
// reset of the socket.
|
|
715
|
+
function startRelayConnectionWatchdog() {
|
|
716
|
+
if (relayConnectionWatchdogTimer || appSharedMode) return;
|
|
717
|
+
relayConnectionWatchdogTimer = setInterval(() => {
|
|
718
|
+
if (!isAppForegrounded()) {
|
|
719
|
+
// Background ticks say nothing about the connection; a count carried
|
|
720
|
+
// across a resume could trigger a reset before reconnection had a chance.
|
|
721
|
+
relayWatchdogDisconnectedTicks = 0;
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
const state = ensureSocketConnected();
|
|
725
|
+
if (state === 'forced') recordStatusEvent('relay-reconnect-forced', {});
|
|
726
|
+
const advice = adviseWatchdogTick({ state, disconnectedTicks: relayWatchdogDisconnectedTicks });
|
|
727
|
+
relayWatchdogDisconnectedTicks = advice.disconnectedTicks;
|
|
728
|
+
if (advice.hardReset) {
|
|
729
|
+
hardResetSocket();
|
|
730
|
+
recordStatusEvent('relay-socket-hard-reset', {
|
|
731
|
+
state,
|
|
732
|
+
afterTicks: RELAY_WATCHDOG_HARD_RESET_TICKS,
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
}, RELAY_WATCHDOG_INTERVAL_MS);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Visibility is reported on transitions, but the server ages those reports out
|
|
739
|
+
// after 90s. Without a periodic re-assert, a user who simply sits and reads for
|
|
740
|
+
// longer than that stops looking active, and a turn finishing afterwards pushes a
|
|
741
|
+
// notification to the phone already in their hand.
|
|
742
|
+
function startDeviceVisibilityHeartbeat() {
|
|
743
|
+
if (deviceVisibilityHeartbeatTimer || appSharedMode) return;
|
|
744
|
+
deviceVisibilityHeartbeatTimer = setInterval(() => {
|
|
745
|
+
if (!isAppForegrounded()) return;
|
|
746
|
+
emitDeviceVisibility(true);
|
|
747
|
+
}, DEVICE_VISIBILITY_HEARTBEAT_MS);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function initNetworkLifecycleHandling() {
|
|
751
|
+
if (networkLifecycleBound) return;
|
|
752
|
+
networkLifecycleBound = true;
|
|
753
|
+
document.addEventListener('visibilitychange', () => {
|
|
754
|
+
if (document.visibilityState === 'hidden') {
|
|
755
|
+
// The "now hidden" heartbeat goes out immediately — the transport stays
|
|
756
|
+
// up for the grace period, so without it the device would keep looking
|
|
757
|
+
// active and suppress push notifications for every device.
|
|
758
|
+
emitDeviceVisibility(false);
|
|
759
|
+
scheduleBackgroundSuspend();
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
handleForegroundTransition('visibility');
|
|
763
|
+
});
|
|
764
|
+
window.addEventListener('online', () => {
|
|
765
|
+
handleForegroundTransition('online', { immediate: true });
|
|
766
|
+
});
|
|
767
|
+
window.addEventListener('offline', () => {
|
|
768
|
+
cancelBackgroundSuspend();
|
|
769
|
+
setForegroundNetworkWorkEnabled(false);
|
|
770
|
+
});
|
|
771
|
+
window.addEventListener('pageshow', () => {
|
|
772
|
+
handleForegroundTransition('pageshow', { immediate: true });
|
|
773
|
+
});
|
|
774
|
+
window.addEventListener('pagehide', () => {
|
|
775
|
+
// The page is going away; no grace period, suspend immediately.
|
|
776
|
+
cancelBackgroundSuspend();
|
|
777
|
+
setForegroundNetworkWorkEnabled(false);
|
|
778
|
+
});
|
|
779
|
+
// Android Chrome freezes backgrounded PWAs. A freeze/resume cycle can happen
|
|
780
|
+
// without any visibility transition, so resume needs its own recovery trigger.
|
|
781
|
+
document.addEventListener('freeze', () => {
|
|
782
|
+
// Timers will not run while frozen, so the pending grace timer is useless;
|
|
783
|
+
// suspend now while code can still run.
|
|
784
|
+
cancelBackgroundSuspend();
|
|
785
|
+
setForegroundNetworkWorkEnabled(false);
|
|
786
|
+
});
|
|
787
|
+
document.addEventListener('resume', () => {
|
|
788
|
+
handleForegroundTransition('resume', { immediate: true });
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function syncThemeMenuLabel() {
|
|
793
|
+
const button = document.getElementById('chat-menu-shared-theme-toggle');
|
|
794
|
+
if (!button) return;
|
|
795
|
+
const isLight = document.documentElement.getAttribute('data-theme') === 'light';
|
|
796
|
+
button.textContent = isLight ? '🌙 Night mode' : '☀️ Day mode';
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function initThemeMenuToggle() {
|
|
800
|
+
const themeBtn = document.getElementById('chat-menu-shared-theme-toggle');
|
|
801
|
+
if (!themeBtn) return;
|
|
802
|
+
themeBtn.hidden = false;
|
|
803
|
+
syncThemeMenuLabel();
|
|
804
|
+
if (themeBtn.dataset.bound === '1') return;
|
|
805
|
+
themeBtn.dataset.bound = '1';
|
|
806
|
+
bindMenuAction(themeBtn, (event) => {
|
|
807
|
+
event.preventDefault();
|
|
808
|
+
event.stopPropagation();
|
|
809
|
+
const isLight = document.documentElement.getAttribute('data-theme') === 'light';
|
|
810
|
+
updateTheme(isLight ? 'dark' : 'light');
|
|
811
|
+
syncThemeMenuLabel();
|
|
812
|
+
closeChatActionsMenu();
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function disableSharedControl(element, {
|
|
817
|
+
title = 'Shared conversation (read-only)',
|
|
818
|
+
hide = false,
|
|
819
|
+
} = {}) {
|
|
820
|
+
if (!element) return;
|
|
821
|
+
element.disabled = true;
|
|
822
|
+
element.setAttribute('aria-disabled', 'true');
|
|
823
|
+
if (title) element.title = title;
|
|
824
|
+
if (hide) element.hidden = true;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function hideSharedMenuEntry(element) {
|
|
828
|
+
if (!element) return;
|
|
829
|
+
element.hidden = true;
|
|
830
|
+
element.setAttribute('aria-hidden', 'true');
|
|
831
|
+
if ('disabled' in element) element.disabled = true;
|
|
832
|
+
if ('tabIndex' in element) element.tabIndex = -1;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function applySharedReaderUi() {
|
|
836
|
+
document.body.classList.add('shared-reader-mode', 'sidebar-collapsed');
|
|
837
|
+
closeSidebar();
|
|
838
|
+
const sidebar = document.getElementById('sidebar');
|
|
839
|
+
const sidebarResizer = document.getElementById('sidebar-resizer');
|
|
840
|
+
const sidebarToggle = document.getElementById('sidebar-toggle');
|
|
841
|
+
const newConversationBtn = document.getElementById('new-conv-btn');
|
|
842
|
+
const searchBtn = document.getElementById('message-search-btn');
|
|
843
|
+
const contextBtn = document.getElementById('context-btn');
|
|
844
|
+
const fullscreenBtn = document.getElementById('fullscreen-btn');
|
|
845
|
+
const input = document.getElementById('msg-input');
|
|
846
|
+
const sendBtn = document.getElementById('send-btn');
|
|
847
|
+
const attachBtn = document.getElementById('attach-btn');
|
|
848
|
+
const emojiBtn = document.getElementById('emoji-btn');
|
|
849
|
+
const repoDesktopBtn = document.getElementById('repo-browser-desktop-btn');
|
|
850
|
+
const repoInputBtn = document.getElementById('repo-browser-input-btn');
|
|
851
|
+
const repoFabBtn = document.getElementById('repo-browser-fab');
|
|
852
|
+
const modelSelect = document.getElementById('model-select');
|
|
853
|
+
const effortSelect = document.getElementById('reasoning-effort-select');
|
|
854
|
+
const modeSelect = document.getElementById('mode-select');
|
|
855
|
+
const chatMenuBtn = document.getElementById('chat-actions-menu-btn');
|
|
856
|
+
const chatMenu = document.getElementById('chat-actions-menu');
|
|
857
|
+
const sharedThemeBtn = document.getElementById('chat-menu-shared-theme-toggle');
|
|
858
|
+
const installBtn = document.getElementById('install-btn');
|
|
859
|
+
const activeElement = document.activeElement;
|
|
860
|
+
if (chatMenu && activeElement instanceof Element && chatMenu.contains(activeElement)) {
|
|
861
|
+
activeElement.blur();
|
|
862
|
+
}
|
|
863
|
+
closeChatActionsMenu();
|
|
864
|
+
if (sidebar) sidebar.hidden = true;
|
|
865
|
+
if (sidebarResizer) sidebarResizer.hidden = true;
|
|
866
|
+
if (sidebarToggle) sidebarToggle.hidden = true;
|
|
867
|
+
if (newConversationBtn) newConversationBtn.hidden = true;
|
|
868
|
+
disableSharedControl(searchBtn);
|
|
869
|
+
disableSharedControl(contextBtn);
|
|
870
|
+
if (fullscreenBtn) fullscreenBtn.hidden = true;
|
|
871
|
+
if (input) {
|
|
872
|
+
input.readOnly = true;
|
|
873
|
+
input.disabled = true;
|
|
874
|
+
input.placeholder = 'Shared conversation (read-only)';
|
|
875
|
+
}
|
|
876
|
+
disableSharedControl(sendBtn);
|
|
877
|
+
disableSharedControl(attachBtn);
|
|
878
|
+
disableSharedControl(emojiBtn);
|
|
879
|
+
disableSharedControl(repoDesktopBtn, { hide: true });
|
|
880
|
+
disableSharedControl(repoInputBtn);
|
|
881
|
+
if (repoFabBtn) repoFabBtn.hidden = true;
|
|
882
|
+
disableSharedControl(installBtn, { hide: true, title: 'PWA install is unavailable for shared conversations' });
|
|
883
|
+
if (modelSelect) modelSelect.hidden = true;
|
|
884
|
+
if (effortSelect) effortSelect.hidden = true;
|
|
885
|
+
if (modeSelect) modeSelect.hidden = true;
|
|
886
|
+
if (chatMenu && sharedThemeBtn) {
|
|
887
|
+
for (const child of Array.from(chatMenu.children)) {
|
|
888
|
+
if (child !== sharedThemeBtn) hideSharedMenuEntry(child);
|
|
889
|
+
}
|
|
890
|
+
sharedThemeBtn.hidden = false;
|
|
891
|
+
sharedThemeBtn.removeAttribute('aria-hidden');
|
|
892
|
+
sharedThemeBtn.disabled = false;
|
|
893
|
+
sharedThemeBtn.tabIndex = 0;
|
|
894
|
+
syncThemeMenuLabel();
|
|
895
|
+
} else if (chatMenuBtn) {
|
|
896
|
+
chatMenuBtn.hidden = true;
|
|
897
|
+
}
|
|
898
|
+
if (chatMenuBtn && !sharedThemeBtn) chatMenuBtn.hidden = true;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function syncChatTitleWatcherIndicator() {
|
|
902
|
+
const indicator = document.getElementById('chat-title-watch-indicator');
|
|
903
|
+
if (!indicator) return;
|
|
904
|
+
if (isSharedReaderMode()) {
|
|
905
|
+
indicator.hidden = true;
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
const convId = String(currentConvId || '').trim();
|
|
909
|
+
const watcherCount = convId ? getConversationWatcherCount(convId) : 0;
|
|
910
|
+
indicator.hidden = watcherCount <= 0;
|
|
911
|
+
indicator.title = watcherCount > 0 ? `${watcherCount} watcher${watcherCount === 1 ? '' : 's'} currently reading this conversation` : '';
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function computeSharedConversationRenderKey(response = null) {
|
|
915
|
+
const messages = Array.isArray(response?.messages) ? response.messages : [];
|
|
916
|
+
const ids = messages.map((message) => `${String(message?.id || '').trim()}:${String(message?.timestamp || '').trim()}`).join('|');
|
|
917
|
+
const inFlight = response?.inFlight || null;
|
|
918
|
+
return [
|
|
919
|
+
String(response?.id || '').trim(),
|
|
920
|
+
String(response?.updatedAt || '').trim(),
|
|
921
|
+
messages.length,
|
|
922
|
+
ids,
|
|
923
|
+
String(inFlight?.messageId || '').trim(),
|
|
924
|
+
String(inFlight?.status || '').trim(),
|
|
925
|
+
String(inFlight?.lastStreamSeq || '').trim(),
|
|
926
|
+
inFlight?.streamDone ? '1' : '0',
|
|
927
|
+
].join('::');
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function resolveStableScrollTopForLiveRefresh(messagesEl, capturedScrollTop) {
|
|
931
|
+
const captured = Number(capturedScrollTop);
|
|
932
|
+
const current = Number(messagesEl?.scrollTop);
|
|
933
|
+
if (!Number.isFinite(current)) return Number.isFinite(captured) ? captured : 0;
|
|
934
|
+
if (!Number.isFinite(captured)) return current;
|
|
935
|
+
if (Math.abs(current - captured) > 2) return current;
|
|
936
|
+
return captured;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
async function refreshSharedConversation() {
|
|
940
|
+
if (sharedConversationPollInFlight) return;
|
|
941
|
+
const sharedToken = resolveSharedTokenFromLocation();
|
|
942
|
+
if (!sharedToken) return;
|
|
943
|
+
sharedConversationPollInFlight = true;
|
|
944
|
+
const requestSeq = ++sharedConversationRequestSeq;
|
|
945
|
+
const messagesEl = document.getElementById('messages');
|
|
946
|
+
const preserveBottom = isMessagesAtBottom();
|
|
947
|
+
const savedScrollTop = messagesEl?.scrollTop || 0;
|
|
948
|
+
try {
|
|
949
|
+
const response = await loadSharedConversation(sharedToken, { limit: 120 });
|
|
950
|
+
if (requestSeq < sharedConversationAppliedSeq) return;
|
|
951
|
+
if (!response?.ok) {
|
|
952
|
+
const message = String(response?.error || 'Could not load shared conversation.').trim();
|
|
953
|
+
if (message && message !== sharedConversationLastError) {
|
|
954
|
+
setModelBanner(`⚠️ ${message}`);
|
|
955
|
+
sharedConversationLastError = message;
|
|
956
|
+
}
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
if (sharedConversationLastError) {
|
|
960
|
+
sharedConversationLastError = '';
|
|
961
|
+
setModelBanner('');
|
|
962
|
+
}
|
|
963
|
+
const convId = String(response.id || '').trim();
|
|
964
|
+
if (!convId) return;
|
|
965
|
+
setCurrentConv(convId);
|
|
966
|
+
conversations[convId] = {
|
|
967
|
+
...(conversations[convId] || {}),
|
|
968
|
+
id: convId,
|
|
969
|
+
title: String(response.title || 'Shared conversation').trim() || 'Shared conversation',
|
|
970
|
+
updatedAt: response.updatedAt || new Date().toISOString(),
|
|
971
|
+
messageCount: Array.isArray(response.messages) ? response.messages.length : 0,
|
|
972
|
+
sdkSessionId: null,
|
|
973
|
+
runtimeSessionId: null,
|
|
974
|
+
};
|
|
975
|
+
const titleEl = document.getElementById('chat-title');
|
|
976
|
+
if (titleEl) titleEl.textContent = conversations[convId].title;
|
|
977
|
+
const renderKey = computeSharedConversationRenderKey(response);
|
|
978
|
+
if (renderKey && renderKey === sharedConversationRenderKey) {
|
|
979
|
+
sharedConversationAppliedSeq = requestSeq;
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
sharedConversationRenderKey = renderKey;
|
|
983
|
+
const stableSavedScrollTop = resolveStableScrollTopForLiveRefresh(messagesEl, savedScrollTop);
|
|
984
|
+
applyLoadedConversationState(convId, response, {
|
|
985
|
+
restoreScroll: !preserveBottom,
|
|
986
|
+
savedScrollTop: preserveBottom ? null : stableSavedScrollTop,
|
|
987
|
+
followLiveUpdates: preserveBottom,
|
|
988
|
+
});
|
|
989
|
+
sharedConversationAppliedSeq = requestSeq;
|
|
990
|
+
syncViewportMetrics();
|
|
991
|
+
} finally {
|
|
992
|
+
sharedConversationPollInFlight = false;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
async function pulseSharedViewerPresence() {
|
|
997
|
+
const sharedToken = resolveSharedTokenFromLocation();
|
|
998
|
+
if (!sharedToken) return;
|
|
999
|
+
await reportSharedViewerPresence(sharedToken, sharedViewerId()).catch(() => {});
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
async function initSharedConversationReader() {
|
|
1003
|
+
applySharedReaderUi();
|
|
1004
|
+
await refreshSharedConversation();
|
|
1005
|
+
await pulseSharedViewerPresence();
|
|
1006
|
+
stopSharedModeTimers();
|
|
1007
|
+
sharedConversationPollTimer = setInterval(() => {
|
|
1008
|
+
if (!shouldRunForegroundNetworkWork()) return;
|
|
1009
|
+
void refreshSharedConversation();
|
|
1010
|
+
}, 900);
|
|
1011
|
+
sharedPresencePollTimer = setInterval(() => {
|
|
1012
|
+
if (!shouldRunForegroundNetworkWork()) return;
|
|
1013
|
+
void pulseSharedViewerPresence();
|
|
1014
|
+
}, 12_000);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function showAuthError(msg) {
|
|
1018
|
+
document.getElementById('auth-error').textContent = msg;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
function resolveAuthErrorMessage(result = null) {
|
|
1022
|
+
if (result?.status === 401) return 'Invalid token';
|
|
1023
|
+
if (result?.status === 403) return 'Access denied';
|
|
1024
|
+
if (result?.status > 0) return `Authentication failed (${result.status})`;
|
|
1025
|
+
return String(result?.error || 'Could not reach the relay').trim() || 'Could not reach the relay';
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function syncPwaVersionMenuEntry() {
|
|
1029
|
+
const chip = document.getElementById('chat-menu-pwa-version');
|
|
1030
|
+
const value = document.getElementById('chat-menu-pwa-version-value');
|
|
1031
|
+
if (!chip) return;
|
|
1032
|
+
const version = String(window.__COPILOT_PWA_VERSION || '').trim();
|
|
1033
|
+
if (value) {
|
|
1034
|
+
value.textContent = version ? `v${version}` : 'v?';
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
chip.textContent = version ? `PWA shell version: v${version}` : 'PWA shell version: v?';
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function normalizeQueueCount(value) {
|
|
1041
|
+
const parsed = Number(value);
|
|
1042
|
+
return Number.isFinite(parsed) && parsed >= 0 ? Math.trunc(parsed) : 0;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function updateQueueStatusFromPayload(payload = null) {
|
|
1046
|
+
if (!payload || typeof payload !== 'object') return;
|
|
1047
|
+
latestQueueStatus = {
|
|
1048
|
+
pendingCount: normalizeQueueCount(payload.pendingCount ?? payload.queue?.pendingCount),
|
|
1049
|
+
processingCount: normalizeQueueCount(payload.processingCount ?? payload.queue?.processingCount),
|
|
1050
|
+
parkedCount: normalizeQueueCount(payload.parkedCount ?? payload.queue?.parkedCount),
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function syncQueueStatusMenuEntry(payload = null) {
|
|
1055
|
+
if (payload) updateQueueStatusFromPayload(payload);
|
|
1056
|
+
const chip = document.getElementById('chat-menu-queue-status');
|
|
1057
|
+
const value = document.getElementById('chat-menu-queue-status-value');
|
|
1058
|
+
if (!chip) return;
|
|
1059
|
+
const statusText = `pending=${latestQueueStatus.pendingCount}, processing=${latestQueueStatus.processingCount}, parked=${latestQueueStatus.parkedCount}`;
|
|
1060
|
+
if (value) {
|
|
1061
|
+
value.textContent = statusText;
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
chip.textContent = `Queue: ${statusText}`;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
function splitVariantId(modelVariantId = '') {
|
|
1069
|
+
const value = String(modelVariantId || '').trim();
|
|
1070
|
+
if (!value) return { baseModelId: '', reasoningEffort: null };
|
|
1071
|
+
const match = value.match(/^(.*)-(none|low|medium|high|xhigh|max)$/i);
|
|
1072
|
+
if (!match) return { baseModelId: value, reasoningEffort: null };
|
|
1073
|
+
return {
|
|
1074
|
+
baseModelId: String(match[1] || '').trim(),
|
|
1075
|
+
reasoningEffort: String(match[2] || '').trim().toLowerCase(),
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function humanizeModelLabel(modelId = '') {
|
|
1080
|
+
const text = String(modelId || '').trim();
|
|
1081
|
+
if (!text) return '';
|
|
1082
|
+
if (/^gpt-/i.test(text)) {
|
|
1083
|
+
return text
|
|
1084
|
+
.replace(/^gpt-/i, 'GPT-')
|
|
1085
|
+
.replace(/-codex$/i, ' Codex')
|
|
1086
|
+
.replace(/-mini$/i, ' Mini');
|
|
1087
|
+
}
|
|
1088
|
+
if (/^claude-/i.test(text)) {
|
|
1089
|
+
return text
|
|
1090
|
+
.replace(/^claude-/i, 'Claude ')
|
|
1091
|
+
.split('-')
|
|
1092
|
+
.map((part) => (/^\d+(\.\d+)?$/.test(part) ? part : (part.charAt(0).toUpperCase() + part.slice(1))))
|
|
1093
|
+
.join(' ');
|
|
1094
|
+
}
|
|
1095
|
+
if (/^gemini-/i.test(text)) {
|
|
1096
|
+
return text
|
|
1097
|
+
.replace(/^gemini-/i, 'Gemini ')
|
|
1098
|
+
.split('-')
|
|
1099
|
+
.map((part) => (/^\d+(\.\d+)?$/.test(part) ? part : (part.charAt(0).toUpperCase() + part.slice(1))))
|
|
1100
|
+
.join(' ');
|
|
1101
|
+
}
|
|
1102
|
+
return text;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function modelOptionLabel(modelVariantId = '') {
|
|
1106
|
+
if (String(modelVariantId || '').trim().toLowerCase() === AUTO_MODEL_OPTION) return 'Auto';
|
|
1107
|
+
const { baseModelId, reasoningEffort } = splitVariantId(modelVariantId);
|
|
1108
|
+
if (!baseModelId) return modelVariantId;
|
|
1109
|
+
const baseLabel = humanizeModelLabel(baseModelId);
|
|
1110
|
+
return reasoningEffort ? `${baseLabel} (${reasoningEffort})` : baseLabel;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function normalizeReasoningEffortList(efforts = []) {
|
|
1114
|
+
const values = Array.isArray(efforts)
|
|
1115
|
+
? efforts.map((value) => String(value || '').trim().toLowerCase()).filter(Boolean)
|
|
1116
|
+
: [];
|
|
1117
|
+
return Array.from(new Set(values));
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function isModelMetadataHealthy(payload = modelCatalogState) {
|
|
1121
|
+
if (!payload || typeof payload !== 'object') return false;
|
|
1122
|
+
if (payload.metadataValid === false) return false;
|
|
1123
|
+
if (payload.stale) return false;
|
|
1124
|
+
const reasoningByModel = payload.reasoningByModel && typeof payload.reasoningByModel === 'object'
|
|
1125
|
+
? payload.reasoningByModel
|
|
1126
|
+
: {};
|
|
1127
|
+
const modelIds = Object.keys(reasoningByModel).filter((modelId) => modelId !== AUTO_MODEL_OPTION);
|
|
1128
|
+
if (!modelIds.length) return false;
|
|
1129
|
+
return modelIds.every((modelId) => {
|
|
1130
|
+
const efforts = reasoningByModel[modelId];
|
|
1131
|
+
return Array.isArray(efforts) && efforts.length > 0;
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
function syncModelMetadataBlocker(message = '') {
|
|
1136
|
+
// Shared readers have no model picker, so metadata problems are not actionable there.
|
|
1137
|
+
if (isSharedReaderMode()) {
|
|
1138
|
+
document.getElementById('model-metadata-blocker')?.classList.remove('visible');
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
const blocker = document.getElementById('model-metadata-blocker');
|
|
1142
|
+
const text = document.getElementById('model-metadata-blocker-text');
|
|
1143
|
+
const retryBtn = document.getElementById('model-metadata-retry-btn');
|
|
1144
|
+
const blocked = modelMetadataBlocked || !isModelMetadataHealthy();
|
|
1145
|
+
if (text) {
|
|
1146
|
+
text.textContent = String(message || '').trim()
|
|
1147
|
+
|| 'Model metadata is unavailable. Refresh to choose a model and reasoning effort.';
|
|
1148
|
+
}
|
|
1149
|
+
if (retryBtn) retryBtn.disabled = modelMetadataRetryInFlight;
|
|
1150
|
+
blocker?.classList.toggle('visible', blocked);
|
|
1151
|
+
const modelSelect = document.getElementById('model-select');
|
|
1152
|
+
const reasoningSelect = document.getElementById('reasoning-effort-select');
|
|
1153
|
+
if (modelSelect) {
|
|
1154
|
+
modelSelect.disabled = blocked;
|
|
1155
|
+
modelSelect.title = blocked ? 'Model metadata unavailable' : 'Model';
|
|
1156
|
+
}
|
|
1157
|
+
if (reasoningSelect) {
|
|
1158
|
+
reasoningSelect.disabled = blocked;
|
|
1159
|
+
reasoningSelect.title = blocked ? 'Reasoning metadata unavailable' : 'Reasoning effort';
|
|
1160
|
+
}
|
|
1161
|
+
window.syncComposerControlState?.();
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function currentConversationHasMessages() {
|
|
1165
|
+
const conversation = currentConvId ? conversations[currentConvId] : null;
|
|
1166
|
+
return Number(conversation?.messageCount || 0) > 0;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function currentOpenAIModelLock() {
|
|
1170
|
+
const conversation = currentConvId ? conversations[currentConvId] : null;
|
|
1171
|
+
const providerType = String(
|
|
1172
|
+
conversation?.runtimeProviderType
|
|
1173
|
+
|| conversation?.runtime_provider_type
|
|
1174
|
+
|| '',
|
|
1175
|
+
).trim().toLowerCase();
|
|
1176
|
+
const providerIsOpenAI = providerType === 'openai' || providerType === 'openai-byok';
|
|
1177
|
+
if (!providerIsOpenAI || !currentConversationHasMessages()) return null;
|
|
1178
|
+
return {
|
|
1179
|
+
model: String(
|
|
1180
|
+
conversation?.runtimeProviderModel
|
|
1181
|
+
|| conversation?.runtime_provider_model
|
|
1182
|
+
|| '',
|
|
1183
|
+
).trim(),
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// A Grok conversation's model is fixed once the first message exists: the ACP
|
|
1188
|
+
// session cannot switch models mid-session and the relay 409s any attempt, so
|
|
1189
|
+
// the composer pins the picker instead of offering a switch that would fail.
|
|
1190
|
+
function currentGrokModelLock() {
|
|
1191
|
+
const conversation = currentConvId ? conversations[currentConvId] : null;
|
|
1192
|
+
const providerType = String(
|
|
1193
|
+
conversation?.runtimeProviderType
|
|
1194
|
+
|| conversation?.runtime_provider_type
|
|
1195
|
+
|| '',
|
|
1196
|
+
).trim().toLowerCase();
|
|
1197
|
+
if (providerType !== 'grok' || !currentConversationHasMessages()) return null;
|
|
1198
|
+
return {
|
|
1199
|
+
model: String(
|
|
1200
|
+
conversation?.runtimeProviderModel
|
|
1201
|
+
|| conversation?.runtime_provider_model
|
|
1202
|
+
|| '',
|
|
1203
|
+
).trim(),
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
function currentRuntimeModelLock() {
|
|
1208
|
+
return currentOpenAIModelLock() || currentGrokModelLock();
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
// The runtime model decides the OpenAI/OpenAI Image distinction. Before the
|
|
1212
|
+
// first message the runtime model can still be rebound, so the composer
|
|
1213
|
+
// selection is the fresher source there.
|
|
1214
|
+
function sessionLockModelForCurrentConversation() {
|
|
1215
|
+
const conversation = currentConvId ? conversations[currentConvId] : null;
|
|
1216
|
+
const runtimeModel = String(
|
|
1217
|
+
conversation?.runtimeProviderModel
|
|
1218
|
+
|| conversation?.runtime_provider_model
|
|
1219
|
+
|| conversation?.runtimeModel
|
|
1220
|
+
|| conversation?.runtime_model
|
|
1221
|
+
|| '',
|
|
1222
|
+
).trim();
|
|
1223
|
+
if (currentConversationHasMessages()) return runtimeModel;
|
|
1224
|
+
return String(document.getElementById('model-select')?.value || '').trim() || runtimeModel;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
function currentSessionProviderLock({ pinnedModel = '' } = {}) {
|
|
1228
|
+
// Shared readers have no model picker, so a lock note would be noise.
|
|
1229
|
+
if (!currentConvId || isSharedReaderMode()) return null;
|
|
1230
|
+
const providerType = activeComposerProviderType();
|
|
1231
|
+
const model = sessionLockModelForCurrentConversation();
|
|
1232
|
+
const noteText = sessionLockNoteText({ providerType, model, pinnedModel });
|
|
1233
|
+
if (!noteText) return null;
|
|
1234
|
+
return { providerKey: sessionLockProviderKey({ providerType, model }), noteText };
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
function syncAutoModelAvailability() {
|
|
1238
|
+
const select = document.getElementById('model-select');
|
|
1239
|
+
if (!select) return;
|
|
1240
|
+
updateModeSelectorForProvider();
|
|
1241
|
+
const currentProviderScope = normalizeModelSelectorProviderType(activeComposerProviderType());
|
|
1242
|
+
if (select.dataset.providerScope !== currentProviderScope) {
|
|
1243
|
+
updateModelCatalogState(modelCatalogState);
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
const autoOption = Array.from(select.options).find((option) => option.value.toLowerCase() === AUTO_MODEL_OPTION);
|
|
1247
|
+
const hasMessages = currentConversationHasMessages();
|
|
1248
|
+
const runtimeLock = currentRuntimeModelLock();
|
|
1249
|
+
for (const option of Array.from(select.options)) {
|
|
1250
|
+
if (option.dataset.runtimeModelLock === '1' && option.value !== runtimeLock?.model) {
|
|
1251
|
+
option.remove();
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
if (autoOption) {
|
|
1255
|
+
autoOption.disabled = hasMessages;
|
|
1256
|
+
autoOption.title = hasMessages ? 'Auto is available only for a new conversation' : '';
|
|
1257
|
+
}
|
|
1258
|
+
if (hasMessages && select.value.toLowerCase() === AUTO_MODEL_OPTION) {
|
|
1259
|
+
const fallback = [
|
|
1260
|
+
modelCatalogState.currentModel,
|
|
1261
|
+
modelCatalogState.defaultModel,
|
|
1262
|
+
...modelCatalogState.models,
|
|
1263
|
+
].find((modelId) => String(modelId || '').trim().toLowerCase() !== AUTO_MODEL_OPTION
|
|
1264
|
+
&& Array.from(select.options).some((option) => option.value === modelId));
|
|
1265
|
+
if (fallback) select.value = fallback;
|
|
1266
|
+
}
|
|
1267
|
+
if (runtimeLock?.model && !Array.from(select.options).some((option) => option.value === runtimeLock.model)) {
|
|
1268
|
+
const option = document.createElement('option');
|
|
1269
|
+
option.value = runtimeLock.model;
|
|
1270
|
+
option.textContent = `🔒 ${modelOptionLabel(runtimeLock.model)}`;
|
|
1271
|
+
option.dataset.runtimeModelLock = '1';
|
|
1272
|
+
select.appendChild(option);
|
|
1273
|
+
}
|
|
1274
|
+
if (runtimeLock?.model) {
|
|
1275
|
+
select.value = runtimeLock.model;
|
|
1276
|
+
}
|
|
1277
|
+
select.dataset.runtimeModelLocked = runtimeLock ? '1' : '0';
|
|
1278
|
+
const metadataBlocked = modelMetadataBlocked || !isModelMetadataHealthy();
|
|
1279
|
+
select.disabled = metadataBlocked || !!runtimeLock;
|
|
1280
|
+
select.title = runtimeLock
|
|
1281
|
+
? `Model locked to ${runtimeLock.model || 'the configured provider model'} for this active session`
|
|
1282
|
+
: (metadataBlocked ? 'Model metadata unavailable' : 'Model');
|
|
1283
|
+
syncSessionLockNote({ pinnedModel: runtimeLock?.model || '' });
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
function syncSessionLockNote({ pinnedModel = '' } = {}) {
|
|
1287
|
+
const note = document.getElementById('model-lock-note');
|
|
1288
|
+
if (!note) return;
|
|
1289
|
+
const providerLock = currentSessionProviderLock({ pinnedModel });
|
|
1290
|
+
note.hidden = !providerLock;
|
|
1291
|
+
note.textContent = providerLock?.noteText || '';
|
|
1292
|
+
if (providerLock?.providerKey) {
|
|
1293
|
+
note.dataset.provider = providerLock.providerKey;
|
|
1294
|
+
} else {
|
|
1295
|
+
delete note.dataset.provider;
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
function applyModelMetadataHardFail(message = '') {
|
|
1300
|
+
if (isSharedReaderMode()) return;
|
|
1301
|
+
modelMetadataBlocked = true;
|
|
1302
|
+
syncModelMetadataBlocker(message);
|
|
1303
|
+
setModelBanner(`⚠️ ${String(message || 'Model metadata is unavailable.').trim()}`);
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
function clearModelMetadataHardFail() {
|
|
1307
|
+
modelMetadataBlocked = false;
|
|
1308
|
+
syncModelMetadataBlocker('');
|
|
1309
|
+
if (!modelCatalogState.warning && !modelCatalogState.stale) {
|
|
1310
|
+
setModelBanner('');
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
async function startAppWithErrorHandling() {
|
|
1315
|
+
try {
|
|
1316
|
+
await initApp();
|
|
1317
|
+
return true;
|
|
1318
|
+
} catch (error) {
|
|
1319
|
+
console.error('[bootstrap] initApp failed', error);
|
|
1320
|
+
showAuthGate();
|
|
1321
|
+
showAuthError(`App initialization failed: ${String(error?.message || error || 'unknown error')}`);
|
|
1322
|
+
return false;
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
function activeComposerProviderType() {
|
|
1327
|
+
const conversation = conversations[currentConvId] || null;
|
|
1328
|
+
return String(
|
|
1329
|
+
conversation?.runtimeProviderType
|
|
1330
|
+
|| conversation?.runtime_provider_type
|
|
1331
|
+
|| 'github',
|
|
1332
|
+
).trim().toLowerCase();
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
function normalizeModelSelectorProviderType(providerType = '') {
|
|
1336
|
+
const normalized = String(providerType || '').trim().toLowerCase();
|
|
1337
|
+
if (normalized === 'openai' || normalized === 'openai-byok') return 'openai';
|
|
1338
|
+
if (normalized === 'claude') return 'claude';
|
|
1339
|
+
if (normalized === 'cursor') return 'cursor';
|
|
1340
|
+
if (normalized === 'grok') return 'grok';
|
|
1341
|
+
return 'github';
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
function modelProvidersForId(modelId, providersByModel = {}) {
|
|
1345
|
+
const key = String(modelId || '').trim().toLowerCase();
|
|
1346
|
+
if (!key) return [];
|
|
1347
|
+
const providers = providersByModel?.[key];
|
|
1348
|
+
if (!Array.isArray(providers)) return [];
|
|
1349
|
+
return providers.map((provider) => String(provider || '').trim().toLowerCase()).filter(Boolean);
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
function openAIImageSizesForModel(modelId = '') {
|
|
1353
|
+
const normalized = String(modelId || '').trim().toLowerCase().replace(/^openai\//, '');
|
|
1354
|
+
if (normalized.startsWith('dall-e-2')) return ['256x256', '512x512', '1024x1024'];
|
|
1355
|
+
if (normalized.startsWith('dall-e-3')) return ['1024x1024', '1792x1024', '1024x1792'];
|
|
1356
|
+
return ['auto', '1024x1024', '1536x1024', '1024x1536'];
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
function modelVisibleForActiveProvider(modelId, activeProviderType, providersByModel = {}) {
|
|
1360
|
+
const normalizedModelId = String(modelId || '').trim().toLowerCase();
|
|
1361
|
+
if (!normalizedModelId) return false;
|
|
1362
|
+
if (normalizedModelId === AUTO_MODEL_OPTION) return true;
|
|
1363
|
+
const providers = modelProvidersForId(normalizedModelId, providersByModel);
|
|
1364
|
+
const hasOpenAIByok = providers.includes('openai-byok');
|
|
1365
|
+
const hasClaude = providers.includes('claude');
|
|
1366
|
+
const hasCursor = providers.includes('cursor');
|
|
1367
|
+
const hasGrok = providers.includes('grok');
|
|
1368
|
+
const hasNonExclusiveProvider = providers.some((provider) => (
|
|
1369
|
+
provider !== 'openai-byok'
|
|
1370
|
+
&& provider !== 'claude'
|
|
1371
|
+
&& provider !== 'cursor'
|
|
1372
|
+
&& provider !== 'grok'
|
|
1373
|
+
));
|
|
1374
|
+
const activeProvider = normalizeModelSelectorProviderType(activeProviderType);
|
|
1375
|
+
if (activeProvider === 'openai') return hasOpenAIByok;
|
|
1376
|
+
if (activeProvider === 'claude') return hasClaude;
|
|
1377
|
+
if (activeProvider === 'cursor') return hasCursor;
|
|
1378
|
+
if (activeProvider === 'grok') return hasGrok;
|
|
1379
|
+
return !((hasOpenAIByok || hasClaude || hasCursor || hasGrok) && !hasNonExclusiveProvider);
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
function buildModelSelectorOptions(models = [], providersByModel = {}, activeProviderType = '') {
|
|
1383
|
+
const normalizedOptions = normalizeModelSelectorOptions(models.length ? models : [FALLBACK_MODEL], {
|
|
1384
|
+
autoValue: AUTO_MODEL_OPTION,
|
|
1385
|
+
labelFor: modelOptionLabel,
|
|
1386
|
+
});
|
|
1387
|
+
return normalizedOptions.filter((option) => modelVisibleForActiveProvider(
|
|
1388
|
+
option.value,
|
|
1389
|
+
activeProviderType,
|
|
1390
|
+
providersByModel,
|
|
1391
|
+
));
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
function reasoningOptionsForModel(modelId = '') {
|
|
1395
|
+
if (!isModelMetadataHealthy()) return [];
|
|
1396
|
+
const key = String(modelId || '').trim().toLowerCase();
|
|
1397
|
+
const provider = activeComposerProviderType();
|
|
1398
|
+
const providerOptions = modelCatalogState.reasoningByProvider?.[provider]?.[key];
|
|
1399
|
+
if (Array.isArray(providerOptions)) return normalizeReasoningEffortList(providerOptions);
|
|
1400
|
+
return normalizeReasoningEffortList(modelCatalogState.reasoningByModel?.[key] || []);
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
function reasoningProviderKey(providerType = '') {
|
|
1404
|
+
const key = String(providerType || '').trim().toLowerCase();
|
|
1405
|
+
if (key === 'openai-byok') return 'openai';
|
|
1406
|
+
if (key === 'github-copilot') return 'github';
|
|
1407
|
+
return key;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
function reasoningOptionsForProviderModel(providerType = '', modelId = '') {
|
|
1411
|
+
if (!isModelMetadataHealthy()) return [];
|
|
1412
|
+
const key = String(modelId || '').trim().toLowerCase();
|
|
1413
|
+
if (!key) return [];
|
|
1414
|
+
const provider = reasoningProviderKey(providerType);
|
|
1415
|
+
const providerOptions = modelCatalogState.reasoningByProvider?.[provider]?.[key];
|
|
1416
|
+
if (Array.isArray(providerOptions) && providerOptions.length) {
|
|
1417
|
+
return normalizeReasoningEffortList(providerOptions);
|
|
1418
|
+
}
|
|
1419
|
+
return normalizeReasoningEffortList(modelCatalogState.reasoningByModel?.[key] || []);
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
function selectedReasoningEffortValue() {
|
|
1423
|
+
const select = document.getElementById('reasoning-effort-select');
|
|
1424
|
+
const value = String(select?.value || '').trim().toLowerCase();
|
|
1425
|
+
if (value) return value;
|
|
1426
|
+
return FALLBACK_REASONING_EFFORT;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
// Opt in to persist=true only from a user-initiated change. Every other caller
|
|
1430
|
+
// is re-rendering options (catalog refresh, provider rescope, applying a stored
|
|
1431
|
+
// preference), and letting those write the shared fallback storage is what made
|
|
1432
|
+
// a transient resolution the remembered default.
|
|
1433
|
+
function updateReasoningSelectorForModel(modelId, preferredEffort = '', { persist = false } = {}) {
|
|
1434
|
+
const select = document.getElementById('reasoning-effort-select');
|
|
1435
|
+
if (!select) return;
|
|
1436
|
+
select.title = isOpenAIImageModelId(modelId) ? 'Quality' : 'Reasoning effort';
|
|
1437
|
+
const options = reasoningOptionsForModel(modelId);
|
|
1438
|
+
const currentEffort = String(select.value || '').trim().toLowerCase();
|
|
1439
|
+
const storedEffort = String(localStorage.getItem(REASONING_STORAGE_KEY) || '').trim().toLowerCase();
|
|
1440
|
+
select.innerHTML = '';
|
|
1441
|
+
if (!options.length) {
|
|
1442
|
+
const opt = document.createElement('option');
|
|
1443
|
+
opt.value = '';
|
|
1444
|
+
opt.textContent = 'Unavailable';
|
|
1445
|
+
select.appendChild(opt);
|
|
1446
|
+
select.value = '';
|
|
1447
|
+
return;
|
|
1448
|
+
}
|
|
1449
|
+
const reasoningOffUnsupported = isReasoningOffUnsupported(
|
|
1450
|
+
modelCatalogState,
|
|
1451
|
+
activeComposerProviderType(),
|
|
1452
|
+
modelId,
|
|
1453
|
+
);
|
|
1454
|
+
for (const effort of options) {
|
|
1455
|
+
const opt = document.createElement('option');
|
|
1456
|
+
opt.value = effort;
|
|
1457
|
+
opt.textContent = reasoningEffortOptionLabel(effort, { reasoningOffUnsupported });
|
|
1458
|
+
const optionTitle = reasoningEffortOptionTitle(effort);
|
|
1459
|
+
if (optionTitle) opt.title = optionTitle;
|
|
1460
|
+
select.appendChild(opt);
|
|
1461
|
+
}
|
|
1462
|
+
const resolved = resolveComposerReasoningEffort({
|
|
1463
|
+
preferredEffort,
|
|
1464
|
+
storedEffort,
|
|
1465
|
+
currentEffort,
|
|
1466
|
+
supportedEfforts: options,
|
|
1467
|
+
});
|
|
1468
|
+
select.value = resolved;
|
|
1469
|
+
if (persist && resolved) localStorage.setItem(REASONING_STORAGE_KEY, resolved);
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// Rebuilds the option list for the active conversation's provider without
|
|
1473
|
+
// touching the selection, mirroring updateModeSelectorForProvider. Preferences
|
|
1474
|
+
// used to be clamped against the previous provider's options, which is how a
|
|
1475
|
+
// Cursor conversation could keep the Claude model that was selected before it.
|
|
1476
|
+
function rebuildModelSelectorOptionsForProvider() {
|
|
1477
|
+
const select = document.getElementById('model-select');
|
|
1478
|
+
if (!select || isSharedReaderMode()) return false;
|
|
1479
|
+
const activeProviderType = activeComposerProviderType();
|
|
1480
|
+
const scope = normalizeModelSelectorProviderType(activeProviderType);
|
|
1481
|
+
if (select.dataset.providerScope === scope) return false;
|
|
1482
|
+
// Same rule as the catalog refresh: never re-render the list out from under
|
|
1483
|
+
// an open picker. The scope stays unset so the rebuild happens on blur.
|
|
1484
|
+
if (document.activeElement === select) return false;
|
|
1485
|
+
const nextOptions = buildModelSelectorOptions(
|
|
1486
|
+
modelCatalogState.models,
|
|
1487
|
+
modelCatalogState.providersByModel,
|
|
1488
|
+
activeProviderType,
|
|
1489
|
+
);
|
|
1490
|
+
const currentOptions = Array.from(select.options)
|
|
1491
|
+
.filter((option) => option.dataset.runtimeModelLock !== '1')
|
|
1492
|
+
.map((option) => ({ value: option.value, label: option.textContent }));
|
|
1493
|
+
if (!modelSelectorOptionsEqual(currentOptions, nextOptions)) {
|
|
1494
|
+
const selectedBefore = String(select.value || '').trim();
|
|
1495
|
+
const lockedOptions = Array.from(select.options).filter((option) => option.dataset.runtimeModelLock === '1');
|
|
1496
|
+
select.innerHTML = '';
|
|
1497
|
+
for (const option of nextOptions) {
|
|
1498
|
+
const opt = document.createElement('option');
|
|
1499
|
+
opt.value = option.value;
|
|
1500
|
+
opt.textContent = option.label;
|
|
1501
|
+
select.appendChild(opt);
|
|
1502
|
+
}
|
|
1503
|
+
for (const locked of lockedOptions) select.appendChild(locked);
|
|
1504
|
+
// Emptying the select resets the value to the first option, which would
|
|
1505
|
+
// hand the clamp below "auto" instead of what was actually selected.
|
|
1506
|
+
if (Array.from(select.options).some((option) => option.value === selectedBefore)) {
|
|
1507
|
+
select.value = selectedBefore;
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
select.dataset.providerScope = scope;
|
|
1511
|
+
return true;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
function updateModelCatalogState(payload) {
|
|
1515
|
+
// Shared readers never load the catalog, so this would only surface
|
|
1516
|
+
// "metadata unavailable" warnings for a picker they cannot see.
|
|
1517
|
+
if (isSharedReaderMode()) return;
|
|
1518
|
+
const select = document.getElementById('model-select');
|
|
1519
|
+
if (!select) return;
|
|
1520
|
+
const models = Array.isArray(payload?.models)
|
|
1521
|
+
? payload.models.map((m) => String(m || '').trim()).filter(Boolean)
|
|
1522
|
+
: [];
|
|
1523
|
+
const currentModel = String(payload?.currentModel || models[0] || '').trim();
|
|
1524
|
+
const defaultModel = String(payload?.defaultModel || models[0] || '').trim();
|
|
1525
|
+
const normalizedModelOptions = normalizeModelSelectorOptions(models.length ? models : [FALLBACK_MODEL], {
|
|
1526
|
+
autoValue: AUTO_MODEL_OPTION,
|
|
1527
|
+
labelFor: modelOptionLabel,
|
|
1528
|
+
});
|
|
1529
|
+
const normalizedModels = normalizedModelOptions.map((option) => option.value);
|
|
1530
|
+
|
|
1531
|
+
const nextState = {
|
|
1532
|
+
models: normalizedModels,
|
|
1533
|
+
currentModel: currentModel || normalizedModels[0] || FALLBACK_MODEL,
|
|
1534
|
+
defaultModel: defaultModel || normalizedModels[0] || FALLBACK_MODEL,
|
|
1535
|
+
reasoningByModel: payload?.reasoningByModel && typeof payload.reasoningByModel === 'object'
|
|
1536
|
+
? Object.fromEntries(Object.entries(payload.reasoningByModel).map(([modelId, efforts]) => [
|
|
1537
|
+
String(modelId || '').trim().toLowerCase(),
|
|
1538
|
+
normalizeReasoningEffortList(efforts),
|
|
1539
|
+
]))
|
|
1540
|
+
: {},
|
|
1541
|
+
reasoningByProvider: payload?.reasoningByProvider && typeof payload.reasoningByProvider === 'object'
|
|
1542
|
+
? Object.fromEntries(Object.entries(payload.reasoningByProvider).map(([provider, entries]) => [
|
|
1543
|
+
String(provider || '').trim().toLowerCase(),
|
|
1544
|
+
entries && typeof entries === 'object'
|
|
1545
|
+
? Object.fromEntries(Object.entries(entries).map(([modelId, efforts]) => [
|
|
1546
|
+
String(modelId || '').trim().toLowerCase(),
|
|
1547
|
+
normalizeReasoningEffortList(efforts),
|
|
1548
|
+
]))
|
|
1549
|
+
: {},
|
|
1550
|
+
]))
|
|
1551
|
+
: {},
|
|
1552
|
+
reasoningOffUnsupportedByProvider: payload?.reasoningOffUnsupportedByProvider && typeof payload.reasoningOffUnsupportedByProvider === 'object'
|
|
1553
|
+
? Object.fromEntries(Object.entries(payload.reasoningOffUnsupportedByProvider).map(([provider, entries]) => [
|
|
1554
|
+
String(provider || '').trim().toLowerCase(),
|
|
1555
|
+
entries && typeof entries === 'object'
|
|
1556
|
+
? Object.fromEntries(Object.entries(entries).map(([modelId, unsupported]) => [
|
|
1557
|
+
String(modelId || '').trim().toLowerCase(),
|
|
1558
|
+
unsupported === true,
|
|
1559
|
+
]))
|
|
1560
|
+
: {},
|
|
1561
|
+
]))
|
|
1562
|
+
: {},
|
|
1563
|
+
providersByModel: payload?.providersByModel && typeof payload.providersByModel === 'object'
|
|
1564
|
+
? Object.fromEntries(Object.entries(payload.providersByModel).map(([modelId, providers]) => [
|
|
1565
|
+
String(modelId || '').trim().toLowerCase(),
|
|
1566
|
+
Array.isArray(providers)
|
|
1567
|
+
? providers.map((provider) => String(provider || '').trim().toLowerCase()).filter(Boolean)
|
|
1568
|
+
: [],
|
|
1569
|
+
]))
|
|
1570
|
+
: {},
|
|
1571
|
+
reasoningEfforts: normalizeReasoningEffortList(payload?.reasoningEfforts || []),
|
|
1572
|
+
modelMetadataByModel: payload?.modelMetadataByModel && typeof payload.modelMetadataByModel === 'object'
|
|
1573
|
+
? payload.modelMetadataByModel
|
|
1574
|
+
: {},
|
|
1575
|
+
claudeContextTiersByModel: payload?.claudeContextTiersByModel && typeof payload.claudeContextTiersByModel === 'object'
|
|
1576
|
+
? Object.fromEntries(Object.entries(payload.claudeContextTiersByModel).map(([modelId, tiers]) => [
|
|
1577
|
+
String(modelId || '').trim().toLowerCase(),
|
|
1578
|
+
Array.isArray(tiers) ? tiers : [],
|
|
1579
|
+
]))
|
|
1580
|
+
: {},
|
|
1581
|
+
stale: !!payload?.stale,
|
|
1582
|
+
metadataValid: payload?.metadataValid === true,
|
|
1583
|
+
reasoningMetadataValid: payload?.reasoningMetadataValid === true,
|
|
1584
|
+
warning: payload?.warning ? String(payload.warning) : null,
|
|
1585
|
+
error: payload?.error ? String(payload.error) : null,
|
|
1586
|
+
refreshedAt: payload?.refreshedAt || null,
|
|
1587
|
+
};
|
|
1588
|
+
const activeProviderType = activeComposerProviderType();
|
|
1589
|
+
const nextOptions = buildModelSelectorOptions(
|
|
1590
|
+
normalizedModels,
|
|
1591
|
+
nextState.providersByModel,
|
|
1592
|
+
activeProviderType,
|
|
1593
|
+
);
|
|
1594
|
+
const nextModels = nextOptions.map((option) => option.value);
|
|
1595
|
+
const currentOptions = Array.from(select.options)
|
|
1596
|
+
.filter((option) => option.dataset.runtimeModelLock !== '1')
|
|
1597
|
+
.map((option) => ({
|
|
1598
|
+
value: option.value,
|
|
1599
|
+
label: option.textContent,
|
|
1600
|
+
}));
|
|
1601
|
+
const optionsChanged = !modelSelectorOptionsEqual(currentOptions, nextOptions);
|
|
1602
|
+
if (optionsChanged && document.activeElement === select) {
|
|
1603
|
+
deferredModelCatalogPayload = payload;
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
deferredModelCatalogPayload = null;
|
|
1607
|
+
|
|
1608
|
+
const nextHealthy = isModelMetadataHealthy(nextState);
|
|
1609
|
+
const currentlyHealthy = isModelMetadataHealthy(modelCatalogState);
|
|
1610
|
+
if (!nextHealthy && !currentlyHealthy && isModelMetadataHealthy(lastHealthyModelCatalogState)) {
|
|
1611
|
+
modelCatalogState = { ...lastHealthyModelCatalogState };
|
|
1612
|
+
clearModelMetadataHardFail();
|
|
1613
|
+
setModelBanner('⚠️ Model metadata refresh failed; restored last known good catalog.');
|
|
1614
|
+
syncModelMetadataBlocker();
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
if (!nextHealthy && currentlyHealthy) {
|
|
1618
|
+
setModelBanner('⚠️ Model metadata refresh failed; keeping last known good catalog.');
|
|
1619
|
+
syncModelMetadataBlocker();
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
modelCatalogState = nextState;
|
|
1624
|
+
|
|
1625
|
+
if (!nextHealthy) {
|
|
1626
|
+
applyModelMetadataHardFail(
|
|
1627
|
+
modelCatalogState.error
|
|
1628
|
+
? `Model metadata error: ${modelCatalogState.error}`
|
|
1629
|
+
: (modelCatalogState.warning || 'Model metadata is stale or incomplete.'),
|
|
1630
|
+
);
|
|
1631
|
+
} else {
|
|
1632
|
+
lastHealthyModelCatalogState = modelCatalogState;
|
|
1633
|
+
clearModelMetadataHardFail();
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
const selectedBefore = select.value;
|
|
1637
|
+
if (optionsChanged) {
|
|
1638
|
+
select.innerHTML = '';
|
|
1639
|
+
for (const option of nextOptions) {
|
|
1640
|
+
const opt = document.createElement('option');
|
|
1641
|
+
opt.value = option.value;
|
|
1642
|
+
opt.textContent = option.label;
|
|
1643
|
+
select.appendChild(opt);
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
select.dataset.providerScope = normalizeModelSelectorProviderType(activeProviderType);
|
|
1647
|
+
|
|
1648
|
+
// A catalog refresh re-renders options and keeps the current selection valid;
|
|
1649
|
+
// it must not decide what is selected. The active conversation's preferences
|
|
1650
|
+
// are reapplied at the end, so a refresh cannot overwrite the user's pick.
|
|
1651
|
+
const keptSelection = nextModels.includes(selectedBefore) ? selectedBefore : '';
|
|
1652
|
+
const nextSelection = keptSelection
|
|
1653
|
+
|| [
|
|
1654
|
+
localStorage.getItem(MODEL_STORAGE_KEY),
|
|
1655
|
+
modelCatalogState.currentModel,
|
|
1656
|
+
modelCatalogState.defaultModel,
|
|
1657
|
+
].map((value) => String(value || '').trim()).find((value) => value && nextModels.includes(value))
|
|
1658
|
+
|| nextModels[0];
|
|
1659
|
+
select.value = nextSelection;
|
|
1660
|
+
updateReasoningSelectorForModel(nextSelection, '');
|
|
1661
|
+
updateContextTierSelector(nextSelection);
|
|
1662
|
+
|
|
1663
|
+
if (modelCatalogState.warning && isModelMetadataHealthy(modelCatalogState)) {
|
|
1664
|
+
setModelBanner(`⚠️ ${modelCatalogState.warning}`);
|
|
1665
|
+
} else if (modelCatalogState.stale && isModelMetadataHealthy(modelCatalogState)) {
|
|
1666
|
+
setModelBanner('⚠️ Model list is cached from CLI; selection may be stale.');
|
|
1667
|
+
} else if (isModelMetadataHealthy(modelCatalogState)) {
|
|
1668
|
+
setModelBanner('');
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
syncModelMetadataBlocker();
|
|
1672
|
+
syncAutoModelAvailability();
|
|
1673
|
+
// The rebuilt catalog may now contain the conversation's preferred model for
|
|
1674
|
+
// the first time (provider models arrive after the initial catalog load).
|
|
1675
|
+
if (currentConvId) applyConversationPreferencesForConversation(currentConvId);
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
function selectedModelValue() {
|
|
1679
|
+
const select = document.getElementById('model-select');
|
|
1680
|
+
const value = String(select?.value || '').trim();
|
|
1681
|
+
if (value) return value;
|
|
1682
|
+
return modelCatalogState.currentModel || modelCatalogState.defaultModel || FALLBACK_MODEL;
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
function updateContextTierSelector(modelId) {
|
|
1686
|
+
const select = document.getElementById('context-tier-select');
|
|
1687
|
+
if (!select) return;
|
|
1688
|
+
if (isOpenAIImageModelId(modelId)) {
|
|
1689
|
+
const current = String(select.value || '').trim().toLowerCase();
|
|
1690
|
+
const imageSizes = openAIImageSizesForModel(modelId);
|
|
1691
|
+
select.innerHTML = '';
|
|
1692
|
+
for (const size of imageSizes) {
|
|
1693
|
+
const option = document.createElement('option');
|
|
1694
|
+
option.value = size;
|
|
1695
|
+
option.textContent = size;
|
|
1696
|
+
select.appendChild(option);
|
|
1697
|
+
}
|
|
1698
|
+
select.value = imageSizes.includes(current) ? current : 'auto';
|
|
1699
|
+
select.title = 'Image size';
|
|
1700
|
+
updateModelPricingDetails(modelId, 'default');
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
const tierOptions = buildContextTierOptions({
|
|
1704
|
+
modelId,
|
|
1705
|
+
providerType: activeComposerProviderType(),
|
|
1706
|
+
metadata: modelCatalogState.modelMetadataByModel?.[modelId] || {},
|
|
1707
|
+
claudeTiers: modelCatalogState.claudeContextTiersByModel,
|
|
1708
|
+
});
|
|
1709
|
+
const current = select.value;
|
|
1710
|
+
select.innerHTML = '';
|
|
1711
|
+
for (const tier of tierOptions) {
|
|
1712
|
+
const option = document.createElement('option');
|
|
1713
|
+
option.value = tier.value;
|
|
1714
|
+
option.textContent = tier.label;
|
|
1715
|
+
select.appendChild(option);
|
|
1716
|
+
}
|
|
1717
|
+
select.value = resolveContextTierValue(tierOptions, current);
|
|
1718
|
+
select.title = 'Context window';
|
|
1719
|
+
updateModelPricingDetails(modelId, select.value);
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
function updateModelPricingDetails(modelId, tier) {
|
|
1723
|
+
const details = document.getElementById('model-pricing-details');
|
|
1724
|
+
const summary = document.getElementById('model-pricing-summary');
|
|
1725
|
+
const grid = document.getElementById('model-pricing-grid');
|
|
1726
|
+
if (!details || !summary || !grid) return;
|
|
1727
|
+
const metadata = modelCatalogState.modelMetadataByModel?.[modelId] || {};
|
|
1728
|
+
const pricing = metadata?.pricing?.[tier === 'long_context' ? 'longContext' : 'default'];
|
|
1729
|
+
if (!pricing || typeof pricing !== 'object') {
|
|
1730
|
+
details.open = false;
|
|
1731
|
+
details.style.display = 'none';
|
|
1732
|
+
grid.replaceChildren();
|
|
1733
|
+
return;
|
|
1734
|
+
}
|
|
1735
|
+
const perMillion = (value) => {
|
|
1736
|
+
const price = Number(value);
|
|
1737
|
+
const batchSize = Number(pricing.batchSize) || 1000000;
|
|
1738
|
+
return Number.isFinite(price) ? (price * 1000000) / batchSize : null;
|
|
1739
|
+
};
|
|
1740
|
+
const input = perMillion(pricing.input);
|
|
1741
|
+
summary.textContent = `${tier === 'long_context' ? 'Long context' : 'Default'} pricing${input !== null ? ` · ${input} credits / 1M input` : ''}`;
|
|
1742
|
+
grid.replaceChildren();
|
|
1743
|
+
for (const [label, value] of [['Input', pricing.input], ['Output', pricing.output], ['Cache read', pricing.cacheRead], ['Cache write', pricing.cacheWrite]]) {
|
|
1744
|
+
const credits = perMillion(value);
|
|
1745
|
+
if (credits === null) continue;
|
|
1746
|
+
const labelEl = document.createElement('span');
|
|
1747
|
+
labelEl.textContent = label;
|
|
1748
|
+
const valueEl = document.createElement('b');
|
|
1749
|
+
valueEl.textContent = String(credits);
|
|
1750
|
+
grid.append(labelEl, valueEl);
|
|
1751
|
+
}
|
|
1752
|
+
details.style.display = grid.childElementCount ? '' : 'none';
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// Relay modes each provider backend actually honors. All four providers
|
|
1756
|
+
// currently support the full set — Copilot/OpenAI via prompt-context
|
|
1757
|
+
// injection, Claude via permissionMode + system-prompt appends, Cursor via
|
|
1758
|
+
// native plan mode plus message-text nudges for ask/autopilot — but the
|
|
1759
|
+
// composer builds its options from this table so a provider that loses (or
|
|
1760
|
+
// gains) a mode only needs a change here.
|
|
1761
|
+
const RELAY_MODE_LABELS = { agent: 'Agent', ask: 'Ask', plan: 'Plan', autopilot: 'Autopilot' };
|
|
1762
|
+
const RELAY_MODES_BY_PROVIDER = {
|
|
1763
|
+
github: ['agent', 'ask', 'plan', 'autopilot'],
|
|
1764
|
+
openai: ['agent', 'ask', 'plan', 'autopilot'],
|
|
1765
|
+
claude: ['agent', 'ask', 'plan', 'autopilot'],
|
|
1766
|
+
cursor: ['agent', 'ask', 'plan', 'autopilot'],
|
|
1767
|
+
grok: ['agent', 'ask', 'plan', 'autopilot'],
|
|
1768
|
+
};
|
|
1769
|
+
|
|
1770
|
+
function relayModesForProvider(providerType = '') {
|
|
1771
|
+
const scope = normalizeModelSelectorProviderType(providerType);
|
|
1772
|
+
const modes = RELAY_MODES_BY_PROVIDER[scope];
|
|
1773
|
+
return Array.isArray(modes) && modes.length ? modes : RELAY_MODES_BY_PROVIDER.github;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
// Same cache-and-rebuild idiom as the model select: dataset.providerScope
|
|
1777
|
+
// remembers which provider the current options were built for.
|
|
1778
|
+
function updateModeSelectorForProvider() {
|
|
1779
|
+
const select = document.getElementById('mode-select');
|
|
1780
|
+
if (!select || isSharedReaderMode()) return;
|
|
1781
|
+
const scope = normalizeModelSelectorProviderType(activeComposerProviderType());
|
|
1782
|
+
if (select.dataset.providerScope === scope) return;
|
|
1783
|
+
const modes = relayModesForProvider(scope);
|
|
1784
|
+
const selectedBefore = String(select.value || '').trim().toLowerCase();
|
|
1785
|
+
select.innerHTML = '';
|
|
1786
|
+
for (const mode of modes) {
|
|
1787
|
+
const option = document.createElement('option');
|
|
1788
|
+
option.value = mode;
|
|
1789
|
+
option.textContent = RELAY_MODE_LABELS[mode] || mode;
|
|
1790
|
+
select.appendChild(option);
|
|
1791
|
+
}
|
|
1792
|
+
select.dataset.providerScope = scope;
|
|
1793
|
+
select.value = modes.includes(selectedBefore)
|
|
1794
|
+
? selectedBefore
|
|
1795
|
+
: (modes.includes(FALLBACK_MODE) ? FALLBACK_MODE : modes[0]);
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
function modeOptions() {
|
|
1799
|
+
return Array.from(document.getElementById('mode-select')?.options || []).map((option) => option.value);
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
function modelOptions() {
|
|
1803
|
+
return Array.from(document.getElementById('model-select')?.options || []).map((option) => option.value);
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
async function persistCurrentConversationPreferences() {
|
|
1807
|
+
const convId = String(currentConvId || '').trim();
|
|
1808
|
+
if (!convId || suppressConversationPreferenceSync) return;
|
|
1809
|
+
const modeSelect = document.getElementById('mode-select');
|
|
1810
|
+
const modelSelect = document.getElementById('model-select');
|
|
1811
|
+
if (!modeSelect || !modelSelect) return;
|
|
1812
|
+
const mode = String(modeSelect.value || '').trim() || FALLBACK_MODE;
|
|
1813
|
+
const model = String(modelSelect.value || '').trim();
|
|
1814
|
+
// The raw value, not selectedReasoningEffortValue(): a model with no reasoning
|
|
1815
|
+
// options leaves the selector empty, and that helper's 'none' fallback would
|
|
1816
|
+
// record an effort the user never picked when they only changed the mode.
|
|
1817
|
+
const reasoningEffort = String(document.getElementById('reasoning-effort-select')?.value || '').trim().toLowerCase();
|
|
1818
|
+
// These keys hold the last explicit choice, so they are not rolled back when
|
|
1819
|
+
// the write below fails: the user did make the choice either way.
|
|
1820
|
+
localStorage.setItem(MODE_STORAGE_KEY, mode);
|
|
1821
|
+
if (model) localStorage.setItem(MODEL_STORAGE_KEY, model);
|
|
1822
|
+
if (reasoningEffort) localStorage.setItem(REASONING_STORAGE_KEY, reasoningEffort);
|
|
1823
|
+
|
|
1824
|
+
// For Claude conversations the 1M context tier is stored as the "[1m]"
|
|
1825
|
+
// variant of the preferred model, so it survives reopening the conversation.
|
|
1826
|
+
const contextTier = String(document.getElementById('context-tier-select')?.value || 'default').trim().toLowerCase();
|
|
1827
|
+
const preferredModelWithTier = model
|
|
1828
|
+
&& contextTier === 'long_context'
|
|
1829
|
+
&& activeComposerProviderType() === 'claude'
|
|
1830
|
+
&& !CLAUDE_LONG_CONTEXT_PATTERN.test(model)
|
|
1831
|
+
? `${model}[1m]`
|
|
1832
|
+
: model;
|
|
1833
|
+
|
|
1834
|
+
// Record the choice locally before the round-trip: a catalog refresh landing
|
|
1835
|
+
// while the PATCH is in flight reapplies preferences, and it must see the new
|
|
1836
|
+
// selection rather than the one being replaced.
|
|
1837
|
+
const previousRecord = conversations[convId] || null;
|
|
1838
|
+
if (previousRecord) {
|
|
1839
|
+
conversations[convId] = {
|
|
1840
|
+
...previousRecord,
|
|
1841
|
+
preferredRelayMode: mode,
|
|
1842
|
+
preferredModel: preferredModelWithTier || previousRecord.preferredModel || '',
|
|
1843
|
+
preferredReasoningEffort: reasoningEffort || previousRecord.preferredReasoningEffort || '',
|
|
1844
|
+
};
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
const writeVersion = ++conversationPreferenceWriteVersion;
|
|
1848
|
+
const response = await updateConversationPreferences(convId, {
|
|
1849
|
+
clientId: CLIENT_ID,
|
|
1850
|
+
preferredRelayMode: mode,
|
|
1851
|
+
preferredModel: preferredModelWithTier,
|
|
1852
|
+
preferredReasoningEffort: reasoningEffort,
|
|
1853
|
+
});
|
|
1854
|
+
if (writeVersion !== conversationPreferenceWriteVersion) return;
|
|
1855
|
+
if (!response) {
|
|
1856
|
+
// The server never took the write, so the optimistic preference has to go
|
|
1857
|
+
// back rather than linger as one the next apply would trust. Only the three
|
|
1858
|
+
// preference fields are restored: the poll rewrites the rest of the record
|
|
1859
|
+
// (messageCount, runtime binding, title) during the round trip.
|
|
1860
|
+
if (previousRecord && conversations[convId]) {
|
|
1861
|
+
conversations[convId] = {
|
|
1862
|
+
...conversations[convId],
|
|
1863
|
+
preferredRelayMode: previousRecord.preferredRelayMode,
|
|
1864
|
+
preferredModel: previousRecord.preferredModel,
|
|
1865
|
+
preferredReasoningEffort: previousRecord.preferredReasoningEffort,
|
|
1866
|
+
};
|
|
1867
|
+
}
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
if (conversations[convId]) {
|
|
1871
|
+
conversations[convId] = {
|
|
1872
|
+
...conversations[convId],
|
|
1873
|
+
preferredRelayMode: response.preferredRelayMode,
|
|
1874
|
+
preferredModel: response.preferredModel || conversations[convId].preferredModel || '',
|
|
1875
|
+
preferredReasoningEffort: response.preferredReasoningEffort || conversations[convId].preferredReasoningEffort || '',
|
|
1876
|
+
};
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
function applyConversationPreferences({
|
|
1881
|
+
preferredRelayMode = '',
|
|
1882
|
+
preferredModel = '',
|
|
1883
|
+
preferredReasoningEffort = '',
|
|
1884
|
+
preferredContextTier = 'default',
|
|
1885
|
+
} = {}) {
|
|
1886
|
+
const modeSelect = document.getElementById('mode-select');
|
|
1887
|
+
const modelSelect = document.getElementById('model-select');
|
|
1888
|
+
if (!modeSelect || !modelSelect) return;
|
|
1889
|
+
|
|
1890
|
+
// Options first, so the preference clamp below sees the provider's modes and
|
|
1891
|
+
// models. Clamping before the model rebuild let the previous conversation's
|
|
1892
|
+
// provider decide which models were "supported" for this one.
|
|
1893
|
+
updateModeSelectorForProvider();
|
|
1894
|
+
rebuildModelSelectorOptionsForProvider();
|
|
1895
|
+
const supportedModes = modeOptions();
|
|
1896
|
+
const supportedModels = modelOptions().length ? modelOptions() : modelCatalogState.models;
|
|
1897
|
+
const selection = resolveConversationComposerSelection({
|
|
1898
|
+
preferredRelayMode,
|
|
1899
|
+
preferredModel,
|
|
1900
|
+
selectedMode: modeSelect.value || localStorage.getItem(MODE_STORAGE_KEY) || FALLBACK_MODE,
|
|
1901
|
+
selectedModel: modelSelect.value || localStorage.getItem(MODEL_STORAGE_KEY) || FALLBACK_MODEL,
|
|
1902
|
+
supportedModes,
|
|
1903
|
+
supportedModels,
|
|
1904
|
+
fallbackMode: FALLBACK_MODE,
|
|
1905
|
+
fallbackModel: FALLBACK_MODEL,
|
|
1906
|
+
});
|
|
1907
|
+
// finally, because the flag also gates every composer change handler: leaking
|
|
1908
|
+
// it as true would silently stop the composer from persisting anything at all
|
|
1909
|
+
// until the page is reloaded.
|
|
1910
|
+
suppressConversationPreferenceSync = true;
|
|
1911
|
+
try {
|
|
1912
|
+
modeSelect.value = selection.mode;
|
|
1913
|
+
if (selection.model) modelSelect.value = selection.model;
|
|
1914
|
+
syncAutoModelAvailability();
|
|
1915
|
+
updateReasoningSelectorForModel(
|
|
1916
|
+
selection.model || modelSelect.value,
|
|
1917
|
+
String(preferredReasoningEffort || '').trim().toLowerCase(),
|
|
1918
|
+
);
|
|
1919
|
+
updateContextTierSelector(selection.model || modelSelect.value);
|
|
1920
|
+
const tierSelect = document.getElementById('context-tier-select');
|
|
1921
|
+
const desiredTier = String(preferredContextTier || 'default').trim().toLowerCase();
|
|
1922
|
+
if (tierSelect && Array.from(tierSelect.options).some((option) => option.value === desiredTier)) {
|
|
1923
|
+
tierSelect.value = desiredTier;
|
|
1924
|
+
updateModelPricingDetails(selection.model || modelSelect.value, tierSelect.value);
|
|
1925
|
+
}
|
|
1926
|
+
} finally {
|
|
1927
|
+
suppressConversationPreferenceSync = false;
|
|
1928
|
+
}
|
|
1929
|
+
// Nothing is written to the shared "last used" storage here. Applying a
|
|
1930
|
+
// conversation's stored preferences is not a choice: it runs on every open,
|
|
1931
|
+
// on the ~1s live poll, on a catalog refresh and on another client's edit,
|
|
1932
|
+
// so persisting the clamp let one conversation's resolution become the
|
|
1933
|
+
// default for the next New Chat. Only persistCurrentConversationPreferences
|
|
1934
|
+
// (a user edit) and a successful bootstrap write those keys.
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
function applyConversationPreferencesForConversation(conversationId, payload = {}) {
|
|
1938
|
+
const convId = String(conversationId || currentConvId || '').trim();
|
|
1939
|
+
const conversation = convId ? conversations[convId] : null;
|
|
1940
|
+
// Unset preferences arrive as '', so each source has to fall through rather
|
|
1941
|
+
// than stop at an empty string the way `??` did.
|
|
1942
|
+
const preferredRelayMode = firstDefinedPreference(
|
|
1943
|
+
payload?.preferredRelayMode,
|
|
1944
|
+
conversation?.preferredRelayMode,
|
|
1945
|
+
localStorage.getItem(MODE_STORAGE_KEY),
|
|
1946
|
+
) || FALLBACK_MODE;
|
|
1947
|
+
const preferredModel = firstDefinedPreference(
|
|
1948
|
+
payload?.preferredModel,
|
|
1949
|
+
conversation?.preferredModel,
|
|
1950
|
+
localStorage.getItem(MODEL_STORAGE_KEY),
|
|
1951
|
+
);
|
|
1952
|
+
// No localStorage fallback here on purpose: updateReasoningSelectorForModel
|
|
1953
|
+
// already consults the shared "last used" effort, but below the conversation's
|
|
1954
|
+
// own value. Feeding it in as `preferredEffort` would promote it above the
|
|
1955
|
+
// selector's current state and let one conversation's clamp leak into the next.
|
|
1956
|
+
const preferredReasoningEffort = firstDefinedPreference(
|
|
1957
|
+
payload?.preferredReasoningEffort,
|
|
1958
|
+
conversation?.preferredReasoningEffort,
|
|
1959
|
+
);
|
|
1960
|
+
const runtimeModel = firstDefinedPreference(
|
|
1961
|
+
payload?.runtimeModel,
|
|
1962
|
+
conversation?.runtimeProviderModel,
|
|
1963
|
+
conversation?.runtime_provider_model,
|
|
1964
|
+
conversation?.runtimeModel,
|
|
1965
|
+
conversation?.runtime_model,
|
|
1966
|
+
);
|
|
1967
|
+
// The runtime binding only stands in for an unstarted conversation that has
|
|
1968
|
+
// no stored preference. Preferring it over one would let every catalog
|
|
1969
|
+
// refresh revert a model the user changed before sending anything.
|
|
1970
|
+
const effectivePreferredModel = normalizePreferenceValue(
|
|
1971
|
+
preferredModel
|
|
1972
|
+
|| (Number(conversation?.messageCount || 0) === 0 ? runtimeModel : ''),
|
|
1973
|
+
);
|
|
1974
|
+
// A stored "[1m]" id decomposes into the base model plus the 1M context tier.
|
|
1975
|
+
const isLongContextModel = CLAUDE_LONG_CONTEXT_PATTERN.test(effectivePreferredModel);
|
|
1976
|
+
applyConversationPreferences({
|
|
1977
|
+
preferredRelayMode,
|
|
1978
|
+
preferredModel: effectivePreferredModel.replace(CLAUDE_LONG_CONTEXT_PATTERN, ''),
|
|
1979
|
+
preferredReasoningEffort,
|
|
1980
|
+
preferredContextTier: isLongContextModel ? 'long_context' : 'default',
|
|
1981
|
+
});
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
function initModelSelector() {
|
|
1985
|
+
const select = document.getElementById('model-select');
|
|
1986
|
+
if (!select) return;
|
|
1987
|
+
if (!select.dataset.bound) {
|
|
1988
|
+
select.dataset.bound = '1';
|
|
1989
|
+
select.addEventListener('change', () => {
|
|
1990
|
+
if (suppressConversationPreferenceSync) return;
|
|
1991
|
+
if (currentConversationHasMessages() && select.value.toLowerCase() === AUTO_MODEL_OPTION) {
|
|
1992
|
+
syncAutoModelAvailability();
|
|
1993
|
+
setModelBanner('⚠️ Auto model selection is available only for a new conversation.');
|
|
1994
|
+
return;
|
|
1995
|
+
}
|
|
1996
|
+
// Carry the current effort across the model change so switching models
|
|
1997
|
+
// does not quietly downgrade a deliberate "high" to the model's default.
|
|
1998
|
+
// The raw value, not selectedReasoningEffortValue(): its 'none' fallback
|
|
1999
|
+
// for an empty selector would overwrite a remembered effort.
|
|
2000
|
+
updateReasoningSelectorForModel(
|
|
2001
|
+
select.value,
|
|
2002
|
+
String(document.getElementById('reasoning-effort-select')?.value || '').trim().toLowerCase(),
|
|
2003
|
+
{ persist: true },
|
|
2004
|
+
);
|
|
2005
|
+
updateContextTierSelector(select.value);
|
|
2006
|
+
syncSessionLockNote({ pinnedModel: currentRuntimeModelLock()?.model || '' });
|
|
2007
|
+
refreshComposerAttachmentWarning();
|
|
2008
|
+
void persistCurrentConversationPreferences().catch(() => {});
|
|
2009
|
+
});
|
|
2010
|
+
select.addEventListener('blur', () => {
|
|
2011
|
+
if (!deferredModelCatalogPayload) return;
|
|
2012
|
+
const payload = deferredModelCatalogPayload;
|
|
2013
|
+
deferredModelCatalogPayload = null;
|
|
2014
|
+
updateModelCatalogState(payload);
|
|
2015
|
+
});
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
function initContextTierSelector() {
|
|
2021
|
+
const select = document.getElementById('context-tier-select');
|
|
2022
|
+
if (!select || select.dataset.bound === '1') return;
|
|
2023
|
+
select.dataset.bound = '1';
|
|
2024
|
+
select.addEventListener('change', () => {
|
|
2025
|
+
updateModelPricingDetails(selectedModelValue(), select.value);
|
|
2026
|
+
if (suppressConversationPreferenceSync) return;
|
|
2027
|
+
void persistCurrentConversationPreferences().catch(() => {});
|
|
2028
|
+
});
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
function initReasoningSelector() {
|
|
2032
|
+
const select = document.getElementById('reasoning-effort-select');
|
|
2033
|
+
if (!select || select.dataset.bound === '1') return;
|
|
2034
|
+
select.dataset.bound = '1';
|
|
2035
|
+
select.addEventListener('change', () => {
|
|
2036
|
+
if (suppressConversationPreferenceSync) return;
|
|
2037
|
+
void persistCurrentConversationPreferences().catch(() => {});
|
|
2038
|
+
});
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
function initModeSelector() {
|
|
2042
|
+
const select = document.getElementById('mode-select');
|
|
2043
|
+
if (!select) return;
|
|
2044
|
+
const saved = localStorage.getItem(MODE_STORAGE_KEY);
|
|
2045
|
+
const available = Array.from(select.options).map(o => o.value);
|
|
2046
|
+
if (saved && available.includes(saved)) {
|
|
2047
|
+
select.value = saved;
|
|
2048
|
+
} else if (!saved && available.includes(FALLBACK_MODE)) {
|
|
2049
|
+
select.value = FALLBACK_MODE;
|
|
2050
|
+
}
|
|
2051
|
+
if (select.dataset.bound === '1') return;
|
|
2052
|
+
select.dataset.bound = '1';
|
|
2053
|
+
select.addEventListener('change', () => {
|
|
2054
|
+
if (suppressConversationPreferenceSync) return;
|
|
2055
|
+
void persistCurrentConversationPreferences().catch(() => {});
|
|
2056
|
+
});
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
function refreshModelCatalog(force = false) {
|
|
2060
|
+
return loadModelCatalog().then((r) => {
|
|
2061
|
+
if (!r) {
|
|
2062
|
+
if (isModelMetadataHealthy(modelCatalogState) || isModelMetadataHealthy(lastHealthyModelCatalogState)) {
|
|
2063
|
+
setModelBanner('⚠️ Could not refresh model metadata; using last known good catalog.');
|
|
2064
|
+
syncModelMetadataBlocker();
|
|
2065
|
+
return null;
|
|
2066
|
+
}
|
|
2067
|
+
applyModelMetadataHardFail(force
|
|
2068
|
+
? 'Could not refresh live model metadata.'
|
|
2069
|
+
: 'Could not load model metadata.');
|
|
2070
|
+
return null;
|
|
2071
|
+
}
|
|
2072
|
+
updateModelCatalogState(r);
|
|
2073
|
+
return r;
|
|
2074
|
+
});
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
function reportOpenAIModelDiscoveryFailure(payload) {
|
|
2078
|
+
const discovery = payload?.openAIModelDiscovery;
|
|
2079
|
+
if (discovery && discovery.ok === false) {
|
|
2080
|
+
showTransientRelayNotice(
|
|
2081
|
+
`GitHub models refreshed, but OpenAI model discovery failed. Cached OpenAI models were kept: ${discovery.error || 'unknown error'}`,
|
|
2082
|
+
8000,
|
|
2083
|
+
);
|
|
2084
|
+
}
|
|
2085
|
+
const claudeDiscovery = payload?.claudeModelDiscovery;
|
|
2086
|
+
if (claudeDiscovery && claudeDiscovery.ok === false && !claudeDiscovery.skipped) {
|
|
2087
|
+
showTransientRelayNotice(
|
|
2088
|
+
`Claude model discovery failed. Cached Claude models were kept: ${claudeDiscovery.error || 'unknown error'}`,
|
|
2089
|
+
8000,
|
|
2090
|
+
);
|
|
2091
|
+
}
|
|
2092
|
+
const cursorDiscovery = payload?.cursorModelDiscovery;
|
|
2093
|
+
if (cursorDiscovery && cursorDiscovery.ok === false && !cursorDiscovery.skipped) {
|
|
2094
|
+
showTransientRelayNotice(
|
|
2095
|
+
`Cursor model discovery failed. Cached Cursor models were kept: ${cursorDiscovery.error || 'unknown error'}`,
|
|
2096
|
+
8000,
|
|
2097
|
+
);
|
|
2098
|
+
}
|
|
2099
|
+
const grokDiscovery = payload?.grokModelDiscovery;
|
|
2100
|
+
if (grokDiscovery && grokDiscovery.ok === false && !grokDiscovery.skipped) {
|
|
2101
|
+
showTransientRelayNotice(
|
|
2102
|
+
`Grok model discovery failed. Cached Grok models were kept: ${grokDiscovery.error || 'unknown error'}`,
|
|
2103
|
+
8000,
|
|
2104
|
+
);
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
async function retryModelMetadataRefresh() {
|
|
2109
|
+
if (modelMetadataRetryInFlight) return;
|
|
2110
|
+
modelMetadataRetryInFlight = true;
|
|
2111
|
+
syncModelMetadataBlocker('Refreshing model metadata…');
|
|
2112
|
+
try {
|
|
2113
|
+
let variantError = null;
|
|
2114
|
+
try {
|
|
2115
|
+
const refreshed = await refreshModelVariantCatalog();
|
|
2116
|
+
if (!refreshed) variantError = new Error('Model variant refresh returned empty');
|
|
2117
|
+
reportOpenAIModelDiscoveryFailure(refreshed);
|
|
2118
|
+
} catch (e) {
|
|
2119
|
+
variantError = e;
|
|
2120
|
+
}
|
|
2121
|
+
await refreshModelCatalog(true);
|
|
2122
|
+
if (variantError) console.warn('[retryModelMetadataRefresh] variant refresh issue:', variantError.message);
|
|
2123
|
+
if (modelCatalogState?.refreshedAt) console.log('[retryModelMetadataRefresh] refreshedAt:', modelCatalogState.refreshedAt);
|
|
2124
|
+
if (!isModelMetadataHealthy()) {
|
|
2125
|
+
throw new Error('Model metadata is still unavailable after refresh');
|
|
2126
|
+
}
|
|
2127
|
+
showTransientRelayNotice('Model metadata refreshed.');
|
|
2128
|
+
} catch (error) {
|
|
2129
|
+
applyModelMetadataHardFail(error?.message || 'Model metadata refresh failed.');
|
|
2130
|
+
} finally {
|
|
2131
|
+
modelMetadataRetryInFlight = false;
|
|
2132
|
+
syncModelMetadataBlocker();
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
function applyModelVariantCatalogState(payload) {
|
|
2137
|
+
const rawVariants = Array.isArray(payload?.variants)
|
|
2138
|
+
? payload.variants.map((entry) => ({
|
|
2139
|
+
variantId: String(entry?.variantId || '').trim(),
|
|
2140
|
+
baseModelId: String(entry?.baseModelId || '').trim(),
|
|
2141
|
+
provider: String(entry?.provider || 'other').trim().toLowerCase() || 'other',
|
|
2142
|
+
label: String(entry?.label || '').trim(),
|
|
2143
|
+
releaseStatus: String(entry?.releaseStatus || '').trim().toLowerCase() || null,
|
|
2144
|
+
reasoningEffort: String(entry?.reasoningEffort || '').trim().toLowerCase() || null,
|
|
2145
|
+
selectable: entry?.selectable !== false,
|
|
2146
|
+
enabled: !!entry?.enabled,
|
|
2147
|
+
sortOrder: Number.isFinite(Number(entry?.sortOrder)) ? Math.max(0, Math.trunc(Number(entry.sortOrder))) : 0,
|
|
2148
|
+
})).filter((entry) => entry.variantId && entry.baseModelId)
|
|
2149
|
+
: [];
|
|
2150
|
+
const canonicalizeId = (value) => String(value || '').trim().toLowerCase();
|
|
2151
|
+
const dedupedVariantsMap = new Map();
|
|
2152
|
+
for (const entry of rawVariants) {
|
|
2153
|
+
const dedupeKey = canonicalizeId(entry.variantId);
|
|
2154
|
+
const existing = dedupedVariantsMap.get(dedupeKey);
|
|
2155
|
+
if (!existing) {
|
|
2156
|
+
dedupedVariantsMap.set(dedupeKey, {
|
|
2157
|
+
...entry,
|
|
2158
|
+
variantId: dedupeKey,
|
|
2159
|
+
baseModelId: canonicalizeId(entry.baseModelId),
|
|
2160
|
+
});
|
|
2161
|
+
continue;
|
|
2162
|
+
}
|
|
2163
|
+
dedupedVariantsMap.set(dedupeKey, {
|
|
2164
|
+
...existing,
|
|
2165
|
+
enabled: existing.enabled || entry.enabled,
|
|
2166
|
+
releaseStatus: (existing.releaseStatus === null || entry.releaseStatus === null)
|
|
2167
|
+
? null
|
|
2168
|
+
: (existing.releaseStatus || entry.releaseStatus),
|
|
2169
|
+
sortOrder: Math.min(existing.sortOrder, entry.sortOrder),
|
|
2170
|
+
label: existing.label || entry.label,
|
|
2171
|
+
provider: existing.provider || entry.provider,
|
|
2172
|
+
});
|
|
2173
|
+
}
|
|
2174
|
+
const variants = Array.from(dedupedVariantsMap.values());
|
|
2175
|
+
const requestedEnabledVariantIds = Array.isArray(payload?.enabledVariantIds)
|
|
2176
|
+
? payload.enabledVariantIds.map((value) => canonicalizeId(value)).filter(Boolean)
|
|
2177
|
+
: variants.filter((entry) => entry.enabled).map((entry) => entry.variantId);
|
|
2178
|
+
const enabledVariantIds = new Set(requestedEnabledVariantIds.filter((value) => dedupedVariantsMap.has(value)));
|
|
2179
|
+
modelVariantCatalogState = {
|
|
2180
|
+
variants,
|
|
2181
|
+
enabledVariantIds: Array.from(enabledVariantIds),
|
|
2182
|
+
reasoningByModel: payload?.reasoningByModel && typeof payload.reasoningByModel === 'object'
|
|
2183
|
+
? Object.fromEntries(Object.entries(payload.reasoningByModel).map(([modelId, efforts]) => [
|
|
2184
|
+
String(modelId || '').trim().toLowerCase(),
|
|
2185
|
+
normalizeReasoningEffortList(efforts),
|
|
2186
|
+
]))
|
|
2187
|
+
: {},
|
|
2188
|
+
source: String(payload?.source || '').trim() || null,
|
|
2189
|
+
refreshedAt: payload?.refreshedAt || null,
|
|
2190
|
+
warning: payload?.warning ? String(payload.warning) : null,
|
|
2191
|
+
error: payload?.error ? String(payload.error) : null,
|
|
2192
|
+
reasoningEfforts: Array.isArray(payload?.reasoningEfforts)
|
|
2193
|
+
? payload.reasoningEfforts.map((value) => String(value || '').trim().toLowerCase()).filter(Boolean)
|
|
2194
|
+
: [],
|
|
2195
|
+
claudeModels: payload?.claudeModels && typeof payload.claudeModels === 'object'
|
|
2196
|
+
? {
|
|
2197
|
+
defaultModel: String(payload.claudeModels.defaultModel || '').trim(),
|
|
2198
|
+
availableModels: Array.isArray(payload.claudeModels.availableModels)
|
|
2199
|
+
? payload.claudeModels.availableModels.map((value) => String(value || '').trim()).filter(Boolean)
|
|
2200
|
+
: [],
|
|
2201
|
+
enabledModels: Array.isArray(payload.claudeModels.enabledModels)
|
|
2202
|
+
? payload.claudeModels.enabledModels.map((value) => String(value || '').trim()).filter(Boolean)
|
|
2203
|
+
: [],
|
|
2204
|
+
}
|
|
2205
|
+
: null,
|
|
2206
|
+
cursorModels: payload?.cursorModels && typeof payload.cursorModels === 'object'
|
|
2207
|
+
? {
|
|
2208
|
+
defaultModel: String(payload.cursorModels.defaultModel || '').trim(),
|
|
2209
|
+
availableModels: Array.isArray(payload.cursorModels.availableModels)
|
|
2210
|
+
? payload.cursorModels.availableModels.map((value) => String(value || '').trim()).filter(Boolean)
|
|
2211
|
+
: [],
|
|
2212
|
+
enabledModels: Array.isArray(payload.cursorModels.enabledModels)
|
|
2213
|
+
? payload.cursorModels.enabledModels.map((value) => String(value || '').trim()).filter(Boolean)
|
|
2214
|
+
: [],
|
|
2215
|
+
}
|
|
2216
|
+
: null,
|
|
2217
|
+
grokModels: payload?.grokModels && typeof payload.grokModels === 'object'
|
|
2218
|
+
? {
|
|
2219
|
+
defaultModel: String(payload.grokModels.defaultModel || '').trim(),
|
|
2220
|
+
availableModels: Array.isArray(payload.grokModels.availableModels)
|
|
2221
|
+
? payload.grokModels.availableModels.map((value) => String(value || '').trim()).filter(Boolean)
|
|
2222
|
+
: [],
|
|
2223
|
+
enabledModels: Array.isArray(payload.grokModels.enabledModels)
|
|
2224
|
+
? payload.grokModels.enabledModels.map((value) => String(value || '').trim()).filter(Boolean)
|
|
2225
|
+
: [],
|
|
2226
|
+
}
|
|
2227
|
+
: null,
|
|
2228
|
+
};
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
function buildReasoningEffortsByBaseModel(variants = [], reasoningByModel = {}) {
|
|
2232
|
+
const map = new Map();
|
|
2233
|
+
for (const entry of variants) {
|
|
2234
|
+
const baseModelId = String(entry.baseModelId || '').trim().toLowerCase();
|
|
2235
|
+
if (!baseModelId) continue;
|
|
2236
|
+
if (!map.has(baseModelId)) {
|
|
2237
|
+
map.set(baseModelId, {
|
|
2238
|
+
label: entry.label,
|
|
2239
|
+
efforts: new Set(),
|
|
2240
|
+
});
|
|
2241
|
+
}
|
|
2242
|
+
const effort = String(entry.reasoningEffort || '').trim().toLowerCase();
|
|
2243
|
+
if (effort) map.get(baseModelId).efforts.add(effort);
|
|
2244
|
+
}
|
|
2245
|
+
for (const [baseModelId, efforts] of Object.entries(reasoningByModel || {})) {
|
|
2246
|
+
const key = String(baseModelId || '').trim().toLowerCase();
|
|
2247
|
+
if (!key) continue;
|
|
2248
|
+
if (!map.has(key)) {
|
|
2249
|
+
map.set(key, { label: humanizeModelLabel(key), efforts: new Set() });
|
|
2250
|
+
}
|
|
2251
|
+
for (const effort of Array.isArray(efforts) ? efforts : []) {
|
|
2252
|
+
const normalized = String(effort || '').trim().toLowerCase();
|
|
2253
|
+
if (normalized) map.get(key).efforts.add(normalized);
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
return map;
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
function renderReasoningEffortChipRow(efforts = []) {
|
|
2260
|
+
const list = Array.from(efforts).sort();
|
|
2261
|
+
if (!list.length) {
|
|
2262
|
+
return '<span class="model-reasoning-chip model-reasoning-chip-muted">standard</span>';
|
|
2263
|
+
}
|
|
2264
|
+
return list.map((effort) => `<span class="model-reasoning-chip">${escHtml(effort)}</span>`).join('');
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
async function reconcileOpenModelVariantModal() {
|
|
2268
|
+
const modal = document.getElementById('summary-modal');
|
|
2269
|
+
if (!modal?.classList.contains('visible')) return;
|
|
2270
|
+
if (summaryModalState.kind !== 'select-models') return;
|
|
2271
|
+
const payload = await loadModelVariantCatalog();
|
|
2272
|
+
if (!payload) return;
|
|
2273
|
+
applyModelVariantCatalogState(payload);
|
|
2274
|
+
renderModelVariantCatalogBody();
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
function renderModelVariantCatalogBody() {
|
|
2278
|
+
const providersByModel = modelCatalogState.providersByModel || {};
|
|
2279
|
+
const variantBelongsToTab = (entry, tab) => {
|
|
2280
|
+
const activeTab = String(tab || 'copilot').trim().toLowerCase();
|
|
2281
|
+
const baseModelId = String(entry?.baseModelId || '').trim().toLowerCase();
|
|
2282
|
+
const entryProvider = String(entry?.provider || '').trim().toLowerCase();
|
|
2283
|
+
const providers = modelProvidersForId(baseModelId, providersByModel);
|
|
2284
|
+
const hasOpenAIByok = providers.includes('openai-byok');
|
|
2285
|
+
// Each tab lists only rows sourced from that runtime: the Claude SDK tab
|
|
2286
|
+
// shows Claude-SDK rows exclusively, the Cursor SDK tab Cursor rows, the
|
|
2287
|
+
// Grok tab Grok rows, and Copilot never shows rows that a different runtime
|
|
2288
|
+
// contributed (there is no cross-runtime switching).
|
|
2289
|
+
if (activeTab === 'anthropic') {
|
|
2290
|
+
return entryProvider === 'claude';
|
|
2291
|
+
}
|
|
2292
|
+
if (entryProvider === 'claude') return false;
|
|
2293
|
+
if (activeTab === 'cursor') {
|
|
2294
|
+
return entryProvider === 'cursor';
|
|
2295
|
+
}
|
|
2296
|
+
if (entryProvider === 'cursor') return false;
|
|
2297
|
+
if (activeTab === 'grok') {
|
|
2298
|
+
return entryProvider === 'grok';
|
|
2299
|
+
}
|
|
2300
|
+
if (entryProvider === 'grok') return false;
|
|
2301
|
+
if (activeTab === 'openai') {
|
|
2302
|
+
return hasOpenAIByok || entryProvider === 'openai-byok';
|
|
2303
|
+
}
|
|
2304
|
+
// Copilot tab: only models the Copilot CLI itself serves.
|
|
2305
|
+
if (entryProvider === 'openai-byok') return false;
|
|
2306
|
+
if (hasOpenAIByok) {
|
|
2307
|
+
return providers.some((provider) => (
|
|
2308
|
+
provider !== 'openai-byok'
|
|
2309
|
+
&& provider !== 'claude'
|
|
2310
|
+
&& provider !== 'cursor'
|
|
2311
|
+
&& provider !== 'grok'
|
|
2312
|
+
));
|
|
2313
|
+
}
|
|
2314
|
+
return true;
|
|
2315
|
+
};
|
|
2316
|
+
const grouped = new Map();
|
|
2317
|
+
for (const entry of modelVariantCatalogState.variants) {
|
|
2318
|
+
const providerKey = String(entry.provider || 'other').trim().toLowerCase() || 'other';
|
|
2319
|
+
if (!grouped.has(providerKey)) grouped.set(providerKey, []);
|
|
2320
|
+
grouped.get(providerKey).push(entry);
|
|
2321
|
+
}
|
|
2322
|
+
const openAIBaseModelsAlreadyListed = new Set(
|
|
2323
|
+
modelVariantCatalogState.variants
|
|
2324
|
+
.filter((entry) => variantBelongsToTab(entry, 'openai'))
|
|
2325
|
+
.map((entry) => String(entry.baseModelId || '').trim().toLowerCase())
|
|
2326
|
+
.filter(Boolean),
|
|
2327
|
+
);
|
|
2328
|
+
for (const [modelId, providers] of Object.entries(modelCatalogState.providersByModel || {})) {
|
|
2329
|
+
const baseModelId = String(modelId || '').trim().toLowerCase();
|
|
2330
|
+
if (!baseModelId) continue;
|
|
2331
|
+
const providerList = Array.isArray(providers) ? providers : [];
|
|
2332
|
+
if (!providerList.includes('openai-byok')) continue;
|
|
2333
|
+
if (openAIBaseModelsAlreadyListed.has(baseModelId)) continue;
|
|
2334
|
+
const providerRows = grouped.get('openai-byok') || [];
|
|
2335
|
+
providerRows.push({
|
|
2336
|
+
variantId: `${baseModelId}--provider-openai-byok`,
|
|
2337
|
+
baseModelId,
|
|
2338
|
+
provider: 'openai-byok',
|
|
2339
|
+
label: humanizeModelLabel(baseModelId),
|
|
2340
|
+
releaseStatus: null,
|
|
2341
|
+
reasoningEffort: null,
|
|
2342
|
+
selectable: false,
|
|
2343
|
+
enabled: false,
|
|
2344
|
+
sortOrder: Number.MAX_SAFE_INTEGER,
|
|
2345
|
+
});
|
|
2346
|
+
grouped.set('openai-byok', providerRows);
|
|
2347
|
+
openAIBaseModelsAlreadyListed.add(baseModelId);
|
|
2348
|
+
}
|
|
2349
|
+
const claudeCatalog = modelVariantCatalogState.claudeModels;
|
|
2350
|
+
if (claudeCatalog?.availableModels?.length) {
|
|
2351
|
+
const claudeEnabledSet = new Set(claudeCatalog.enabledModels || []);
|
|
2352
|
+
const claudeDefaultModel = String(claudeCatalog.defaultModel || '').trim();
|
|
2353
|
+
const providerRows = [];
|
|
2354
|
+
for (const [index, claudeModelId] of claudeCatalog.availableModels.entries()) {
|
|
2355
|
+
const isDefaultModel = claudeModelId === claudeDefaultModel;
|
|
2356
|
+
providerRows.push({
|
|
2357
|
+
variantId: `${claudeModelId}--provider-claude`,
|
|
2358
|
+
baseModelId: claudeModelId,
|
|
2359
|
+
provider: 'claude',
|
|
2360
|
+
label: humanizeModelLabel(claudeModelId),
|
|
2361
|
+
releaseStatus: null,
|
|
2362
|
+
reasoningEffort: null,
|
|
2363
|
+
// The default model always stays enabled; deselecting it would leave
|
|
2364
|
+
// Claude conversations without a model.
|
|
2365
|
+
selectable: !isDefaultModel,
|
|
2366
|
+
enabled: claudeEnabledSet.has(claudeModelId) || isDefaultModel,
|
|
2367
|
+
claudeModelId,
|
|
2368
|
+
sortOrder: index,
|
|
2369
|
+
});
|
|
2370
|
+
}
|
|
2371
|
+
grouped.set('claude', providerRows);
|
|
2372
|
+
}
|
|
2373
|
+
const cursorCatalog = modelVariantCatalogState.cursorModels;
|
|
2374
|
+
if (cursorCatalog?.availableModels?.length) {
|
|
2375
|
+
const cursorEnabledSet = new Set(cursorCatalog.enabledModels || []);
|
|
2376
|
+
const cursorDefaultModel = String(cursorCatalog.defaultModel || '').trim();
|
|
2377
|
+
const providerRows = [];
|
|
2378
|
+
for (const [index, cursorModelId] of cursorCatalog.availableModels.entries()) {
|
|
2379
|
+
const isDefaultModel = cursorModelId === cursorDefaultModel;
|
|
2380
|
+
providerRows.push({
|
|
2381
|
+
variantId: `${cursorModelId}--provider-cursor`,
|
|
2382
|
+
baseModelId: cursorModelId,
|
|
2383
|
+
provider: 'cursor',
|
|
2384
|
+
label: humanizeModelLabel(cursorModelId),
|
|
2385
|
+
releaseStatus: null,
|
|
2386
|
+
reasoningEffort: null,
|
|
2387
|
+
// The default model always stays enabled; deselecting it would leave
|
|
2388
|
+
// Cursor conversations without a model.
|
|
2389
|
+
selectable: !isDefaultModel,
|
|
2390
|
+
enabled: cursorEnabledSet.has(cursorModelId) || isDefaultModel,
|
|
2391
|
+
cursorModelId,
|
|
2392
|
+
sortOrder: index,
|
|
2393
|
+
});
|
|
2394
|
+
}
|
|
2395
|
+
grouped.set('cursor', providerRows);
|
|
2396
|
+
}
|
|
2397
|
+
const grokCatalog = modelVariantCatalogState.grokModels;
|
|
2398
|
+
if (grokCatalog?.availableModels?.length) {
|
|
2399
|
+
const grokEnabledSet = new Set(grokCatalog.enabledModels || []);
|
|
2400
|
+
const grokDefaultModel = String(grokCatalog.defaultModel || '').trim();
|
|
2401
|
+
const providerRows = [];
|
|
2402
|
+
for (const [index, grokModelId] of grokCatalog.availableModels.entries()) {
|
|
2403
|
+
const isDefaultModel = grokModelId === grokDefaultModel;
|
|
2404
|
+
providerRows.push({
|
|
2405
|
+
variantId: `${grokModelId}--provider-grok`,
|
|
2406
|
+
baseModelId: grokModelId,
|
|
2407
|
+
provider: 'grok',
|
|
2408
|
+
label: humanizeModelLabel(grokModelId),
|
|
2409
|
+
releaseStatus: null,
|
|
2410
|
+
reasoningEffort: null,
|
|
2411
|
+
// The default model always stays enabled; deselecting it would leave
|
|
2412
|
+
// Grok conversations without a model.
|
|
2413
|
+
selectable: !isDefaultModel,
|
|
2414
|
+
enabled: grokEnabledSet.has(grokModelId) || isDefaultModel,
|
|
2415
|
+
grokModelId,
|
|
2416
|
+
sortOrder: index,
|
|
2417
|
+
});
|
|
2418
|
+
}
|
|
2419
|
+
grouped.set('grok', providerRows);
|
|
2420
|
+
}
|
|
2421
|
+
const selected = new Set(modelVariantCatalogState.enabledVariantIds);
|
|
2422
|
+
const selectedOrder = new Map(
|
|
2423
|
+
modelVariantCatalogState.enabledVariantIds.map((variantId, index) => [variantId, index]),
|
|
2424
|
+
);
|
|
2425
|
+
const providerSortMeta = (providerKey) => {
|
|
2426
|
+
const rows = grouped.get(providerKey) || [];
|
|
2427
|
+
const selectedPositions = rows
|
|
2428
|
+
.filter((row) => selected.has(row.variantId))
|
|
2429
|
+
.map((row) => selectedOrder.get(row.variantId))
|
|
2430
|
+
.filter((value) => Number.isFinite(value));
|
|
2431
|
+
const hasSelected = selectedPositions.length > 0;
|
|
2432
|
+
const firstSelectedPos = hasSelected ? Math.min(...selectedPositions) : Number.POSITIVE_INFINITY;
|
|
2433
|
+
return { hasSelected, firstSelectedPos };
|
|
2434
|
+
};
|
|
2435
|
+
const providerOrder = Array.from(grouped.keys()).sort((a, b) => {
|
|
2436
|
+
const aMeta = providerSortMeta(a);
|
|
2437
|
+
const bMeta = providerSortMeta(b);
|
|
2438
|
+
if (aMeta.hasSelected !== bMeta.hasSelected) return aMeta.hasSelected ? -1 : 1;
|
|
2439
|
+
if (aMeta.firstSelectedPos !== bMeta.firstSelectedPos) return aMeta.firstSelectedPos - bMeta.firstSelectedPos;
|
|
2440
|
+
const aLabel = PROVIDER_LABELS[a] || a;
|
|
2441
|
+
const bLabel = PROVIDER_LABELS[b] || b;
|
|
2442
|
+
return aLabel.localeCompare(bLabel);
|
|
2443
|
+
});
|
|
2444
|
+
const refreshedLabel = modelVariantCatalogState.refreshedAt
|
|
2445
|
+
? new Date(modelVariantCatalogState.refreshedAt).toLocaleString()
|
|
2446
|
+
: 'Never';
|
|
2447
|
+
const providerBelongsToTab = (providerKey, tab) => (
|
|
2448
|
+
(grouped.get(String(providerKey || '').trim().toLowerCase()) || [])
|
|
2449
|
+
.some((entry) => variantBelongsToTab(entry, tab))
|
|
2450
|
+
);
|
|
2451
|
+
const hasOpenAITab = providerOrder.some((providerKey) => providerBelongsToTab(providerKey, 'openai'));
|
|
2452
|
+
const hasCopilotTab = providerOrder.some((providerKey) => providerBelongsToTab(providerKey, 'copilot'));
|
|
2453
|
+
const hasAnthropicTab = providerOrder.some((providerKey) => providerBelongsToTab(providerKey, 'anthropic'));
|
|
2454
|
+
const hasCursorTab = providerOrder.some((providerKey) => providerBelongsToTab(providerKey, 'cursor'));
|
|
2455
|
+
const hasGrokTab = providerOrder.some((providerKey) => providerBelongsToTab(providerKey, 'grok'));
|
|
2456
|
+
if (modelVariantCatalogProviderTab === 'openai' && !hasOpenAITab) modelVariantCatalogProviderTab = 'copilot';
|
|
2457
|
+
if (modelVariantCatalogProviderTab === 'anthropic' && !hasAnthropicTab) modelVariantCatalogProviderTab = 'copilot';
|
|
2458
|
+
if (modelVariantCatalogProviderTab === 'cursor' && !hasCursorTab) modelVariantCatalogProviderTab = 'copilot';
|
|
2459
|
+
if (modelVariantCatalogProviderTab === 'grok' && !hasGrokTab) modelVariantCatalogProviderTab = 'copilot';
|
|
2460
|
+
if (modelVariantCatalogProviderTab === 'copilot' && !hasCopilotTab) {
|
|
2461
|
+
if (hasOpenAITab) modelVariantCatalogProviderTab = 'openai';
|
|
2462
|
+
else if (hasAnthropicTab) modelVariantCatalogProviderTab = 'anthropic';
|
|
2463
|
+
else if (hasCursorTab) modelVariantCatalogProviderTab = 'cursor';
|
|
2464
|
+
else if (hasGrokTab) modelVariantCatalogProviderTab = 'grok';
|
|
2465
|
+
}
|
|
2466
|
+
const visibleProviderOrder = providerOrder.filter((providerKey) => providerBelongsToTab(providerKey, modelVariantCatalogProviderTab));
|
|
2467
|
+
const providerTabButtons = `
|
|
2468
|
+
<div class="summary-tab-strip" style="display:flex;gap:8px;align-items:center;">
|
|
2469
|
+
<button type="button" class="summary-btn model-provider-tab${modelVariantCatalogProviderTab === 'copilot' ? ' active' : ''}" data-model-provider-tab="copilot">Copilot</button>
|
|
2470
|
+
<button type="button" class="summary-btn model-provider-tab${modelVariantCatalogProviderTab === 'openai' ? ' active' : ''}" data-model-provider-tab="openai" ${hasOpenAITab ? '' : 'disabled'}>OpenAI</button>
|
|
2471
|
+
<button type="button" class="summary-btn model-provider-tab${modelVariantCatalogProviderTab === 'anthropic' ? ' active' : ''}" data-model-provider-tab="anthropic" ${hasAnthropicTab ? '' : 'disabled'}>Claude SDK</button>
|
|
2472
|
+
<button type="button" class="summary-btn model-provider-tab${modelVariantCatalogProviderTab === 'cursor' ? ' active' : ''}" data-model-provider-tab="cursor" ${hasCursorTab ? '' : 'disabled'}>Cursor SDK</button>
|
|
2473
|
+
<button type="button" class="summary-btn model-provider-tab${modelVariantCatalogProviderTab === 'grok' ? ' active' : ''}" data-model-provider-tab="grok" ${hasGrokTab ? '' : 'disabled'}>Grok</button>
|
|
2474
|
+
</div>
|
|
2475
|
+
`;
|
|
2476
|
+
const warnings = [
|
|
2477
|
+
modelVariantCatalogState.warning ? `⚠️ ${escHtml(modelVariantCatalogState.warning)}` : '',
|
|
2478
|
+
modelVariantCatalogState.error ? `⚠️ ${escHtml(modelVariantCatalogState.error)}` : '',
|
|
2479
|
+
].filter(Boolean);
|
|
2480
|
+
const reasoningByBaseModel = buildReasoningEffortsByBaseModel(
|
|
2481
|
+
modelVariantCatalogState.variants,
|
|
2482
|
+
modelVariantCatalogState.reasoningByModel,
|
|
2483
|
+
);
|
|
2484
|
+
const groupsHtml = visibleProviderOrder.map((providerKey) => {
|
|
2485
|
+
const providerLabel = PROVIDER_LABELS[providerKey] || providerKey;
|
|
2486
|
+
const rows = (grouped.get(providerKey) || [])
|
|
2487
|
+
.filter((entry) => variantBelongsToTab(entry, modelVariantCatalogProviderTab));
|
|
2488
|
+
rows.sort((a, b) => {
|
|
2489
|
+
const aSelected = selected.has(a.variantId);
|
|
2490
|
+
const bSelected = selected.has(b.variantId);
|
|
2491
|
+
if (aSelected !== bSelected) return aSelected ? -1 : 1;
|
|
2492
|
+
|
|
2493
|
+
const aSelectedPos = selectedOrder.has(a.variantId)
|
|
2494
|
+
? selectedOrder.get(a.variantId)
|
|
2495
|
+
: Number.POSITIVE_INFINITY;
|
|
2496
|
+
const bSelectedPos = selectedOrder.has(b.variantId)
|
|
2497
|
+
? selectedOrder.get(b.variantId)
|
|
2498
|
+
: Number.POSITIVE_INFINITY;
|
|
2499
|
+
if (aSelectedPos !== bSelectedPos) return aSelectedPos - bSelectedPos;
|
|
2500
|
+
|
|
2501
|
+
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder;
|
|
2502
|
+
return a.variantId.localeCompare(b.variantId);
|
|
2503
|
+
});
|
|
2504
|
+
const byBaseModel = new Map();
|
|
2505
|
+
for (const row of rows) {
|
|
2506
|
+
const baseModelId = String(row.baseModelId || '').trim().toLowerCase();
|
|
2507
|
+
if (!byBaseModel.has(baseModelId)) byBaseModel.set(baseModelId, []);
|
|
2508
|
+
byBaseModel.get(baseModelId).push(row);
|
|
2509
|
+
}
|
|
2510
|
+
const baseModelBlocks = Array.from(byBaseModel.entries()).map(([baseModelId, variantRows]) => {
|
|
2511
|
+
const providerEfforts = reasoningOptionsForProviderModel(providerKey, baseModelId);
|
|
2512
|
+
const fallbackMeta = reasoningByBaseModel.get(baseModelId) || { label: humanizeModelLabel(baseModelId), efforts: new Set() };
|
|
2513
|
+
const meta = providerEfforts.length
|
|
2514
|
+
? { ...fallbackMeta, efforts: new Set(providerEfforts) }
|
|
2515
|
+
: fallbackMeta;
|
|
2516
|
+
const label = meta.label || humanizeModelLabel(baseModelId) || baseModelId;
|
|
2517
|
+
const variantRowsHtml = variantRows.map((row) => {
|
|
2518
|
+
const selectable = row.selectable !== false;
|
|
2519
|
+
// SDK-provider rows (Claude/Cursor/Grok) carry their own enabled flag; only
|
|
2520
|
+
// Copilot variant rows read the enabledVariantIds selection.
|
|
2521
|
+
const sdkProviderModelId = row.claudeModelId || row.cursorModelId || row.grokModelId || '';
|
|
2522
|
+
const checked = sdkProviderModelId
|
|
2523
|
+
? row.enabled === true
|
|
2524
|
+
: (selectable && selected.has(row.variantId));
|
|
2525
|
+
const effortChip = row.reasoningEffort
|
|
2526
|
+
? ` <span class="model-reasoning-chip">${escHtml(row.reasoningEffort)}</span>`
|
|
2527
|
+
: '';
|
|
2528
|
+
const unavailable = row.releaseStatus === 'unavailable';
|
|
2529
|
+
const statusChip = unavailable
|
|
2530
|
+
? ' <span class="model-reasoning-chip model-reasoning-chip-warn">unavailable</span>'
|
|
2531
|
+
: '';
|
|
2532
|
+
const fixedChip = !selectable
|
|
2533
|
+
? (sdkProviderModelId
|
|
2534
|
+
? ' <span class="model-reasoning-chip">default model</span>'
|
|
2535
|
+
: ' <span class="model-reasoning-chip">managed in settings</span>')
|
|
2536
|
+
: '';
|
|
2537
|
+
const sdkModelAttr = row.claudeModelId
|
|
2538
|
+
? ` data-claude-model="${escHtml(row.claudeModelId)}"`
|
|
2539
|
+
: (row.cursorModelId
|
|
2540
|
+
? ` data-cursor-model="${escHtml(row.cursorModelId)}"`
|
|
2541
|
+
: (row.grokModelId ? ` data-grok-model="${escHtml(row.grokModelId)}"` : ''));
|
|
2542
|
+
return `
|
|
2543
|
+
<label class="model-variant-row">
|
|
2544
|
+
<input class="model-variant-checkbox" type="checkbox" data-selectable="${selectable ? '1' : '0'}" data-variant-id="${escHtml(row.variantId)}"${sdkModelAttr} ${checked ? 'checked' : ''} ${selectable ? '' : 'disabled'}>
|
|
2545
|
+
<span class="model-variant-row-copy">
|
|
2546
|
+
<span class="model-variant-row-title">${escHtml(row.variantId)}${effortChip}${statusChip}${fixedChip}</span>
|
|
2547
|
+
</span>
|
|
2548
|
+
</label>
|
|
2549
|
+
`;
|
|
2550
|
+
}).join('');
|
|
2551
|
+
return `
|
|
2552
|
+
<div class="model-base-group">
|
|
2553
|
+
<div class="model-base-header">
|
|
2554
|
+
<div class="model-base-title">${escHtml(label)}</div>
|
|
2555
|
+
<div class="model-base-reasoning">
|
|
2556
|
+
<span class="model-base-reasoning-label">Reasoning</span>
|
|
2557
|
+
${renderReasoningEffortChipRow(meta.efforts)}
|
|
2558
|
+
</div>
|
|
2559
|
+
<code class="model-base-id">${escHtml(baseModelId)}</code>
|
|
2560
|
+
</div>
|
|
2561
|
+
<div class="model-variant-list">${variantRowsHtml}</div>
|
|
2562
|
+
</div>
|
|
2563
|
+
`;
|
|
2564
|
+
}).join('');
|
|
2565
|
+
return `
|
|
2566
|
+
<section class="model-provider-group">
|
|
2567
|
+
<div class="model-provider-title">${escHtml(providerLabel)}</div>
|
|
2568
|
+
${baseModelBlocks || '<div class="model-provider-empty">No models</div>'}
|
|
2569
|
+
</section>
|
|
2570
|
+
`;
|
|
2571
|
+
}).join('');
|
|
2572
|
+
const bodyHtml = `
|
|
2573
|
+
<div style="display:flex;flex-direction:column;gap:10px">
|
|
2574
|
+
${providerTabButtons}
|
|
2575
|
+
<div style="font-size:0.8rem;color:var(--muted)">
|
|
2576
|
+
Saved globally for this relay. Refreshed: <strong>${escHtml(refreshedLabel)}</strong>
|
|
2577
|
+
</div>
|
|
2578
|
+
<div style="font-size:0.78rem;color:var(--muted)">
|
|
2579
|
+
Variants marked unavailable are preserved from earlier selections so updates do not reset your models.
|
|
2580
|
+
</div>
|
|
2581
|
+
<div style="font-size:0.78rem;color:var(--muted)">
|
|
2582
|
+
Each model shows all supported reasoning efforts above its selectable variants.
|
|
2583
|
+
</div>
|
|
2584
|
+
${warnings.map((line) => `<div style="font-size:0.78rem;color:var(--warn,#f7c873)">${line}</div>`).join('')}
|
|
2585
|
+
<div style="display:grid;gap:10px">${groupsHtml || '<div style="color:var(--muted)">No models discovered yet. Click Refresh.</div>'}</div>
|
|
2586
|
+
</div>
|
|
2587
|
+
`;
|
|
2588
|
+
renderSummaryModalContent({
|
|
2589
|
+
title: '🤗 Select Models',
|
|
2590
|
+
subtitle: 'Choose model variants shown in the composer selector',
|
|
2591
|
+
bodyHtml,
|
|
2592
|
+
refresh: async () => {
|
|
2593
|
+
const refreshed = await refreshModelVariantCatalog();
|
|
2594
|
+
if (!refreshed) throw new Error('Failed to refresh model variants');
|
|
2595
|
+
reportOpenAIModelDiscoveryFailure(refreshed);
|
|
2596
|
+
applyModelVariantCatalogState(refreshed);
|
|
2597
|
+
renderModelVariantCatalogBody();
|
|
2598
|
+
await refreshModelCatalog(true);
|
|
2599
|
+
},
|
|
2600
|
+
kind: 'select-models',
|
|
2601
|
+
});
|
|
2602
|
+
const headerActions = document.querySelector('#summary-modal .summary-header-actions');
|
|
2603
|
+
const refreshBtn = document.getElementById('summary-modal-refresh');
|
|
2604
|
+
if (headerActions && refreshBtn && !document.getElementById('summary-modal-save-models')) {
|
|
2605
|
+
const saveBtn = document.createElement('button');
|
|
2606
|
+
saveBtn.id = 'summary-modal-save-models';
|
|
2607
|
+
saveBtn.className = 'summary-btn';
|
|
2608
|
+
saveBtn.type = 'button';
|
|
2609
|
+
saveBtn.textContent = '💾 Save enabled models';
|
|
2610
|
+
saveBtn.onclick = () => saveSelectedModelsFromModal();
|
|
2611
|
+
headerActions.insertBefore(saveBtn, refreshBtn);
|
|
2612
|
+
}
|
|
2613
|
+
for (const button of Array.from(document.querySelectorAll('[data-model-provider-tab]'))) {
|
|
2614
|
+
button.onclick = () => {
|
|
2615
|
+
const nextTab = String(button.dataset.modelProviderTab || '').trim().toLowerCase();
|
|
2616
|
+
if (!nextTab || nextTab === modelVariantCatalogProviderTab) return;
|
|
2617
|
+
modelVariantCatalogProviderTab = nextTab;
|
|
2618
|
+
renderModelVariantCatalogBody();
|
|
2619
|
+
};
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
async function openSelectModelsModal() {
|
|
2624
|
+
modelVariantCatalogProviderTab = 'copilot';
|
|
2625
|
+
openSummaryModal({
|
|
2626
|
+
title: '🤗 Select Models',
|
|
2627
|
+
subtitle: 'Loading…',
|
|
2628
|
+
bodyHtml: '<div class="summary-loading">Loading saved model variants…</div>',
|
|
2629
|
+
kind: 'select-models',
|
|
2630
|
+
});
|
|
2631
|
+
setSummaryModalLoading(true);
|
|
2632
|
+
try {
|
|
2633
|
+
const payload = await loadModelVariantCatalog();
|
|
2634
|
+
if (!payload) throw new Error('Failed to load model variants');
|
|
2635
|
+
applyModelVariantCatalogState(payload);
|
|
2636
|
+
renderModelVariantCatalogBody();
|
|
2637
|
+
} catch (error) {
|
|
2638
|
+
renderSummaryModalContent({
|
|
2639
|
+
title: '🤗 Select Models',
|
|
2640
|
+
subtitle: 'Unable to load',
|
|
2641
|
+
bodyHtml: `<div class="summary-error">Failed to load model variants: ${escHtml(error?.message || 'Unknown error')}</div>`,
|
|
2642
|
+
kind: 'select-models',
|
|
2643
|
+
});
|
|
2644
|
+
} finally {
|
|
2645
|
+
setSummaryModalLoading(false);
|
|
2646
|
+
}
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
async function saveSelectedModelsFromModal() {
|
|
2650
|
+
const body = document.getElementById('summary-modal-body');
|
|
2651
|
+
if (!body) return;
|
|
2652
|
+
const claudeCheckboxes = Array.from(body.querySelectorAll('.model-variant-checkbox[data-claude-model]'));
|
|
2653
|
+
const selectedClaudeModels = claudeCheckboxes
|
|
2654
|
+
.filter((input) => input.checked)
|
|
2655
|
+
.map((input) => String(input.getAttribute('data-claude-model') || '').trim())
|
|
2656
|
+
.filter(Boolean);
|
|
2657
|
+
const cursorCheckboxes = Array.from(body.querySelectorAll('.model-variant-checkbox[data-cursor-model]'));
|
|
2658
|
+
const selectedCursorModels = cursorCheckboxes
|
|
2659
|
+
.filter((input) => input.checked)
|
|
2660
|
+
.map((input) => String(input.getAttribute('data-cursor-model') || '').trim())
|
|
2661
|
+
.filter(Boolean);
|
|
2662
|
+
const grokCheckboxes = Array.from(body.querySelectorAll('.model-variant-checkbox[data-grok-model]'));
|
|
2663
|
+
const selectedGrokModels = grokCheckboxes
|
|
2664
|
+
.filter((input) => input.checked)
|
|
2665
|
+
.map((input) => String(input.getAttribute('data-grok-model') || '').trim())
|
|
2666
|
+
.filter(Boolean);
|
|
2667
|
+
// Only the active provider tab's rows are in the DOM, but the PATCH
|
|
2668
|
+
// replaces the WHOLE enabled set — so the save must only change rows the
|
|
2669
|
+
// user could actually see. Off-screen variants keep their stored state,
|
|
2670
|
+
// otherwise saving from e.g. the OpenAI tab silently disables every
|
|
2671
|
+
// Copilot-only vendor group (Anthropic, Google, …).
|
|
2672
|
+
const renderedVariantCheckboxes = Array.from(body.querySelectorAll('.model-variant-checkbox[data-selectable="1"]:not([data-claude-model]):not([data-cursor-model]):not([data-grok-model])'));
|
|
2673
|
+
const renderedVariantIds = new Set(renderedVariantCheckboxes
|
|
2674
|
+
.map((input) => String(input.getAttribute('data-variant-id') || '').trim())
|
|
2675
|
+
.filter(Boolean));
|
|
2676
|
+
const checkedVariantIds = renderedVariantCheckboxes
|
|
2677
|
+
.filter((input) => input.checked)
|
|
2678
|
+
.map((input) => String(input.getAttribute('data-variant-id') || '').trim())
|
|
2679
|
+
.filter(Boolean);
|
|
2680
|
+
const checkedVariantSet = new Set(checkedVariantIds);
|
|
2681
|
+
const selectedVariantIds = (modelVariantCatalogState.enabledVariantIds || [])
|
|
2682
|
+
.filter((variantId) => !renderedVariantIds.has(variantId) || checkedVariantSet.has(variantId));
|
|
2683
|
+
for (const variantId of checkedVariantIds) {
|
|
2684
|
+
if (!selectedVariantIds.includes(variantId)) selectedVariantIds.push(variantId);
|
|
2685
|
+
}
|
|
2686
|
+
const hasVariantRows = renderedVariantCheckboxes.length > 0;
|
|
2687
|
+
if (hasVariantRows && !selectedVariantIds.length) {
|
|
2688
|
+
alert('Select at least one model variant.');
|
|
2689
|
+
return;
|
|
2690
|
+
}
|
|
2691
|
+
setSummaryModalLoading(true);
|
|
2692
|
+
try {
|
|
2693
|
+
if (hasVariantRows) {
|
|
2694
|
+
const saved = await saveEnabledModelVariants(selectedVariantIds);
|
|
2695
|
+
if (!saved) throw new Error('Failed to save model selection');
|
|
2696
|
+
applyModelVariantCatalogState(saved);
|
|
2697
|
+
}
|
|
2698
|
+
if (claudeCheckboxes.length) {
|
|
2699
|
+
const savedClaude = await updateClaudeSettings({ enabledModels: selectedClaudeModels });
|
|
2700
|
+
if (!savedClaude) throw new Error('Failed to save Claude model selection');
|
|
2701
|
+
applyClaudeSettingsState(savedClaude);
|
|
2702
|
+
}
|
|
2703
|
+
if (cursorCheckboxes.length) {
|
|
2704
|
+
const savedCursor = await updateCursorSettings({ enabledModels: selectedCursorModels });
|
|
2705
|
+
if (!savedCursor) throw new Error('Failed to save Cursor model selection');
|
|
2706
|
+
applyCursorSettingsState(savedCursor);
|
|
2707
|
+
}
|
|
2708
|
+
if (grokCheckboxes.length) {
|
|
2709
|
+
const savedGrok = await updateGrokSettings({ enabledModels: selectedGrokModels });
|
|
2710
|
+
if (!savedGrok) throw new Error('Failed to save Grok model selection');
|
|
2711
|
+
applyGrokSettingsState(savedGrok);
|
|
2712
|
+
}
|
|
2713
|
+
if (claudeCheckboxes.length || cursorCheckboxes.length || grokCheckboxes.length) {
|
|
2714
|
+
const refreshedCatalog = await loadModelVariantCatalog();
|
|
2715
|
+
if (refreshedCatalog) applyModelVariantCatalogState(refreshedCatalog);
|
|
2716
|
+
}
|
|
2717
|
+
renderModelVariantCatalogBody();
|
|
2718
|
+
await refreshModelCatalog(true);
|
|
2719
|
+
showTransientRelayNotice('Saved model selection.');
|
|
2720
|
+
} catch (error) {
|
|
2721
|
+
alert(error?.message || 'Failed to save model selection');
|
|
2722
|
+
} finally {
|
|
2723
|
+
setSummaryModalLoading(false);
|
|
2724
|
+
}
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2727
|
+
// Which usage tab is showing. Null means "follow the session's provider"; a
|
|
2728
|
+
// click pins a provider so the modal's Refresh re-renders onto the same tab
|
|
2729
|
+
// instead of snapping back. Reset on every fresh open of the modal.
|
|
2730
|
+
let usageTabProviderOverride = null;
|
|
2731
|
+
|
|
2732
|
+
/**
|
|
2733
|
+
* Delegated because #summary-modal-body's innerHTML is replaced on every render
|
|
2734
|
+
* and refresh. Scoped to [data-usage-tab], which only the usage body emits, so
|
|
2735
|
+
* the shared shell's context/git renders never reach it.
|
|
2736
|
+
*/
|
|
2737
|
+
function initUsageTabControl() {
|
|
2738
|
+
const body = document.getElementById('summary-modal-body');
|
|
2739
|
+
if (!body || body.dataset.usageTabsBound === '1') return;
|
|
2740
|
+
body.dataset.usageTabsBound = '1';
|
|
2741
|
+
body.addEventListener('click', (event) => {
|
|
2742
|
+
const btn = event.target?.closest?.('[data-usage-tab]');
|
|
2743
|
+
if (!btn || !body.contains(btn) || summaryModalState.kind !== 'usage') return;
|
|
2744
|
+
selectUsageTab(body, btn.dataset.usageTab);
|
|
2745
|
+
});
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
/** Pure DOM toggle: switching tabs never refetches `/api/usage`. */
|
|
2749
|
+
function selectUsageTab(root, provider) {
|
|
2750
|
+
const target = String(provider || '').trim();
|
|
2751
|
+
if (!target) return;
|
|
2752
|
+
const tabs = Array.from(root.querySelectorAll('[data-usage-tab]'));
|
|
2753
|
+
if (!tabs.some((tab) => tab.dataset.usageTab === target)) return;
|
|
2754
|
+
for (const tab of tabs) {
|
|
2755
|
+
const active = tab.dataset.usageTab === target;
|
|
2756
|
+
tab.classList.toggle('active', active);
|
|
2757
|
+
tab.setAttribute('aria-selected', active ? 'true' : 'false');
|
|
2758
|
+
tab.tabIndex = active ? 0 : -1;
|
|
2759
|
+
}
|
|
2760
|
+
for (const panel of root.querySelectorAll('[data-usage-panel]')) {
|
|
2761
|
+
panel.hidden = panel.dataset.usagePanel !== target;
|
|
2762
|
+
}
|
|
2763
|
+
usageTabProviderOverride = target;
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
/**
|
|
2767
|
+
* `{loggedIn:false}` and "the status call failed" are different answers, so a
|
|
2768
|
+
* failure returns null (the card then says nothing) rather than a logged-out
|
|
2769
|
+
* claim we cannot back up.
|
|
2770
|
+
*/
|
|
2771
|
+
function claudeAccountFromAuthStatus(payload) {
|
|
2772
|
+
const status = payload?.status;
|
|
2773
|
+
if (!status || typeof status !== 'object') return null;
|
|
2774
|
+
if (status.loggedIn !== true) return { loggedIn: false };
|
|
2775
|
+
return {
|
|
2776
|
+
loggedIn: true,
|
|
2777
|
+
email: status.email || '',
|
|
2778
|
+
plan: status.subscriptionType || '',
|
|
2779
|
+
};
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
/** Joins the Claude account onto its usage card without mutating the payload. */
|
|
2783
|
+
function withClaudeAccount(report, account) {
|
|
2784
|
+
if (!account || !Array.isArray(report?.providers)) return report;
|
|
2785
|
+
return {
|
|
2786
|
+
...report,
|
|
2787
|
+
providers: report.providers.map((card) => (
|
|
2788
|
+
card && normalizeUsageProvider(card.provider) === 'claude' ? { ...card, account } : card
|
|
2789
|
+
)),
|
|
2790
|
+
};
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
async function loadUsageSummaryAndRender() {
|
|
2794
|
+
// Usage is the point of the modal; the account line is a garnish, so the two
|
|
2795
|
+
// are settled independently and a failing auth probe cannot blank the cards.
|
|
2796
|
+
const [usageResult, authResult] = await Promise.allSettled([
|
|
2797
|
+
loadUsageSummary(),
|
|
2798
|
+
getClaudeAuthStatus(),
|
|
2799
|
+
]);
|
|
2800
|
+
if (usageResult.status === 'rejected') throw usageResult.reason;
|
|
2801
|
+
const d = usageResult.value;
|
|
2802
|
+
if (!d) throw new Error('Unable to load usage data');
|
|
2803
|
+
const account = authResult.status === 'fulfilled'
|
|
2804
|
+
? claudeAccountFromAuthStatus(authResult.value)
|
|
2805
|
+
: null;
|
|
2806
|
+
const activeProvider = usageTabProviderOverride
|
|
2807
|
+
|| normalizeUsageProvider(activeComposerProviderType());
|
|
2808
|
+
const planHtml = renderPlanUsageHtml(withClaudeAccount(d, account), { activeProvider });
|
|
2809
|
+
if (planHtml) {
|
|
2810
|
+
initUsageTabControl();
|
|
2811
|
+
renderSummaryModalContent({
|
|
2812
|
+
title: 'Plan usage',
|
|
2813
|
+
subtitle: planUsageSubtitle(d),
|
|
2814
|
+
bodyHtml: planHtml,
|
|
2815
|
+
refresh: loadUsageSummaryAndRender,
|
|
2816
|
+
kind: 'usage',
|
|
2817
|
+
});
|
|
2818
|
+
return;
|
|
2819
|
+
}
|
|
2820
|
+
// Legacy relay (or a relay without the plan-usage service): fall back to the
|
|
2821
|
+
// Copilot-only text summary rather than showing an empty modal.
|
|
2822
|
+
const pct = d.premiumInteractions?.percentRemaining != null
|
|
2823
|
+
? ` (${d.premiumInteractions.percentRemaining.toFixed(1)}% left)`
|
|
2824
|
+
: '';
|
|
2825
|
+
const msg = `Chat/Completions: ${d.chat?.unlimited ? 'Unlimited ✅' : `${d.chat?.remaining} remaining`}\n` +
|
|
2826
|
+
`Premium interactions: ${d.premiumInteractions?.remaining} / ${d.premiumInteractions?.entitlement} remaining${pct}`;
|
|
2827
|
+
renderSummaryModalContent({
|
|
2828
|
+
title: 'Copilot Usage',
|
|
2829
|
+
subtitle: `Resets ${d.resetDate || 'unknown'}`,
|
|
2830
|
+
bodyHtml: `<pre>${escHtml(msg)}</pre>`,
|
|
2831
|
+
refresh: loadUsageSummaryAndRender,
|
|
2832
|
+
kind: 'usage',
|
|
2833
|
+
});
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
// The context modal is rendered from scratch on every refresh, so the slider's
|
|
2837
|
+
// conversation binding lives here rather than on the element.
|
|
2838
|
+
let autoCompactControlConversationId = '';
|
|
2839
|
+
let autoCompactWindowUpdateInFlight = false;
|
|
2840
|
+
// The server's last known window for the rendered control. Kept so a rejected
|
|
2841
|
+
// PATCH can put the slider back instead of leaving the rejected value on
|
|
2842
|
+
// screen until the next refresh.
|
|
2843
|
+
let autoCompactControlStoredWindow = null;
|
|
2844
|
+
|
|
2845
|
+
// Snaps both the range input and its label back to `window`.
|
|
2846
|
+
function setAutoCompactControlValue(window) {
|
|
2847
|
+
const slider = document.getElementById('ctx-autocompact-slider');
|
|
2848
|
+
if (slider) slider.value = String(autoCompactWindowToIndex(window));
|
|
2849
|
+
const label = document.getElementById('ctx-autocompact-value');
|
|
2850
|
+
if (label) label.textContent = formatAutoCompactWindowLabel(window);
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
/**
|
|
2854
|
+
* Delegated because #summary-modal-body's innerHTML is replaced on every modal
|
|
2855
|
+
* render (store.js renderSummaryModalContent) — a listener on the input itself
|
|
2856
|
+
* would die with the first refresh.
|
|
2857
|
+
*/
|
|
2858
|
+
function initAutoCompactWindowControl() {
|
|
2859
|
+
const body = document.getElementById('summary-modal-body');
|
|
2860
|
+
if (!body || body.dataset.autoCompactBound === '1') return;
|
|
2861
|
+
body.dataset.autoCompactBound = '1';
|
|
2862
|
+
const sliderFrom = (event) => {
|
|
2863
|
+
const target = event?.target;
|
|
2864
|
+
return target && target.id === 'ctx-autocompact-slider' ? target : null;
|
|
2865
|
+
};
|
|
2866
|
+
// Dragging only previews the label; nothing is persisted until the value
|
|
2867
|
+
// settles, matching the settings-modal sliders.
|
|
2868
|
+
body.addEventListener('input', (event) => {
|
|
2869
|
+
const slider = sliderFrom(event);
|
|
2870
|
+
if (!slider) return;
|
|
2871
|
+
const label = document.getElementById('ctx-autocompact-value');
|
|
2872
|
+
if (label) label.textContent = formatAutoCompactWindowLabel(autoCompactWindowFromIndex(slider.value));
|
|
2873
|
+
});
|
|
2874
|
+
body.addEventListener('change', (event) => {
|
|
2875
|
+
const slider = sliderFrom(event);
|
|
2876
|
+
if (!slider) return;
|
|
2877
|
+
void applyAutoCompactWindowSelection(autoCompactWindowFromIndex(slider.value));
|
|
2878
|
+
});
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
// Same lifecycle as the auto-compact control: the modal body is re-rendered
|
|
2882
|
+
// from scratch, so state lives here. Written on every context render.
|
|
2883
|
+
let thinkingControlStored = { enabled: true, display: 'summarized' };
|
|
2884
|
+
let thinkingControlUpdateInFlight = false;
|
|
2885
|
+
|
|
2886
|
+
function setThinkingControlValue({ enabled, display }) {
|
|
2887
|
+
const activeKeys = {
|
|
2888
|
+
enabled: thinkingEnabledToKey(enabled),
|
|
2889
|
+
display: String(display || 'summarized'),
|
|
2890
|
+
};
|
|
2891
|
+
for (const btn of document.querySelectorAll('.ctx-thinking-btn')) {
|
|
2892
|
+
const axis = btn.dataset.thinkingAxis;
|
|
2893
|
+
const active = btn.dataset.thinkingValue === activeKeys[axis];
|
|
2894
|
+
btn.classList.toggle('is-active', active);
|
|
2895
|
+
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
|
|
2896
|
+
}
|
|
2897
|
+
}
|
|
2898
|
+
|
|
2899
|
+
function initThinkingControl() {
|
|
2900
|
+
const body = document.getElementById('summary-modal-body');
|
|
2901
|
+
if (!body || body.dataset.thinkingBound === '1') return;
|
|
2902
|
+
body.dataset.thinkingBound = '1';
|
|
2903
|
+
body.addEventListener('click', (event) => {
|
|
2904
|
+
const btn = event.target?.closest?.('.ctx-thinking-btn');
|
|
2905
|
+
if (!btn || !body.contains(btn)) return;
|
|
2906
|
+
void applyThinkingSelection(btn.dataset.thinkingAxis, btn.dataset.thinkingValue);
|
|
2907
|
+
});
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
async function applyThinkingSelection(axis, key) {
|
|
2911
|
+
// The control shares the auto-compact control's conversation binding: both
|
|
2912
|
+
// render only for Claude conversations from the same payload.
|
|
2913
|
+
const convId = String(autoCompactControlConversationId || '').trim();
|
|
2914
|
+
if (!convId || thinkingControlUpdateInFlight) {
|
|
2915
|
+
setThinkingControlValue(thinkingControlStored);
|
|
2916
|
+
return;
|
|
2917
|
+
}
|
|
2918
|
+
// Only the touched axis is sent: the route keeps every unmentioned key as
|
|
2919
|
+
// stored, so an enabled click can never clobber the display (or the
|
|
2920
|
+
// composer's model/effort).
|
|
2921
|
+
const patch = axis === 'enabled'
|
|
2922
|
+
? { thinkingEnabled: thinkingEnabledFromKey(key) }
|
|
2923
|
+
: { thinkingDisplay: key === 'omitted' ? 'omitted' : 'summarized' };
|
|
2924
|
+
thinkingControlUpdateInFlight = true;
|
|
2925
|
+
try {
|
|
2926
|
+
const response = await updateConversationPreferences(convId, {
|
|
2927
|
+
clientId: CLIENT_ID,
|
|
2928
|
+
...patch,
|
|
2929
|
+
});
|
|
2930
|
+
if (!response) throw new Error('Failed to update the thinking setting');
|
|
2931
|
+
thinkingControlStored = {
|
|
2932
|
+
enabled: response.thinkingEnabled !== false,
|
|
2933
|
+
display: response.thinkingDisplay || 'summarized',
|
|
2934
|
+
};
|
|
2935
|
+
setThinkingControlValue(thinkingControlStored);
|
|
2936
|
+
// Per-transition truth (probed): enabling and display changes are live on
|
|
2937
|
+
// the next message; disabling/reverting only reaches the CLI at its next
|
|
2938
|
+
// process start.
|
|
2939
|
+
showTransientRelayNotice(axis === 'enabled'
|
|
2940
|
+
? (thinkingControlStored.enabled
|
|
2941
|
+
? 'Thinking on; applies to the next message.'
|
|
2942
|
+
: 'Thinking off; applies when the CLI session next restarts.')
|
|
2943
|
+
: (thinkingControlStored.display === 'summarized'
|
|
2944
|
+
? 'Thinking text visible (summarized); applies to the next message.'
|
|
2945
|
+
: 'Thinking text hidden; applies to the next message.'));
|
|
2946
|
+
} catch (error) {
|
|
2947
|
+
setThinkingControlValue(thinkingControlStored);
|
|
2948
|
+
alert(error?.message || 'Failed to update the thinking setting.');
|
|
2949
|
+
} finally {
|
|
2950
|
+
thinkingControlUpdateInFlight = false;
|
|
2951
|
+
}
|
|
2952
|
+
}
|
|
2953
|
+
|
|
2954
|
+
async function applyAutoCompactWindowSelection(autoCompactWindow) {
|
|
2955
|
+
const convId = String(autoCompactControlConversationId || '').trim();
|
|
2956
|
+
const slider = document.getElementById('ctx-autocompact-slider');
|
|
2957
|
+
if (!convId || autoCompactWindowUpdateInFlight) {
|
|
2958
|
+
// Nothing will be written, so leave the control showing what is stored
|
|
2959
|
+
// rather than a position the server never heard about.
|
|
2960
|
+
setAutoCompactControlValue(autoCompactControlStoredWindow);
|
|
2961
|
+
return;
|
|
2962
|
+
}
|
|
2963
|
+
autoCompactWindowUpdateInFlight = true;
|
|
2964
|
+
if (slider) slider.disabled = true;
|
|
2965
|
+
try {
|
|
2966
|
+
// Only the window is sent: the route keeps every unmentioned preference as
|
|
2967
|
+
// stored, so this cannot clobber the composer's model/effort choice.
|
|
2968
|
+
const response = await updateConversationPreferences(convId, {
|
|
2969
|
+
clientId: CLIENT_ID,
|
|
2970
|
+
autoCompactWindow,
|
|
2971
|
+
});
|
|
2972
|
+
if (!response) throw new Error('Failed to update the auto-compact window');
|
|
2973
|
+
// The server, not the slider, decides what got stored (values snap to a
|
|
2974
|
+
// stop), so the control follows the response.
|
|
2975
|
+
const stored = response.autoCompactWindow ?? null;
|
|
2976
|
+
autoCompactControlStoredWindow = stored;
|
|
2977
|
+
setAutoCompactControlValue(stored);
|
|
2978
|
+
showTransientRelayNotice(stored === null
|
|
2979
|
+
? 'Auto-compact window back to the model default; applies to the next message.'
|
|
2980
|
+
: `Auto-compact window set to ${formatAutoCompactWindowLabel(stored)} tokens; applies to the next message.`);
|
|
2981
|
+
} catch (error) {
|
|
2982
|
+
// The write was refused, so the rejected position is a lie: put the
|
|
2983
|
+
// control back on the last value the server confirmed.
|
|
2984
|
+
setAutoCompactControlValue(autoCompactControlStoredWindow);
|
|
2985
|
+
alert(error?.message || 'Failed to update the auto-compact window.');
|
|
2986
|
+
} finally {
|
|
2987
|
+
autoCompactWindowUpdateInFlight = false;
|
|
2988
|
+
if (slider) slider.disabled = false;
|
|
2989
|
+
}
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
async function loadContextSummaryAndRender(convId) {
|
|
2993
|
+
const trimmedConvId = String(convId || '').trim();
|
|
2994
|
+
const payload = await loadContextSummary(trimmedConvId);
|
|
2995
|
+
if (!payload) throw new Error('Unable to load context');
|
|
2996
|
+
const sessionId = String(payload.copilotSessionId || payload.snapshot?.copilot_session_id || '').trim();
|
|
2997
|
+
const refreshLookupId = sessionId || trimmedConvId || null;
|
|
2998
|
+
const providerLabel = payload.providerType === 'claude'
|
|
2999
|
+
? 'Claude'
|
|
3000
|
+
: (payload.providerType === 'cursor'
|
|
3001
|
+
? 'Cursor'
|
|
3002
|
+
: (payload.providerType === 'grok' ? 'Grok' : 'Copilot'));
|
|
3003
|
+
const subtitle = sessionId
|
|
3004
|
+
? `${providerLabel} session ${sessionId.slice(0, 8)}`
|
|
3005
|
+
: (trimmedConvId ? `Conversation ${trimmedConvId.slice(0, 8)}` : 'No conversation selected');
|
|
3006
|
+
|
|
3007
|
+
const usageHtml = renderContextUsageHtml(payload.contextUsage);
|
|
3008
|
+
// Claude-only: no other provider exposes a compaction window (or any
|
|
3009
|
+
// session-settings surface) to steer.
|
|
3010
|
+
const autoCompactHtml = payload.providerType === 'claude'
|
|
3011
|
+
? renderAutoCompactControlHtml({
|
|
3012
|
+
autoCompactWindow: payload.autoCompactWindow ?? null,
|
|
3013
|
+
autoCompactThreshold: payload.contextUsage?.autoCompactThreshold ?? null,
|
|
3014
|
+
autocompactSource: payload.contextUsage?.autocompactSource ?? null,
|
|
3015
|
+
// Tri-state: only a real snapshot can say "disabled". Collapsing this to
|
|
3016
|
+
// a boolean made a brand-new conversation (no snapshot yet) claim
|
|
3017
|
+
// auto-compact was off, which is both false and self-contradictory next
|
|
3018
|
+
// to "Effective: — (known after the first turn)".
|
|
3019
|
+
isAutoCompactEnabled: payload.contextUsage
|
|
3020
|
+
? (payload.contextUsage.isAutoCompactEnabled ?? null)
|
|
3021
|
+
: null,
|
|
3022
|
+
maxTokens: payload.contextUsage?.maxTokens ?? null,
|
|
3023
|
+
})
|
|
3024
|
+
: '';
|
|
3025
|
+
const thinkingHtml = payload.providerType === 'claude'
|
|
3026
|
+
? renderThinkingControlHtml({
|
|
3027
|
+
thinkingEnabled: payload.thinkingEnabled !== false,
|
|
3028
|
+
thinkingDisplay: payload.thinkingDisplay ?? null,
|
|
3029
|
+
})
|
|
3030
|
+
: '';
|
|
3031
|
+
// The slider's writes need the conversation id even when the modal was
|
|
3032
|
+
// opened through an sdk_session_id.
|
|
3033
|
+
autoCompactControlConversationId = String(
|
|
3034
|
+
payload.resolvedConversationId || (payload.providerType === 'claude' ? trimmedConvId : '') || '',
|
|
3035
|
+
).trim();
|
|
3036
|
+
// Baseline for the failure path below: what the server says is stored right
|
|
3037
|
+
// now, refreshed every time the control is re-rendered.
|
|
3038
|
+
autoCompactControlStoredWindow = payload.autoCompactWindow ?? null;
|
|
3039
|
+
thinkingControlStored = {
|
|
3040
|
+
enabled: payload.thinkingEnabled !== false,
|
|
3041
|
+
display: payload.thinkingDisplay || 'summarized',
|
|
3042
|
+
};
|
|
3043
|
+
const detailText = String(payload.text || '').trim();
|
|
3044
|
+
// The structured breakdown is the answer; the runtime's own text dump stays
|
|
3045
|
+
// available underneath for the details it carries that categories don't.
|
|
3046
|
+
const bodyHtml = usageHtml
|
|
3047
|
+
? `${usageHtml}${autoCompactHtml}${thinkingHtml}${detailText
|
|
3048
|
+
? `<details class="ctx-usage-raw"><summary>Raw details</summary><pre>${escHtml(detailText)}</pre></details>`
|
|
3049
|
+
: ''}`
|
|
3050
|
+
: `${autoCompactHtml}${thinkingHtml}<pre>${escHtml(detailText || 'No context data available.')}</pre>`;
|
|
3051
|
+
|
|
3052
|
+
renderSummaryModalContent({
|
|
3053
|
+
title: 'Context usage',
|
|
3054
|
+
subtitle,
|
|
3055
|
+
bodyHtml,
|
|
3056
|
+
refresh: () => loadContextSummaryAndRender(refreshLookupId),
|
|
3057
|
+
kind: 'context',
|
|
3058
|
+
});
|
|
3059
|
+
}
|
|
3060
|
+
|
|
3061
|
+
async function showUsage() {
|
|
3062
|
+
// A fresh open follows the current session's provider again; only a click
|
|
3063
|
+
// inside the modal pins a tab (for the Refresh button's sake).
|
|
3064
|
+
usageTabProviderOverride = null;
|
|
3065
|
+
const btn = document.getElementById('chat-menu-usage') || document.getElementById('usage-btn');
|
|
3066
|
+
if (btn) {
|
|
3067
|
+
btn.textContent = '⏳';
|
|
3068
|
+
btn.disabled = true;
|
|
3069
|
+
}
|
|
3070
|
+
openSummaryModal({
|
|
3071
|
+
title: 'Plan usage',
|
|
3072
|
+
subtitle: 'Loading…',
|
|
3073
|
+
bodyHtml: '<div class="summary-loading">Fetching plan usage across providers…</div>',
|
|
3074
|
+
refresh: loadUsageSummaryAndRender,
|
|
3075
|
+
kind: 'usage',
|
|
3076
|
+
});
|
|
3077
|
+
setSummaryModalLoading(true);
|
|
3078
|
+
try {
|
|
3079
|
+
await loadUsageSummaryAndRender();
|
|
3080
|
+
} catch (e) {
|
|
3081
|
+
renderSummaryModalContent({
|
|
3082
|
+
title: 'Plan usage',
|
|
3083
|
+
subtitle: 'Unable to load',
|
|
3084
|
+
bodyHtml: `<div class="summary-error">Failed to fetch usage: ${escHtml(e.message || 'Unknown error')}</div>`,
|
|
3085
|
+
refresh: loadUsageSummaryAndRender,
|
|
3086
|
+
kind: 'usage',
|
|
3087
|
+
});
|
|
3088
|
+
} finally {
|
|
3089
|
+
if (btn) {
|
|
3090
|
+
btn.textContent = btn.id === 'chat-menu-usage' ? '📊 Check Usage' : '📊';
|
|
3091
|
+
btn.disabled = false;
|
|
3092
|
+
}
|
|
3093
|
+
}
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
async function showContext() {
|
|
3097
|
+
const btn = document.getElementById('context-btn');
|
|
3098
|
+
const convId = String(currentConvId || '').trim();
|
|
3099
|
+
if (!convId) {
|
|
3100
|
+
// /api/context has no data without a conversation, so say so rather than
|
|
3101
|
+
// opening a modal that can only ever render an error.
|
|
3102
|
+
openSummaryModal({
|
|
3103
|
+
title: 'Context usage',
|
|
3104
|
+
subtitle: 'No conversation selected',
|
|
3105
|
+
bodyHtml: '<div class="summary-loading">Open a conversation to see its context usage.</div>',
|
|
3106
|
+
kind: 'context',
|
|
3107
|
+
});
|
|
3108
|
+
return;
|
|
3109
|
+
}
|
|
3110
|
+
if (btn) {
|
|
3111
|
+
btn.textContent = '⏳';
|
|
3112
|
+
btn.disabled = true;
|
|
3113
|
+
}
|
|
3114
|
+
openSummaryModal({
|
|
3115
|
+
title: 'Context usage',
|
|
3116
|
+
subtitle: `Conversation ${convId.slice(0, 8)}`,
|
|
3117
|
+
bodyHtml: '<div class="summary-loading">Fetching context snapshot…</div>',
|
|
3118
|
+
refresh: () => loadContextSummaryAndRender(convId),
|
|
3119
|
+
kind: 'context',
|
|
3120
|
+
});
|
|
3121
|
+
setSummaryModalLoading(true);
|
|
3122
|
+
try {
|
|
3123
|
+
await loadContextSummaryAndRender(convId);
|
|
3124
|
+
} catch (e) {
|
|
3125
|
+
renderSummaryModalContent({
|
|
3126
|
+
title: 'Context usage',
|
|
3127
|
+
subtitle: 'Unable to load',
|
|
3128
|
+
bodyHtml: `<div class="summary-error">Failed to fetch context: ${escHtml(e.message || 'Unknown error')}</div>`,
|
|
3129
|
+
refresh: () => loadContextSummaryAndRender(convId || null),
|
|
3130
|
+
kind: 'context',
|
|
3131
|
+
});
|
|
3132
|
+
} finally {
|
|
3133
|
+
if (btn) {
|
|
3134
|
+
btn.textContent = '🧠';
|
|
3135
|
+
btn.disabled = false;
|
|
3136
|
+
}
|
|
3137
|
+
}
|
|
3138
|
+
}
|
|
3139
|
+
|
|
3140
|
+
function renderSessionInstructionDocs(docs) {
|
|
3141
|
+
const items = Array.isArray(docs) ? docs : [];
|
|
3142
|
+
if (!items.length) {
|
|
3143
|
+
return '<div class="summary-loading">No session instruction files were found.</div>';
|
|
3144
|
+
}
|
|
3145
|
+
|
|
3146
|
+
return `<div style="display:flex;flex-direction:column;gap:12px">${
|
|
3147
|
+
items.map((doc) => {
|
|
3148
|
+
const title = String(doc?.title || doc?.sessionId || 'Session instructions').trim();
|
|
3149
|
+
const name = String(doc?.name || '').trim();
|
|
3150
|
+
const gender = String(doc?.gender || '').trim();
|
|
3151
|
+
const summary = [name, gender].filter(Boolean).join(' · ');
|
|
3152
|
+
const sessionId = String(doc?.sessionId || '').trim();
|
|
3153
|
+
const updatedAt = String(doc?.updatedAt || '').trim();
|
|
3154
|
+
const content = escHtml(String(doc?.content || '').trim());
|
|
3155
|
+
return `
|
|
3156
|
+
<details open style="border:1px solid var(--border);border-radius:10px;background:var(--bg3);padding:10px 12px">
|
|
3157
|
+
<summary style="cursor:pointer;font-weight:600;outline:none">${escHtml(title)}</summary>
|
|
3158
|
+
<div style="margin-top:6px;font-size:0.78rem;color:var(--muted)">
|
|
3159
|
+
${escHtml(summary || sessionId)}
|
|
3160
|
+
${updatedAt ? ` · ${escHtml(updatedAt)}` : ''}
|
|
3161
|
+
</div>
|
|
3162
|
+
<pre style="margin-top:10px;white-space:pre-wrap;word-break:break-word">${content}</pre>
|
|
3163
|
+
</details>
|
|
3164
|
+
`;
|
|
3165
|
+
}).join('')
|
|
3166
|
+
}</div>`;
|
|
3167
|
+
}
|
|
3168
|
+
|
|
3169
|
+
|
|
3170
|
+
function startRelayQuestionPolling() {
|
|
3171
|
+
if (relayQuestionPollTimer) return;
|
|
3172
|
+
relayQuestionPollTimer = setInterval(() => {
|
|
3173
|
+
if (!shouldRunForegroundNetworkWork()) return;
|
|
3174
|
+
loadRelayQuestions(currentConvId).catch(() => {});
|
|
3175
|
+
}, 3000);
|
|
3176
|
+
}
|
|
3177
|
+
|
|
3178
|
+
function startRelayBoardPolling() {
|
|
3179
|
+
if (relayBoardPollTimer) return;
|
|
3180
|
+
relayBoardPollTimer = setInterval(() => {
|
|
3181
|
+
if (!shouldRunForegroundNetworkWork()) return;
|
|
3182
|
+
loadRelayBoards().catch(() => {});
|
|
3183
|
+
}, 3000);
|
|
3184
|
+
}
|
|
3185
|
+
|
|
3186
|
+
async function refreshSessionWorkerStatus() {
|
|
3187
|
+
const currentId = String(currentConvId || '').trim();
|
|
3188
|
+
const currentConversation = currentId ? conversations[currentId] : null;
|
|
3189
|
+
const currentSdkSessionId = String(currentConversation?.sdkSessionId || currentConversation?.sdk_session_id || '').trim();
|
|
3190
|
+
const previousWorkerState = currentSdkSessionId ? getSessionWorkerState(currentSdkSessionId) : null;
|
|
3191
|
+
const previousWorkerStatus = String(previousWorkerState?.status || '').trim().toLowerCase();
|
|
3192
|
+
const status = await refreshWorkspaceRootHints();
|
|
3193
|
+
if (!status) return;
|
|
3194
|
+
syncQueueStatusMenuEntry(status);
|
|
3195
|
+
if (setSessionWorkerStatesFromStatusPayload(status.sessionWorker)) {
|
|
3196
|
+
renderConvList();
|
|
3197
|
+
}
|
|
3198
|
+
updateCliStatus();
|
|
3199
|
+
const nextWorkerState = currentSdkSessionId ? getSessionWorkerState(currentSdkSessionId) : null;
|
|
3200
|
+
const transitionedToOffline = (
|
|
3201
|
+
['starting', 'ready', 'processing'].includes(previousWorkerStatus)
|
|
3202
|
+
&& !nextWorkerState
|
|
3203
|
+
);
|
|
3204
|
+
const hasSessionUsageSummary = !!(
|
|
3205
|
+
currentConversation?.sessionUsageSummary
|
|
3206
|
+
|| currentConversation?.session_usage_summary
|
|
3207
|
+
);
|
|
3208
|
+
if (transitionedToOffline && currentId && !hasSessionUsageSummary) {
|
|
3209
|
+
refreshCurrentView().catch(() => {});
|
|
3210
|
+
}
|
|
3211
|
+
}
|
|
3212
|
+
|
|
3213
|
+
function startSessionWorkerStatusPolling() {
|
|
3214
|
+
if (sessionWorkerStatusPollTimer) return;
|
|
3215
|
+
sessionWorkerStatusPollTimer = setInterval(() => {
|
|
3216
|
+
if (!shouldRunForegroundNetworkWork()) return;
|
|
3217
|
+
refreshSessionWorkerStatus().catch(() => {});
|
|
3218
|
+
}, 4000);
|
|
3219
|
+
}
|
|
3220
|
+
|
|
3221
|
+
async function pollAuthenticatedCurrentConversationLive() {
|
|
3222
|
+
if (liveConversationPollInFlight) return;
|
|
3223
|
+
if (isSharedReaderMode()) return;
|
|
3224
|
+
const currentId = String(currentConvId || '').trim();
|
|
3225
|
+
if (!currentId) return;
|
|
3226
|
+
const currentConversation = conversations[currentId] || null;
|
|
3227
|
+
const isProcessing = String(currentConversation?.localTurnStatus || '').trim().toLowerCase() === 'processing'
|
|
3228
|
+
|| !!document.getElementById('thinking-indicator');
|
|
3229
|
+
if (!isProcessing) return;
|
|
3230
|
+
// Refreshing while the user selects or drags in the chat would rebuild the
|
|
3231
|
+
// DOM under the selection; the guard's release callback re-polls right away.
|
|
3232
|
+
if (isChatInteractionHeld()) return;
|
|
3233
|
+
|
|
3234
|
+
liveConversationPollInFlight = true;
|
|
3235
|
+
try {
|
|
3236
|
+
const messagesEl = document.getElementById('messages');
|
|
3237
|
+
const preserveBottom = isMessagesAtBottom();
|
|
3238
|
+
const savedScrollTop = messagesEl?.scrollTop || 0;
|
|
3239
|
+
const savedLoadedCount = loadConversationLoadedMessageCount(currentId);
|
|
3240
|
+
const requestLimit = Math.max(20, getConversationLoadedMessageCount() || 0, savedLoadedCount || 0);
|
|
3241
|
+
const response = await loadConversation(currentId, { limit: requestLimit });
|
|
3242
|
+
if (!response) return;
|
|
3243
|
+
if (String(currentConvId || '').trim() !== currentId) return;
|
|
3244
|
+
const stableSavedScrollTop = resolveStableScrollTopForLiveRefresh(messagesEl, savedScrollTop);
|
|
3245
|
+
applyLoadedConversationState(currentId, response, {
|
|
3246
|
+
restoreScroll: !preserveBottom,
|
|
3247
|
+
savedScrollTop: preserveBottom ? null : stableSavedScrollTop,
|
|
3248
|
+
followLiveUpdates: preserveBottom,
|
|
3249
|
+
});
|
|
3250
|
+
} finally {
|
|
3251
|
+
liveConversationPollInFlight = false;
|
|
3252
|
+
}
|
|
3253
|
+
}
|
|
3254
|
+
|
|
3255
|
+
function startLiveConversationPolling() {
|
|
3256
|
+
if (liveConversationPollTimer || isSharedReaderMode()) return;
|
|
3257
|
+
liveConversationPollTimer = setInterval(() => {
|
|
3258
|
+
if (!shouldRunForegroundNetworkWork()) return;
|
|
3259
|
+
pollAuthenticatedCurrentConversationLive().catch(() => {});
|
|
3260
|
+
}, 900);
|
|
3261
|
+
}
|
|
3262
|
+
|
|
3263
|
+
function setupViewportTracking() {
|
|
3264
|
+
syncViewportMetrics();
|
|
3265
|
+
const update = () => syncViewportMetrics();
|
|
3266
|
+
window.addEventListener('resize', update, { passive: true });
|
|
3267
|
+
window.addEventListener('orientationchange', update, { passive: true });
|
|
3268
|
+
if (window.visualViewport) {
|
|
3269
|
+
window.visualViewport.addEventListener('resize', update, { passive: true });
|
|
3270
|
+
window.visualViewport.addEventListener('scroll', update, { passive: true });
|
|
3271
|
+
}
|
|
3272
|
+
|
|
3273
|
+
// The page root must never scroll: #app is viewport-sized and body hides its
|
|
3274
|
+
// scrollbar, but programmatic scrolls (scrollIntoView, focus reveals) can
|
|
3275
|
+
// still shift the root and push the header off-screen with no way back.
|
|
3276
|
+
const clampRootScroll = () => {
|
|
3277
|
+
if (window.scrollY || document.documentElement.scrollTop || document.body.scrollTop) {
|
|
3278
|
+
window.scrollTo(0, 0);
|
|
3279
|
+
}
|
|
3280
|
+
};
|
|
3281
|
+
clampRootScroll();
|
|
3282
|
+
window.addEventListener('scroll', clampRootScroll, { passive: true });
|
|
3283
|
+
|
|
3284
|
+
// The relay question cards create their reply controls dynamically, so the
|
|
3285
|
+
// keyboard-open bookkeeping is delegated instead of bound per input.
|
|
3286
|
+
const isQuestionTextControl = (node) => node instanceof Element
|
|
3287
|
+
&& (node.tagName === 'TEXTAREA' || node.tagName === 'INPUT')
|
|
3288
|
+
&& !!node.closest('.relay-question-container');
|
|
3289
|
+
document.addEventListener('focusin', (event) => {
|
|
3290
|
+
if (!isQuestionTextControl(event.target)) return;
|
|
3291
|
+
document.body.classList.add('keyboard-open');
|
|
3292
|
+
syncViewportMetrics();
|
|
3293
|
+
});
|
|
3294
|
+
document.addEventListener('focusout', (event) => {
|
|
3295
|
+
if (!isQuestionTextControl(event.target)) return;
|
|
3296
|
+
document.body.classList.remove('keyboard-open');
|
|
3297
|
+
syncViewportMetrics();
|
|
3298
|
+
});
|
|
3299
|
+
|
|
3300
|
+
const input = document.getElementById('msg-input');
|
|
3301
|
+
if (input && input.dataset.viewportBound !== '1') {
|
|
3302
|
+
input.dataset.viewportBound = '1';
|
|
3303
|
+
input.addEventListener('input', () => {
|
|
3304
|
+
syncComposerControlState();
|
|
3305
|
+
}, { passive: true });
|
|
3306
|
+
input.addEventListener('focus', () => {
|
|
3307
|
+
document.body.classList.add('keyboard-open');
|
|
3308
|
+
syncViewportMetrics();
|
|
3309
|
+
}, { passive: true });
|
|
3310
|
+
input.addEventListener('blur', () => {
|
|
3311
|
+
document.body.classList.remove('keyboard-open');
|
|
3312
|
+
syncViewportMetrics();
|
|
3313
|
+
void flushConversationDraft(currentConvId);
|
|
3314
|
+
}, { passive: true });
|
|
3315
|
+
}
|
|
3316
|
+
}
|
|
3317
|
+
|
|
3318
|
+
function composerAttachmentsAllowed() {
|
|
3319
|
+
// Shared read-only viewers must never be able to attach files.
|
|
3320
|
+
return !appSharedMode && !IS_SHARED_VIEW;
|
|
3321
|
+
}
|
|
3322
|
+
|
|
3323
|
+
function initComposerAttachmentInput() {
|
|
3324
|
+
const input = document.getElementById('msg-input');
|
|
3325
|
+
if (input && input.dataset.pasteBound !== '1') {
|
|
3326
|
+
input.dataset.pasteBound = '1';
|
|
3327
|
+
input.addEventListener('paste', (event) => {
|
|
3328
|
+
if (!composerAttachmentsAllowed()) return;
|
|
3329
|
+
void handleComposerPaste(event);
|
|
3330
|
+
});
|
|
3331
|
+
}
|
|
3332
|
+
|
|
3333
|
+
const dropZone = document.getElementById('input-area');
|
|
3334
|
+
if (!dropZone || dropZone.dataset.dropBound === '1') return;
|
|
3335
|
+
dropZone.dataset.dropBound = '1';
|
|
3336
|
+
|
|
3337
|
+
// dragenter/dragleave fire for every child element, so the highlight is
|
|
3338
|
+
// reference counted instead of toggled, otherwise it flickers on each hover.
|
|
3339
|
+
let dragDepth = 0;
|
|
3340
|
+
const clearHighlight = () => {
|
|
3341
|
+
dragDepth = 0;
|
|
3342
|
+
dropZone.classList.remove('composer-dropzone-active');
|
|
3343
|
+
};
|
|
3344
|
+
|
|
3345
|
+
dropZone.addEventListener('dragenter', (event) => {
|
|
3346
|
+
if (!composerAttachmentsAllowed() || !dataTransferHasFiles(event.dataTransfer)) return;
|
|
3347
|
+
event.preventDefault();
|
|
3348
|
+
dragDepth += 1;
|
|
3349
|
+
dropZone.classList.add('composer-dropzone-active');
|
|
3350
|
+
});
|
|
3351
|
+
|
|
3352
|
+
dropZone.addEventListener('dragover', (event) => {
|
|
3353
|
+
if (!composerAttachmentsAllowed() || !dataTransferHasFiles(event.dataTransfer)) return;
|
|
3354
|
+
// Without preventDefault the browser refuses the drop entirely.
|
|
3355
|
+
event.preventDefault();
|
|
3356
|
+
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
|
|
3357
|
+
});
|
|
3358
|
+
|
|
3359
|
+
dropZone.addEventListener('dragleave', (event) => {
|
|
3360
|
+
if (!dragDepth) return;
|
|
3361
|
+
dragDepth = Math.max(0, dragDepth - 1);
|
|
3362
|
+
if (!dragDepth) dropZone.classList.remove('composer-dropzone-active');
|
|
3363
|
+
});
|
|
3364
|
+
|
|
3365
|
+
dropZone.addEventListener('drop', (event) => {
|
|
3366
|
+
clearHighlight();
|
|
3367
|
+
if (!composerAttachmentsAllowed()) return;
|
|
3368
|
+
void handleComposerDrop(event);
|
|
3369
|
+
});
|
|
3370
|
+
|
|
3371
|
+
window.addEventListener('dragend', clearHighlight);
|
|
3372
|
+
}
|
|
3373
|
+
|
|
3374
|
+
function initMessageScrollPersistence() {
|
|
3375
|
+
const el = document.getElementById('messages');
|
|
3376
|
+
if (!el || el.dataset.scrollPersistenceBound === '1') return;
|
|
3377
|
+
el.dataset.scrollPersistenceBound = '1';
|
|
3378
|
+
el.addEventListener('scroll', () => {
|
|
3379
|
+
const convId = String(currentConvId || '').trim();
|
|
3380
|
+
if (!convId) return;
|
|
3381
|
+
saveConversationScrollTop(convId, el.scrollTop);
|
|
3382
|
+
}, { passive: true });
|
|
3383
|
+
}
|
|
3384
|
+
|
|
3385
|
+
async function runConversationHistoryRefresh({ source = 'menu' } = {}) {
|
|
3386
|
+
const currentId = String(currentConvId || '').trim();
|
|
3387
|
+
if (!currentId) {
|
|
3388
|
+
if (source !== 'pull') showTransientRelayNotice('Select a conversation first.');
|
|
3389
|
+
return false;
|
|
3390
|
+
}
|
|
3391
|
+
const eligibility = canRefreshConversationHistory(currentId);
|
|
3392
|
+
if (!eligibility.ok) {
|
|
3393
|
+
showTransientRelayNotice(eligibility.reason);
|
|
3394
|
+
return false;
|
|
3395
|
+
}
|
|
3396
|
+
|
|
3397
|
+
const messagesEl = document.getElementById('messages');
|
|
3398
|
+
const scrollTop = messagesEl?.scrollTop || 0;
|
|
3399
|
+
const savedLoadedCount = loadConversationLoadedMessageCount(currentId);
|
|
3400
|
+
const requestLimit = Math.max(20, getConversationLoadedMessageCount() || 0, savedLoadedCount || 0);
|
|
3401
|
+
|
|
3402
|
+
setHistoryRefreshInFlight(true);
|
|
3403
|
+
syncRefreshHistoryMenuState();
|
|
3404
|
+
try {
|
|
3405
|
+
const refreshed = await refreshConversationHistory(currentId, { limit: requestLimit });
|
|
3406
|
+
if (!refreshed) {
|
|
3407
|
+
throw new Error('History refresh request failed.');
|
|
3408
|
+
}
|
|
3409
|
+
if (String(currentConvId || '').trim() === currentId) {
|
|
3410
|
+
applyLoadedConversationState(currentId, refreshed, { restoreScroll: false });
|
|
3411
|
+
}
|
|
3412
|
+
await refreshConversations();
|
|
3413
|
+
await loadRelayQuestions(currentId);
|
|
3414
|
+
await loadRelayBoards();
|
|
3415
|
+
scheduleContextUsageRefresh(currentId, 0);
|
|
3416
|
+
|
|
3417
|
+
if (messagesEl && String(currentConvId || '').trim() === currentId) {
|
|
3418
|
+
const savedScrollTop = loadConversationScrollTop(currentId);
|
|
3419
|
+
const nextScrollTop = Number.isFinite(savedScrollTop) ? savedScrollTop : scrollTop;
|
|
3420
|
+
messagesEl.scrollTop = nextScrollTop;
|
|
3421
|
+
if (Number.isFinite(nextScrollTop) && nextScrollTop >= 0) {
|
|
3422
|
+
saveConversationScrollTop(currentId, nextScrollTop);
|
|
3423
|
+
}
|
|
3424
|
+
}
|
|
3425
|
+
showTransientRelayNotice('Conversation history refreshed.');
|
|
3426
|
+
return true;
|
|
3427
|
+
} catch (error) {
|
|
3428
|
+
const message = String(error?.message || '').trim() || 'Could not refresh conversation history.';
|
|
3429
|
+
showTransientRelayNotice(message);
|
|
3430
|
+
return false;
|
|
3431
|
+
} finally {
|
|
3432
|
+
setHistoryRefreshInFlight(false);
|
|
3433
|
+
syncRefreshHistoryMenuState();
|
|
3434
|
+
}
|
|
3435
|
+
}
|
|
3436
|
+
|
|
3437
|
+
let refreshViewVersion = 0;
|
|
3438
|
+
|
|
3439
|
+
async function refreshCurrentView() {
|
|
3440
|
+
const capturedVersion = ++refreshViewVersion;
|
|
3441
|
+
const messagesEl = document.getElementById('messages');
|
|
3442
|
+
const preserveBottom = isMessagesAtBottom();
|
|
3443
|
+
const scrollTop = messagesEl?.scrollTop || 0;
|
|
3444
|
+
const stableSavedScrollTop = resolveStableScrollTopForLiveRefresh(messagesEl, scrollTop);
|
|
3445
|
+
|
|
3446
|
+
await refreshConversations();
|
|
3447
|
+
|
|
3448
|
+
const currentId = String(currentConvId || '').trim();
|
|
3449
|
+
if (!currentId) {
|
|
3450
|
+
setRepoBrowserSessionInfo('', '');
|
|
3451
|
+
renderMessages([]);
|
|
3452
|
+
restoreInFlightThinking(null);
|
|
3453
|
+
scheduleContextUsageRefresh(null);
|
|
3454
|
+
syncRefreshHistoryMenuState();
|
|
3455
|
+
return;
|
|
3456
|
+
}
|
|
3457
|
+
|
|
3458
|
+
const savedLoadedCount = loadConversationLoadedMessageCount(currentId);
|
|
3459
|
+
const requestLimit = Math.max(20, getConversationLoadedMessageCount() || 0, savedLoadedCount || 0);
|
|
3460
|
+
const r = await loadConversation(currentId, { limit: requestLimit });
|
|
3461
|
+
if (capturedVersion < refreshViewVersion) return;
|
|
3462
|
+
if (String(currentConvId || '').trim() !== currentId) return;
|
|
3463
|
+
if (r) {
|
|
3464
|
+
applyLoadedConversationState(currentId, r, {
|
|
3465
|
+
restoreScroll: !preserveBottom,
|
|
3466
|
+
savedScrollTop: preserveBottom ? null : stableSavedScrollTop,
|
|
3467
|
+
followLiveUpdates: preserveBottom,
|
|
3468
|
+
});
|
|
3469
|
+
} else {
|
|
3470
|
+
setRepoBrowserSessionInfo('', '');
|
|
3471
|
+
restoreInFlightThinking(null);
|
|
3472
|
+
}
|
|
3473
|
+
await loadRelayQuestions(currentId);
|
|
3474
|
+
await loadRelayBoards();
|
|
3475
|
+
scheduleContextUsageRefresh(currentId, 0);
|
|
3476
|
+
if (messagesEl && String(currentConvId || '').trim() === currentId) {
|
|
3477
|
+
if (!preserveBottom && Number.isFinite(stableSavedScrollTop) && stableSavedScrollTop >= 0) {
|
|
3478
|
+
messagesEl.scrollTop = stableSavedScrollTop;
|
|
3479
|
+
}
|
|
3480
|
+
saveConversationScrollTop(currentId, messagesEl.scrollTop);
|
|
3481
|
+
}
|
|
3482
|
+
syncRefreshHistoryMenuState();
|
|
3483
|
+
}
|
|
3484
|
+
|
|
3485
|
+
async function copyTextToClipboard(text) {
|
|
3486
|
+
const value = String(text || '').trim();
|
|
3487
|
+
if (!value) return false;
|
|
3488
|
+
if (navigator?.clipboard?.writeText) {
|
|
3489
|
+
try {
|
|
3490
|
+
await navigator.clipboard.writeText(value);
|
|
3491
|
+
return true;
|
|
3492
|
+
} catch {}
|
|
3493
|
+
}
|
|
3494
|
+
try {
|
|
3495
|
+
const el = document.createElement('textarea');
|
|
3496
|
+
el.value = value;
|
|
3497
|
+
el.setAttribute('readonly', 'readonly');
|
|
3498
|
+
el.style.position = 'fixed';
|
|
3499
|
+
el.style.opacity = '0';
|
|
3500
|
+
document.body.appendChild(el);
|
|
3501
|
+
el.focus();
|
|
3502
|
+
el.select();
|
|
3503
|
+
const ok = document.execCommand('copy');
|
|
3504
|
+
document.body.removeChild(el);
|
|
3505
|
+
return !!ok;
|
|
3506
|
+
} catch {
|
|
3507
|
+
return false;
|
|
3508
|
+
}
|
|
3509
|
+
}
|
|
3510
|
+
|
|
3511
|
+
function initChatTitleCopy() {
|
|
3512
|
+
const title = document.getElementById('chat-title');
|
|
3513
|
+
if (!title || title.dataset.copyBound === '1') return;
|
|
3514
|
+
title.dataset.copyBound = '1';
|
|
3515
|
+
if (title.dataset.fullscreenBound) delete title.dataset.fullscreenBound;
|
|
3516
|
+
|
|
3517
|
+
title.addEventListener('click', (event) => {
|
|
3518
|
+
event.preventDefault();
|
|
3519
|
+
event.stopPropagation();
|
|
3520
|
+
event.stopImmediatePropagation();
|
|
3521
|
+
const sessionId = String(title.dataset.copilotSessionId || '').trim();
|
|
3522
|
+
if (!sessionId) return;
|
|
3523
|
+
copyTextToClipboard(sessionId).then((ok) => {
|
|
3524
|
+
if (ok) {
|
|
3525
|
+
showTransientRelayNotice(`Copied Copilot session ID: ${sessionId.slice(0, 8)}…`);
|
|
3526
|
+
} else {
|
|
3527
|
+
showTransientRelayNotice('Could not copy session ID.');
|
|
3528
|
+
}
|
|
3529
|
+
}).catch(() => {});
|
|
3530
|
+
}, true);
|
|
3531
|
+
}
|
|
3532
|
+
|
|
3533
|
+
function getChatTitleElements() {
|
|
3534
|
+
return {
|
|
3535
|
+
wrap: document.getElementById('chat-title-wrap'),
|
|
3536
|
+
title: document.getElementById('chat-title'),
|
|
3537
|
+
editBtn: document.getElementById('chat-actions-menu-btn'),
|
|
3538
|
+
editor: document.getElementById('chat-title-editor'),
|
|
3539
|
+
input: document.getElementById('chat-title-input'),
|
|
3540
|
+
saveBtn: document.getElementById('chat-title-save-btn'),
|
|
3541
|
+
cancelBtn: document.getElementById('chat-title-cancel-btn'),
|
|
3542
|
+
};
|
|
3543
|
+
}
|
|
3544
|
+
|
|
3545
|
+
function closeChatActionsMenu() {
|
|
3546
|
+
const menu = document.getElementById('chat-actions-menu');
|
|
3547
|
+
const trigger = document.getElementById('chat-actions-menu-btn');
|
|
3548
|
+
const backdrop = document.getElementById('chat-actions-menu-backdrop');
|
|
3549
|
+
if (menu) menu.hidden = true;
|
|
3550
|
+
if (trigger) trigger.setAttribute('aria-expanded', 'false');
|
|
3551
|
+
if (backdrop && !window.__chatActionsMenuShieldTimer) backdrop.classList.remove('visible');
|
|
3552
|
+
}
|
|
3553
|
+
|
|
3554
|
+
function toggleChatActionsMenu() {
|
|
3555
|
+
const menu = document.getElementById('chat-actions-menu');
|
|
3556
|
+
const trigger = document.getElementById('chat-actions-menu-btn');
|
|
3557
|
+
if (!menu || !trigger || trigger.hidden || trigger.disabled) return;
|
|
3558
|
+
syncRefreshHistoryMenuState();
|
|
3559
|
+
syncThemeMenuLabel();
|
|
3560
|
+
const willOpen = !!menu.hidden;
|
|
3561
|
+
menu.hidden = !willOpen;
|
|
3562
|
+
trigger.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
|
|
3563
|
+
}
|
|
3564
|
+
|
|
3565
|
+
function isConversationLocallyProcessing(conversation) {
|
|
3566
|
+
if (!conversation || typeof conversation !== 'object') return false;
|
|
3567
|
+
const localTurnStatus = String(conversation?.localTurnStatus || '').trim().toLowerCase();
|
|
3568
|
+
if (localTurnStatus === 'processing') {
|
|
3569
|
+
const updatedAtMs = Number(conversation?.localTurnStatusUpdatedAt || 0);
|
|
3570
|
+
if (!Number.isFinite(updatedAtMs) || updatedAtMs <= 0 || (Date.now() - updatedAtMs) < LOCAL_PROCESSING_STALE_MS) {
|
|
3571
|
+
return true;
|
|
3572
|
+
}
|
|
3573
|
+
}
|
|
3574
|
+
const runtimeStatus = String(
|
|
3575
|
+
conversation?.runtimeSessionStatus
|
|
3576
|
+
|| conversation?.runtime_session_status
|
|
3577
|
+
|| conversation?.status
|
|
3578
|
+
|| '',
|
|
3579
|
+
).trim().toLowerCase();
|
|
3580
|
+
return runtimeStatus === 'processing';
|
|
3581
|
+
}
|
|
3582
|
+
|
|
3583
|
+
function canRefreshConversationHistory(conversationId) {
|
|
3584
|
+
const currentId = String(conversationId || '').trim();
|
|
3585
|
+
if (!currentId) {
|
|
3586
|
+
return { ok: false, reason: 'Select a conversation first.' };
|
|
3587
|
+
}
|
|
3588
|
+
if (isHistoryRefreshInFlight()) {
|
|
3589
|
+
return { ok: false, reason: 'History refresh is already running.' };
|
|
3590
|
+
}
|
|
3591
|
+
if (isSendInFlight()) {
|
|
3592
|
+
return { ok: false, reason: 'Wait for the current send to finish before refreshing history.' };
|
|
3593
|
+
}
|
|
3594
|
+
const conversation = conversations[currentId] || null;
|
|
3595
|
+
if (isConversationLocallyProcessing(conversation)) {
|
|
3596
|
+
return { ok: false, reason: 'Wait for the current turn to finish before refreshing history.' };
|
|
3597
|
+
}
|
|
3598
|
+
return { ok: true };
|
|
3599
|
+
}
|
|
3600
|
+
|
|
3601
|
+
function syncRefreshHistoryMenuState() {
|
|
3602
|
+
const button = document.getElementById('chat-menu-refresh-history');
|
|
3603
|
+
if (!button) return;
|
|
3604
|
+
if (button.hidden || button.getAttribute('aria-hidden') === 'true') {
|
|
3605
|
+
button.disabled = true;
|
|
3606
|
+
button.tabIndex = -1;
|
|
3607
|
+
button.setAttribute('aria-disabled', 'true');
|
|
3608
|
+
return;
|
|
3609
|
+
}
|
|
3610
|
+
if (isHistoryRefreshInFlight()) {
|
|
3611
|
+
button.disabled = true;
|
|
3612
|
+
button.title = 'Refreshing conversation history';
|
|
3613
|
+
return;
|
|
3614
|
+
}
|
|
3615
|
+
const eligibility = canRefreshConversationHistory(currentConvId);
|
|
3616
|
+
button.disabled = !eligibility.ok;
|
|
3617
|
+
button.title = eligibility.ok ? 'Rebuild this conversation history from SDK events.' : eligibility.reason;
|
|
3618
|
+
}
|
|
3619
|
+
|
|
3620
|
+
function lockChatActionsMenuShield(ms = 300) {
|
|
3621
|
+
const backdrop = document.getElementById('chat-actions-menu-backdrop');
|
|
3622
|
+
if (!backdrop) return;
|
|
3623
|
+
if (window.__chatActionsMenuShieldTimer) {
|
|
3624
|
+
window.clearTimeout(window.__chatActionsMenuShieldTimer);
|
|
3625
|
+
}
|
|
3626
|
+
backdrop.classList.add('visible');
|
|
3627
|
+
window.__chatActionsMenuShieldTimer = window.setTimeout(() => {
|
|
3628
|
+
window.__chatActionsMenuShieldTimer = null;
|
|
3629
|
+
const menu = document.getElementById('chat-actions-menu');
|
|
3630
|
+
if (menu?.hidden) backdrop.classList.remove('visible');
|
|
3631
|
+
}, Math.max(150, Number(ms) || 300));
|
|
3632
|
+
}
|
|
3633
|
+
|
|
3634
|
+
|
|
3635
|
+
function syncChatTitleControls() {
|
|
3636
|
+
const { title, editBtn, editor, input } = getChatTitleElements();
|
|
3637
|
+
const convId = String(currentConvId || '').trim();
|
|
3638
|
+
const killBtn = document.getElementById('chat-menu-kill-session');
|
|
3639
|
+
const sharedMode = isSharedReaderMode();
|
|
3640
|
+
const conversation = convId ? (conversations[convId] || null) : null;
|
|
3641
|
+
const sdkSessionId = String(conversation?.sdkSessionId || '').trim();
|
|
3642
|
+
if (title) {
|
|
3643
|
+
if (!convId || !conversation) {
|
|
3644
|
+
delete title.dataset.uiState;
|
|
3645
|
+
} else {
|
|
3646
|
+
const pendingByConversation = getPendingQuestionCountsByConversation();
|
|
3647
|
+
const pendingCount = Number(pendingByConversation[convId] || 0);
|
|
3648
|
+
const workerState = sdkSessionId ? getSessionWorkerState(sdkSessionId) : null;
|
|
3649
|
+
const uiState = resolveConversationUiState({
|
|
3650
|
+
conversation,
|
|
3651
|
+
workerState,
|
|
3652
|
+
hasPendingQuestion: pendingCount > 0,
|
|
3653
|
+
});
|
|
3654
|
+
title.dataset.uiState = uiState;
|
|
3655
|
+
}
|
|
3656
|
+
}
|
|
3657
|
+
if (chatTitleEditingConversationId && chatTitleEditingConversationId !== convId) {
|
|
3658
|
+
chatTitleEditingConversationId = null;
|
|
3659
|
+
}
|
|
3660
|
+
const editing = convId && chatTitleEditingConversationId === convId;
|
|
3661
|
+
document.body.classList.toggle('chat-title-editing', !!editing);
|
|
3662
|
+
if (title) title.hidden = editing;
|
|
3663
|
+
if (editBtn) {
|
|
3664
|
+
editBtn.hidden = !convId;
|
|
3665
|
+
editBtn.disabled = !convId || editing;
|
|
3666
|
+
}
|
|
3667
|
+
if (killBtn) {
|
|
3668
|
+
killBtn.disabled = sharedMode || !convId || !sdkSessionId;
|
|
3669
|
+
killBtn.hidden = sharedMode || !convId;
|
|
3670
|
+
}
|
|
3671
|
+
if (editor) {
|
|
3672
|
+
editor.hidden = !editing;
|
|
3673
|
+
}
|
|
3674
|
+
if (input) {
|
|
3675
|
+
input.maxLength = CHAT_TITLE_MAX_LENGTH;
|
|
3676
|
+
}
|
|
3677
|
+
if (!convId && chatTitleEditingConversationId) {
|
|
3678
|
+
chatTitleEditingConversationId = null;
|
|
3679
|
+
}
|
|
3680
|
+
if (!editing && editor && !editor.hidden) {
|
|
3681
|
+
editor.hidden = true;
|
|
3682
|
+
}
|
|
3683
|
+
if (!isStatusViewActive()) {
|
|
3684
|
+
syncChatHeaderWorkspaceLabel();
|
|
3685
|
+
}
|
|
3686
|
+
syncChatTitleWatcherIndicator();
|
|
3687
|
+
}
|
|
3688
|
+
|
|
3689
|
+
function openChatTitleEditor() {
|
|
3690
|
+
const convId = String(currentConvId || '').trim();
|
|
3691
|
+
if (!convId) return;
|
|
3692
|
+
const { title, editBtn, editor, input } = getChatTitleElements();
|
|
3693
|
+
closeChatActionsMenu();
|
|
3694
|
+
const currentTitle = String(conversations[convId]?.title || title?.textContent || convId).trim() || convId;
|
|
3695
|
+
chatTitleEditingConversationId = convId;
|
|
3696
|
+
document.body.classList.add('chat-title-editing');
|
|
3697
|
+
if (title) title.hidden = true;
|
|
3698
|
+
if (editBtn) editBtn.disabled = true;
|
|
3699
|
+
if (editor) editor.hidden = false;
|
|
3700
|
+
if (input) {
|
|
3701
|
+
input.maxLength = CHAT_TITLE_MAX_LENGTH;
|
|
3702
|
+
input.value = currentTitle;
|
|
3703
|
+
requestAnimationFrame(() => {
|
|
3704
|
+
if (chatTitleEditingConversationId !== convId) return;
|
|
3705
|
+
window.setTimeout(() => {
|
|
3706
|
+
if (chatTitleEditingConversationId !== convId) return;
|
|
3707
|
+
input.focus({ preventScroll: true });
|
|
3708
|
+
input.select();
|
|
3709
|
+
}, 50);
|
|
3710
|
+
});
|
|
3711
|
+
}
|
|
3712
|
+
}
|
|
3713
|
+
|
|
3714
|
+
function closeChatTitleEditor() {
|
|
3715
|
+
chatTitleEditingConversationId = null;
|
|
3716
|
+
document.body.classList.remove('chat-title-editing');
|
|
3717
|
+
const { title, editBtn, editor, input } = getChatTitleElements();
|
|
3718
|
+
closeChatActionsMenu();
|
|
3719
|
+
if (editor) editor.hidden = true;
|
|
3720
|
+
if (title) title.hidden = !String(currentConvId || '').trim();
|
|
3721
|
+
if (editBtn) editBtn.disabled = !String(currentConvId || '').trim();
|
|
3722
|
+
if (input) {
|
|
3723
|
+
const convId = String(currentConvId || '').trim();
|
|
3724
|
+
input.value = convId ? String(conversations[convId]?.title || title?.textContent || convId) : '';
|
|
3725
|
+
}
|
|
3726
|
+
syncChatTitleControls();
|
|
3727
|
+
}
|
|
3728
|
+
|
|
3729
|
+
function applyConversationTitleUpdate(conversationId, title, updatedAt) {
|
|
3730
|
+
const id = String(conversationId || '').trim();
|
|
3731
|
+
const nextTitle = String(title || '').trim();
|
|
3732
|
+
if (!id || !nextTitle) return;
|
|
3733
|
+
const existing = conversations[id] || { id, archived: false, messageCount: 0 };
|
|
3734
|
+
conversations[id] = {
|
|
3735
|
+
...existing,
|
|
3736
|
+
title: nextTitle,
|
|
3737
|
+
updatedAt: String(updatedAt || existing.updatedAt || new Date().toISOString()),
|
|
3738
|
+
};
|
|
3739
|
+
if (currentConvId === id) {
|
|
3740
|
+
const titleEl = document.getElementById('chat-title');
|
|
3741
|
+
if (titleEl) titleEl.textContent = nextTitle;
|
|
3742
|
+
}
|
|
3743
|
+
renderConvList();
|
|
3744
|
+
}
|
|
3745
|
+
|
|
3746
|
+
function applyConversationWorkspaceRootUpdate(payload = {}) {
|
|
3747
|
+
const conversationId = String(payload.conversationId || '').trim();
|
|
3748
|
+
if (!conversationId) return;
|
|
3749
|
+
const existing = conversations[conversationId] || { id: conversationId, archived: false, messageCount: 0 };
|
|
3750
|
+
const previousCurrentPath = String(existing.currentWorkspaceRootPath || '').trim();
|
|
3751
|
+
// A relaunch into a new CWD must not keep the old runtime root alive through
|
|
3752
|
+
// the `existing` fallback: the payload is the post-relaunch truth.
|
|
3753
|
+
const nextRuntimePath = Object.prototype.hasOwnProperty.call(payload, 'runtimeWorkspaceRootPath')
|
|
3754
|
+
? String(payload.runtimeWorkspaceRootPath || '').trim()
|
|
3755
|
+
: String(existing.runtimeWorkspaceRootPath || '').trim();
|
|
3756
|
+
const nextRuntimeName = Object.prototype.hasOwnProperty.call(payload, 'runtimeWorkspaceRootName')
|
|
3757
|
+
? String(payload.runtimeWorkspaceRootName || '').trim()
|
|
3758
|
+
: String(existing.runtimeWorkspaceRootName || '').trim();
|
|
3759
|
+
conversations[conversationId] = {
|
|
3760
|
+
...existing,
|
|
3761
|
+
configuredWorkspaceRootPath: String(payload.configuredWorkspaceRootPath || existing.configuredWorkspaceRootPath || '').trim() || null,
|
|
3762
|
+
configuredWorkspaceRootName: String(payload.configuredWorkspaceRootName || existing.configuredWorkspaceRootName || '').trim() || null,
|
|
3763
|
+
runtimeWorkspaceRootPath: nextRuntimePath || null,
|
|
3764
|
+
runtimeWorkspaceRootName: nextRuntimeName || null,
|
|
3765
|
+
currentWorkspaceRootPath: String(payload.currentWorkspaceRootPath || existing.currentWorkspaceRootPath || '').trim() || null,
|
|
3766
|
+
currentWorkspaceRootName: String(payload.currentWorkspaceRootName || existing.currentWorkspaceRootName || '').trim() || null,
|
|
3767
|
+
};
|
|
3768
|
+
if (currentConvId !== conversationId) return;
|
|
3769
|
+
syncChatHeaderWorkspaceLabel();
|
|
3770
|
+
const nextCurrentPath = String(conversations[conversationId].currentWorkspaceRootPath || '').trim();
|
|
3771
|
+
const rootChanged = nextCurrentPath.toLowerCase() !== previousCurrentPath.toLowerCase();
|
|
3772
|
+
if (rootChanged) {
|
|
3773
|
+
resetWorkspaceRepoBrowserForRootChange();
|
|
3774
|
+
} else if (repoBrowserState.activeRoot === 'workspace' && repoBrowserState.open) {
|
|
3775
|
+
// Same root: refresh through the restoring path so open folders and the
|
|
3776
|
+
// current selection survive.
|
|
3777
|
+
refreshRepoBrowser();
|
|
3778
|
+
}
|
|
3779
|
+
}
|
|
3780
|
+
|
|
3781
|
+
async function submitChatTitleEditor() {
|
|
3782
|
+
const convId = String(chatTitleEditingConversationId || currentConvId || '').trim();
|
|
3783
|
+
if (!convId) return;
|
|
3784
|
+
const { input } = getChatTitleElements();
|
|
3785
|
+
const nextTitle = String(input?.value || '').replace(/[\r\n]+/g, ' ').trim();
|
|
3786
|
+
if (!nextTitle) {
|
|
3787
|
+
setModelBanner('⚠️ Conversation title cannot be empty.');
|
|
3788
|
+
input?.focus();
|
|
3789
|
+
return;
|
|
3790
|
+
}
|
|
3791
|
+
if (nextTitle.length > CHAT_TITLE_MAX_LENGTH) {
|
|
3792
|
+
setModelBanner(`⚠️ Conversation title must be ${CHAT_TITLE_MAX_LENGTH} characters or fewer.`);
|
|
3793
|
+
input?.focus();
|
|
3794
|
+
return;
|
|
3795
|
+
}
|
|
3796
|
+
|
|
3797
|
+
const result = await updateConversationTitle(convId, nextTitle);
|
|
3798
|
+
if (!result) {
|
|
3799
|
+
alert('Failed to update conversation title');
|
|
3800
|
+
return;
|
|
3801
|
+
}
|
|
3802
|
+
|
|
3803
|
+
applyConversationTitleUpdate(result.conversationId || convId, result.title || nextTitle, result.updatedAt);
|
|
3804
|
+
closeChatTitleEditor();
|
|
3805
|
+
}
|
|
3806
|
+
|
|
3807
|
+
|
|
3808
|
+
initSocketHandlers({
|
|
3809
|
+
refreshCurrentView,
|
|
3810
|
+
refreshSessionWorkerStatus,
|
|
3811
|
+
refreshModelCatalog,
|
|
3812
|
+
updateModelCatalogState,
|
|
3813
|
+
reconcileOpenModelVariantModal,
|
|
3814
|
+
applyConversationWorkspaceRootUpdate,
|
|
3815
|
+
applyConversationTitleUpdate,
|
|
3816
|
+
syncChatTitleControls,
|
|
3817
|
+
applyConversationPreferencesForConversation,
|
|
3818
|
+
applyOpenAISettingsState,
|
|
3819
|
+
applyClaudeSettingsState,
|
|
3820
|
+
applyCopilotSettingsState,
|
|
3821
|
+
applyGrokSettingsState,
|
|
3822
|
+
applyCursorSettingsState,
|
|
3823
|
+
});
|
|
3824
|
+
|
|
3825
|
+
initCwdPicker({
|
|
3826
|
+
applyConversationWorkspaceRootUpdate,
|
|
3827
|
+
refreshSessionWorkerStatus,
|
|
3828
|
+
});
|
|
3829
|
+
|
|
3830
|
+
initActionConfirmations({
|
|
3831
|
+
lockChatActionsMenuShield,
|
|
3832
|
+
closeChatActionsMenu,
|
|
3833
|
+
syncQueueStatusMenuEntry,
|
|
3834
|
+
refreshSessionWorkerStatus,
|
|
3835
|
+
exposeOnWindow: false,
|
|
3836
|
+
});
|
|
3837
|
+
initTmuxInspectorView({
|
|
3838
|
+
bindMenuAction,
|
|
3839
|
+
lockChatActionsMenuShield,
|
|
3840
|
+
closeChatActionsMenu,
|
|
3841
|
+
});
|
|
3842
|
+
|
|
3843
|
+
function showAuthGate(error = '') {
|
|
3844
|
+
document.getElementById('startup-loading')?.remove();
|
|
3845
|
+
document.getElementById('auth-gate').style.display = 'flex';
|
|
3846
|
+
document.getElementById('app').classList.remove('visible');
|
|
3847
|
+
showAuthError(error);
|
|
3848
|
+
document.getElementById('token-input')?.focus();
|
|
3849
|
+
}
|
|
3850
|
+
|
|
3851
|
+
// Runs before anything reads MODEL_STORAGE_KEY so the first load after an
|
|
3852
|
+
// upgrade already sees the model the user last picked in the New Chat modal.
|
|
3853
|
+
function migrateLegacyModelStorageKey() {
|
|
3854
|
+
try {
|
|
3855
|
+
const legacy = String(localStorage.getItem(LEGACY_MODEL_STORAGE_KEY) || '').trim();
|
|
3856
|
+
if (!legacy) return;
|
|
3857
|
+
if (!String(localStorage.getItem(MODEL_STORAGE_KEY) || '').trim()) {
|
|
3858
|
+
localStorage.setItem(MODEL_STORAGE_KEY, legacy);
|
|
3859
|
+
}
|
|
3860
|
+
localStorage.removeItem(LEGACY_MODEL_STORAGE_KEY);
|
|
3861
|
+
} catch {}
|
|
3862
|
+
}
|
|
3863
|
+
|
|
3864
|
+
async function initApp() {
|
|
3865
|
+
migrateLegacyModelStorageKey();
|
|
3866
|
+
initClientDiagnostics();
|
|
3867
|
+
installExternalLinkPolicy({ onFallback: showExternalLinkFallback });
|
|
3868
|
+
const sharedMode = isSharedReaderMode();
|
|
3869
|
+
appSharedMode = sharedMode;
|
|
3870
|
+
initNetworkLifecycleHandling();
|
|
3871
|
+
setForegroundNetworkWorkEnabled(document.visibilityState === 'visible');
|
|
3872
|
+
const sharedThemeBtn = document.getElementById('chat-menu-shared-theme-toggle');
|
|
3873
|
+
initTheme();
|
|
3874
|
+
initThemeMenuToggle();
|
|
3875
|
+
if (sharedThemeBtn && sharedMode) sharedThemeBtn.hidden = false;
|
|
3876
|
+
initFontScaling();
|
|
3877
|
+
if (!sharedMode) {
|
|
3878
|
+
clearLegacyKnownCwdHistoryStorage();
|
|
3879
|
+
}
|
|
3880
|
+
syncPwaVersionMenuEntry();
|
|
3881
|
+
syncQueueStatusMenuEntry();
|
|
3882
|
+
if (!sharedMode) {
|
|
3883
|
+
syncSuspendHostVisibility();
|
|
3884
|
+
}
|
|
3885
|
+
setupViewportTracking();
|
|
3886
|
+
bindChatSelectionGuard();
|
|
3887
|
+
chatSelectionGuard.onRelease(() => {
|
|
3888
|
+
flushDeferredMessageRender();
|
|
3889
|
+
pollAuthenticatedCurrentConversationLive().catch(() => {});
|
|
3890
|
+
});
|
|
3891
|
+
window.addEventListener('pagehide', () => {
|
|
3892
|
+
stopSharedModeTimers();
|
|
3893
|
+
// The direct flush below can be killed mid-flight by the browser; the
|
|
3894
|
+
// queued copy survives and is replayed by Background Sync. If the direct
|
|
3895
|
+
// write wins, the replay hits a draft version conflict and is dropped.
|
|
3896
|
+
if (!appSharedMode) void enqueueDraftFlushForBackgroundSync(currentConvId);
|
|
3897
|
+
void flushConversationDraft(currentConvId);
|
|
3898
|
+
closeTmuxInspectorView();
|
|
3899
|
+
});
|
|
3900
|
+
document.getElementById('startup-loading')?.remove();
|
|
3901
|
+
document.getElementById('auth-gate').style.display = 'none';
|
|
3902
|
+
document.getElementById('app').classList.add('visible');
|
|
3903
|
+
initSidebarLayout();
|
|
3904
|
+
const chatActionsMenuBtn = document.getElementById('chat-actions-menu-btn');
|
|
3905
|
+
bindTapAction(chatActionsMenuBtn, (event) => {
|
|
3906
|
+
event.preventDefault();
|
|
3907
|
+
event.stopPropagation();
|
|
3908
|
+
toggleChatActionsMenu();
|
|
3909
|
+
});
|
|
3910
|
+
if (!sharedMode) {
|
|
3911
|
+
const chatMenuEditBtn = document.getElementById('chat-menu-edit-title');
|
|
3912
|
+
const chatMenuUsageBtn = document.getElementById('chat-menu-usage');
|
|
3913
|
+
bindMenuAction(chatMenuUsageBtn, (event) => {
|
|
3914
|
+
event.preventDefault();
|
|
3915
|
+
event.stopPropagation();
|
|
3916
|
+
lockChatActionsMenuShield(350);
|
|
3917
|
+
closeChatActionsMenu();
|
|
3918
|
+
showUsage().catch((error) => {
|
|
3919
|
+
alert(error?.message || 'Failed to load usage');
|
|
3920
|
+
});
|
|
3921
|
+
});
|
|
3922
|
+
bindMenuAction(chatMenuEditBtn, (event) => {
|
|
3923
|
+
event.preventDefault();
|
|
3924
|
+
event.stopPropagation();
|
|
3925
|
+
lockChatActionsMenuShield(350);
|
|
3926
|
+
closeChatActionsMenu();
|
|
3927
|
+
openChatTitleEditor();
|
|
3928
|
+
});
|
|
3929
|
+
const chatMenuCompactBtn = document.getElementById('chat-menu-compact');
|
|
3930
|
+
if (chatMenuCompactBtn && !chatMenuCompactBtn.hidden && !chatMenuCompactBtn.disabled) {
|
|
3931
|
+
bindMenuAction(chatMenuCompactBtn, (event) => {
|
|
3932
|
+
event.preventDefault();
|
|
3933
|
+
event.stopPropagation();
|
|
3934
|
+
lockChatActionsMenuShield(350);
|
|
3935
|
+
closeChatActionsMenu();
|
|
3936
|
+
compactCurrentConversation().catch((error) => {
|
|
3937
|
+
alert(error?.message || 'Failed to compact conversation');
|
|
3938
|
+
});
|
|
3939
|
+
});
|
|
3940
|
+
}
|
|
3941
|
+
const chatMenuShareConversationBtn = document.getElementById('chat-menu-share-conversation');
|
|
3942
|
+
bindMenuAction(chatMenuShareConversationBtn, (event) => {
|
|
3943
|
+
event.preventDefault();
|
|
3944
|
+
event.stopPropagation();
|
|
3945
|
+
lockChatActionsMenuShield(350);
|
|
3946
|
+
closeChatActionsMenu();
|
|
3947
|
+
const convId = String(currentConvId || '').trim();
|
|
3948
|
+
if (!convId) {
|
|
3949
|
+
showTransientRelayNotice('Select a conversation first.');
|
|
3950
|
+
return;
|
|
3951
|
+
}
|
|
3952
|
+
createConversationShareLink(convId).then(async (result) => {
|
|
3953
|
+
const shareUrl = String(result?.shareUrl || '').trim();
|
|
3954
|
+
if (!shareUrl) {
|
|
3955
|
+
showTransientRelayNotice(result?.error || 'Could not create share link.');
|
|
3956
|
+
return;
|
|
3957
|
+
}
|
|
3958
|
+
const copied = await copyTextToClipboard(shareUrl);
|
|
3959
|
+
showTransientRelayNotice(copied ? 'Share link copied to clipboard.' : shareUrl);
|
|
3960
|
+
}).catch((error) => {
|
|
3961
|
+
showTransientRelayNotice(error?.message || 'Could not create share link.');
|
|
3962
|
+
});
|
|
3963
|
+
});
|
|
3964
|
+
const chatMenuRefreshHistoryBtn = document.getElementById('chat-menu-refresh-history');
|
|
3965
|
+
if (chatMenuRefreshHistoryBtn && !chatMenuRefreshHistoryBtn.hidden && !chatMenuRefreshHistoryBtn.disabled) {
|
|
3966
|
+
bindMenuAction(chatMenuRefreshHistoryBtn, (event) => {
|
|
3967
|
+
event.preventDefault();
|
|
3968
|
+
event.stopPropagation();
|
|
3969
|
+
lockChatActionsMenuShield(350);
|
|
3970
|
+
closeChatActionsMenu();
|
|
3971
|
+
runConversationHistoryRefresh({ source: 'menu' }).catch(() => {});
|
|
3972
|
+
});
|
|
3973
|
+
}
|
|
3974
|
+
const chatMenuSelectModelsBtn = document.getElementById('chat-menu-select-models');
|
|
3975
|
+
bindMenuAction(chatMenuSelectModelsBtn, (event) => {
|
|
3976
|
+
event.preventDefault();
|
|
3977
|
+
event.stopPropagation();
|
|
3978
|
+
lockChatActionsMenuShield(350);
|
|
3979
|
+
closeChatActionsMenu();
|
|
3980
|
+
openSelectModelsModal().catch((error) => {
|
|
3981
|
+
alert(error?.message || 'Failed to open model selector');
|
|
3982
|
+
});
|
|
3983
|
+
});
|
|
3984
|
+
const chatMenuChangeCwdBtn = document.getElementById('chat-menu-change-cwd');
|
|
3985
|
+
bindMenuAction(chatMenuChangeCwdBtn, (event) => {
|
|
3986
|
+
event.preventDefault();
|
|
3987
|
+
event.stopPropagation();
|
|
3988
|
+
lockChatActionsMenuShield(350);
|
|
3989
|
+
closeChatActionsMenu();
|
|
3990
|
+
openChangeCwdModal();
|
|
3991
|
+
});
|
|
3992
|
+
const chatMenuGitChangesBtn = document.getElementById('chat-menu-git-changes');
|
|
3993
|
+
bindMenuAction(chatMenuGitChangesBtn, (event) => {
|
|
3994
|
+
event.preventDefault();
|
|
3995
|
+
event.stopPropagation();
|
|
3996
|
+
lockChatActionsMenuShield(350);
|
|
3997
|
+
closeChatActionsMenu();
|
|
3998
|
+
openGitChangesModal();
|
|
3999
|
+
});
|
|
4000
|
+
const chatMenuSettingsBtn = document.getElementById('chat-menu-settings');
|
|
4001
|
+
bindMenuAction(chatMenuSettingsBtn, (event) => {
|
|
4002
|
+
event.preventDefault();
|
|
4003
|
+
event.stopPropagation();
|
|
4004
|
+
lockChatActionsMenuShield(350);
|
|
4005
|
+
closeChatActionsMenu();
|
|
4006
|
+
openSettingsModal();
|
|
4007
|
+
});
|
|
4008
|
+
const chatMenuRestartRelayBtn = document.getElementById('chat-menu-restart-relay');
|
|
4009
|
+
bindMenuAction(chatMenuRestartRelayBtn, (event) => {
|
|
4010
|
+
event.preventDefault();
|
|
4011
|
+
event.stopPropagation();
|
|
4012
|
+
lockChatActionsMenuShield(350);
|
|
4013
|
+
closeChatActionsMenu();
|
|
4014
|
+
openRestartRelayConfirmation();
|
|
4015
|
+
});
|
|
4016
|
+
const chatMenuEmptyQueueBtn = document.getElementById('chat-menu-empty-queue');
|
|
4017
|
+
bindMenuAction(chatMenuEmptyQueueBtn, (event) => {
|
|
4018
|
+
event.preventDefault();
|
|
4019
|
+
event.stopPropagation();
|
|
4020
|
+
openEmptyQueueConfirmation();
|
|
4021
|
+
});
|
|
4022
|
+
const chatMenuKillBtn = document.getElementById('chat-menu-kill-session');
|
|
4023
|
+
bindMenuAction(chatMenuKillBtn, (event) => {
|
|
4024
|
+
event.preventDefault();
|
|
4025
|
+
event.stopPropagation();
|
|
4026
|
+
lockChatActionsMenuShield(350);
|
|
4027
|
+
closeChatActionsMenu();
|
|
4028
|
+
openKillSessionConfirmation();
|
|
4029
|
+
});
|
|
4030
|
+
}
|
|
4031
|
+
const sidebarToggleBtn = document.getElementById('sidebar-toggle');
|
|
4032
|
+
bindTapAction(sidebarToggleBtn, (event) => {
|
|
4033
|
+
event.preventDefault();
|
|
4034
|
+
event.stopPropagation();
|
|
4035
|
+
toggleSidebar();
|
|
4036
|
+
});
|
|
4037
|
+
if (!window.__chatActionsMenuBound) {
|
|
4038
|
+
window.__chatActionsMenuBound = true;
|
|
4039
|
+
document.addEventListener('click', (event) => {
|
|
4040
|
+
const menuWrap = document.getElementById('chat-actions-menu-wrap');
|
|
4041
|
+
if (!menuWrap) return;
|
|
4042
|
+
if (menuWrap.contains(event.target)) return;
|
|
4043
|
+
closeChatActionsMenu();
|
|
4044
|
+
});
|
|
4045
|
+
document.addEventListener('keydown', (event) => {
|
|
4046
|
+
if (event.key !== 'Escape') return;
|
|
4047
|
+
closeChatActionsMenu();
|
|
4048
|
+
});
|
|
4049
|
+
}
|
|
4050
|
+
const chatTitleEditor = document.getElementById('chat-title-editor');
|
|
4051
|
+
if (chatTitleEditor && chatTitleEditor.dataset.bound !== '1') {
|
|
4052
|
+
chatTitleEditor.dataset.bound = '1';
|
|
4053
|
+
chatTitleEditor.addEventListener('submit', (event) => {
|
|
4054
|
+
event.preventDefault();
|
|
4055
|
+
submitChatTitleEditor().catch((error) => {
|
|
4056
|
+
alert(error?.message || 'Failed to update conversation title');
|
|
4057
|
+
});
|
|
4058
|
+
});
|
|
4059
|
+
}
|
|
4060
|
+
const chatTitleInput = document.getElementById('chat-title-input');
|
|
4061
|
+
if (chatTitleInput && chatTitleInput.dataset.bound !== '1') {
|
|
4062
|
+
chatTitleInput.dataset.bound = '1';
|
|
4063
|
+
chatTitleInput.addEventListener('keydown', (event) => {
|
|
4064
|
+
if (event.key === 'Escape') {
|
|
4065
|
+
event.preventDefault();
|
|
4066
|
+
closeChatTitleEditor();
|
|
4067
|
+
}
|
|
4068
|
+
});
|
|
4069
|
+
}
|
|
4070
|
+
const chatTitleCancelBtn = document.getElementById('chat-title-cancel-btn');
|
|
4071
|
+
if (chatTitleCancelBtn && chatTitleCancelBtn.dataset.bound !== '1') {
|
|
4072
|
+
chatTitleCancelBtn.dataset.bound = '1';
|
|
4073
|
+
chatTitleCancelBtn.addEventListener('click', (event) => {
|
|
4074
|
+
event.preventDefault();
|
|
4075
|
+
event.stopPropagation();
|
|
4076
|
+
closeChatTitleEditor();
|
|
4077
|
+
});
|
|
4078
|
+
}
|
|
4079
|
+
if (sharedMode) {
|
|
4080
|
+
initFullscreenButton();
|
|
4081
|
+
initConversationHistoryLazyLoading();
|
|
4082
|
+
initBubbleActionHandlers();
|
|
4083
|
+
initMessageScrollPersistence();
|
|
4084
|
+
syncChatTitleControls();
|
|
4085
|
+
await initSharedConversationReader();
|
|
4086
|
+
return;
|
|
4087
|
+
}
|
|
4088
|
+
initAutoCompactWindowControl();
|
|
4089
|
+
initThinkingControl();
|
|
4090
|
+
initModeSelector();
|
|
4091
|
+
initModelSelector();
|
|
4092
|
+
initReasoningSelector();
|
|
4093
|
+
initContextTierSelector();
|
|
4094
|
+
const modelMetadataRetryBtn = document.getElementById('model-metadata-retry-btn');
|
|
4095
|
+
if (modelMetadataRetryBtn && modelMetadataRetryBtn.dataset.bound !== '1') {
|
|
4096
|
+
modelMetadataRetryBtn.dataset.bound = '1';
|
|
4097
|
+
modelMetadataRetryBtn.addEventListener('click', () => {
|
|
4098
|
+
void retryModelMetadataRefresh();
|
|
4099
|
+
});
|
|
4100
|
+
}
|
|
4101
|
+
syncModelMetadataBlocker();
|
|
4102
|
+
const status = await refreshWorkspaceRootHints();
|
|
4103
|
+
syncQueueStatusMenuEntry(status);
|
|
4104
|
+
setSessionWorkerStatesFromStatusPayload(status?.sessionWorker || null);
|
|
4105
|
+
await refreshOpenAISettingsState();
|
|
4106
|
+
await refreshCursorSettingsState();
|
|
4107
|
+
await refreshGrokSettingsState();
|
|
4108
|
+
await refreshModelCatalog(true);
|
|
4109
|
+
initFullscreenButton();
|
|
4110
|
+
initInstallButton();
|
|
4111
|
+
syncRefreshHistoryMenuState();
|
|
4112
|
+
initChatTitleCopy();
|
|
4113
|
+
initEmojiPicker();
|
|
4114
|
+
initSlashAutocomplete();
|
|
4115
|
+
initConversationListLazyLoading();
|
|
4116
|
+
initConversationHistoryLazyLoading();
|
|
4117
|
+
initBubbleActionHandlers();
|
|
4118
|
+
initMessageScrollPersistence();
|
|
4119
|
+
initComposerAttachmentInput();
|
|
4120
|
+
initMessageSearchView({ openConversation });
|
|
4121
|
+
initGitChangesView();
|
|
4122
|
+
syncChatTitleControls();
|
|
4123
|
+
connectSocket();
|
|
4124
|
+
startRelayConnectionWatchdog();
|
|
4125
|
+
startDeviceVisibilityHeartbeat();
|
|
4126
|
+
startRelayQuestionPolling();
|
|
4127
|
+
startRelayBoardPolling();
|
|
4128
|
+
startSessionWorkerStatusPolling();
|
|
4129
|
+
startLiveConversationPolling();
|
|
4130
|
+
initPushNotificationClientHooks();
|
|
4131
|
+
// Browsers without Background Sync replay the outbox from the page instead.
|
|
4132
|
+
void initOutboxFallbackReplay();
|
|
4133
|
+
await loadConversations();
|
|
4134
|
+
const pushConversationId = consumePushConversationDeepLink();
|
|
4135
|
+
if (pushConversationId) {
|
|
4136
|
+
await openConversation(pushConversationId).catch(() => {});
|
|
4137
|
+
}
|
|
4138
|
+
await loadRelayQuestions(currentConvId);
|
|
4139
|
+
await loadRelayBoards();
|
|
4140
|
+
updateCompactButton();
|
|
4141
|
+
document.getElementById('msg-input').focus();
|
|
4142
|
+
}
|
|
4143
|
+
|
|
4144
|
+
async function doAuth() {
|
|
4145
|
+
const tokenInput = document.getElementById('token-input');
|
|
4146
|
+
const val = tokenInput.value.trim() || getTokenFromUrl();
|
|
4147
|
+
if (!val) return showAuthError('Please enter a token');
|
|
4148
|
+
const result = await verifyToken(val);
|
|
4149
|
+
if (result?.ok) {
|
|
4150
|
+
setToken('');
|
|
4151
|
+
tokenInput.value = '';
|
|
4152
|
+
await startAppWithErrorHandling();
|
|
4153
|
+
} else {
|
|
4154
|
+
showAuthError(resolveAuthErrorMessage(result));
|
|
4155
|
+
}
|
|
4156
|
+
}
|
|
4157
|
+
|
|
4158
|
+
|
|
4159
|
+
async function bootstrap() {
|
|
4160
|
+
if (ensureTrailingSlashPath()) return;
|
|
4161
|
+
const sharedMode = isSharedReaderMode();
|
|
4162
|
+
if (sharedMode && navigator.serviceWorker?.getRegistrations) {
|
|
4163
|
+
navigator.serviceWorker.getRegistrations()
|
|
4164
|
+
.then((registrations) => Promise.all(registrations.map((registration) => registration.unregister().catch(() => false))))
|
|
4165
|
+
.catch(() => {});
|
|
4166
|
+
}
|
|
4167
|
+
if (!sharedMode) {
|
|
4168
|
+
await applyPwaManifestFromSettings();
|
|
4169
|
+
}
|
|
4170
|
+
if (!sharedMode) {
|
|
4171
|
+
registerPwaShell();
|
|
4172
|
+
initInstallButton();
|
|
4173
|
+
}
|
|
4174
|
+
if (sharedMode) {
|
|
4175
|
+
await startAppWithErrorHandling();
|
|
4176
|
+
return;
|
|
4177
|
+
}
|
|
4178
|
+
const urlToken = getTokenFromUrl();
|
|
4179
|
+
if (urlToken) stripTokenFromUrl();
|
|
4180
|
+
const persistedToken = consumeLegacyPersistedAuthToken();
|
|
4181
|
+
const bootstrapToken = String(urlToken || persistedToken || '').trim();
|
|
4182
|
+
const existingSession = await verifyExistingSession(bootstrapToken);
|
|
4183
|
+
if (existingSession?.ok) {
|
|
4184
|
+
setToken('');
|
|
4185
|
+
await startAppWithErrorHandling();
|
|
4186
|
+
return;
|
|
4187
|
+
}
|
|
4188
|
+
if (bootstrapToken) {
|
|
4189
|
+
setToken('');
|
|
4190
|
+
document.getElementById('token-input').value = '';
|
|
4191
|
+
showAuthGate(resolveAuthErrorMessage(existingSession));
|
|
4192
|
+
return;
|
|
4193
|
+
}
|
|
4194
|
+
showAuthGate();
|
|
4195
|
+
}
|
|
4196
|
+
|
|
4197
|
+
window.updateTheme = updateTheme;
|
|
4198
|
+
window.updateFontScaleFromSelect = updateFontScaleFromSelect;
|
|
4199
|
+
window.updatePwaAppName = updatePwaAppName;
|
|
4200
|
+
window.updateDefaultSessionWorkspaceRootSetting = updateDefaultSessionWorkspaceRootSetting;
|
|
4201
|
+
window.saveOpenAISettings = saveOpenAISettings;
|
|
4202
|
+
window.removeOpenAISettings = removeOpenAISettings;
|
|
4203
|
+
window.toggleOpenAIProvider = toggleOpenAIProvider;
|
|
4204
|
+
window.saveClaudeSettings = saveClaudeSettings;
|
|
4205
|
+
window.saveCopilotSettings = saveCopilotSettings;
|
|
4206
|
+
window.toggleClaudeProvider = toggleClaudeProvider;
|
|
4207
|
+
window.startClaudeRelogin = startClaudeRelogin;
|
|
4208
|
+
window.submitClaudeLoginCodeFromInput = submitClaudeLoginCodeFromInput;
|
|
4209
|
+
window.handleClaudeLoginCodeKey = handleClaudeLoginCodeKey;
|
|
4210
|
+
window.cancelClaudeRelogin = cancelClaudeRelogin;
|
|
4211
|
+
window.copyClaudeLoginUrl = copyClaudeLoginUrl;
|
|
4212
|
+
window.openClaudeLogoutConfirmation = openClaudeLogoutConfirmation;
|
|
4213
|
+
window.confirmClaudeLogout = confirmClaudeLogout;
|
|
4214
|
+
window.startGrokSignIn = startGrokSignIn;
|
|
4215
|
+
window.cancelGrokSignIn = cancelGrokSignIn;
|
|
4216
|
+
window.copyGrokLoginUrl = copyGrokLoginUrl;
|
|
4217
|
+
window.openGrokLogoutConfirmation = openGrokLogoutConfirmation;
|
|
4218
|
+
window.confirmGrokLogout = confirmGrokLogout;
|
|
4219
|
+
window.confirmCliInstall = confirmCliInstall;
|
|
4220
|
+
window.runCliInstall = runCliInstall;
|
|
4221
|
+
window.cancelCliInstallRun = cancelCliInstallRun;
|
|
4222
|
+
window.saveGrokSettings = saveGrokSettings;
|
|
4223
|
+
window.toggleGrokProvider = toggleGrokProvider;
|
|
4224
|
+
window.saveCursorSettings = saveCursorSettings;
|
|
4225
|
+
window.removeCursorSettings = removeCursorSettings;
|
|
4226
|
+
window.saveCursorAllowanceSettings = saveCursorAllowanceSettings;
|
|
4227
|
+
window.resetCursorAllowanceAccounting = resetCursorAllowanceAccounting;
|
|
4228
|
+
window.saveCursorDashboardToken = saveCursorDashboardToken;
|
|
4229
|
+
window.removeCursorDashboardToken = removeCursorDashboardToken;
|
|
4230
|
+
window.saveGrokAllowanceSettings = saveGrokAllowanceSettings;
|
|
4231
|
+
window.resetGrokAllowanceAccounting = resetGrokAllowanceAccounting;
|
|
4232
|
+
window.toggleCursorProvider = toggleCursorProvider;
|
|
4233
|
+
window.updateShowSuspendHostSetting = updateShowSuspendHostSetting;
|
|
4234
|
+
window.updateWindowsAutostartSettingFromToggle = updateWindowsAutostartSettingFromToggle;
|
|
4235
|
+
window.previewTurnCeilingSetting = previewTurnCeilingSetting;
|
|
4236
|
+
window.updateTurnCeilingSetting = updateTurnCeilingSetting;
|
|
4237
|
+
window.previewBackgroundTaskTimeoutSetting = previewBackgroundTaskTimeoutSetting;
|
|
4238
|
+
window.updateBackgroundTaskTimeoutSetting = updateBackgroundTaskTimeoutSetting;
|
|
4239
|
+
window.togglePushOnThisDevice = togglePushOnThisDevice;
|
|
4240
|
+
window.updatePushPreferencesFromControls = updatePushPreferencesFromControls;
|
|
4241
|
+
window.openSettingsModal = openSettingsModal;
|
|
4242
|
+
window.closeSettingsModal = closeSettingsModal;
|
|
4243
|
+
window.doAuth = doAuth;
|
|
4244
|
+
window.initApp = initApp;
|
|
4245
|
+
window.connectSocket = connectSocket;
|
|
4246
|
+
window.openSidebar = openSidebar;
|
|
4247
|
+
window.closeSidebar = closeSidebar;
|
|
4248
|
+
window.toggleSidebar = toggleSidebar;
|
|
4249
|
+
window.showUsage = showUsage;
|
|
4250
|
+
window.openChangeCwdModal = openChangeCwdModal;
|
|
4251
|
+
window.confirmChangeCwd = confirmChangeCwd;
|
|
4252
|
+
window.confirmChangeCwdAndLaunch = confirmChangeCwdAndLaunch;
|
|
4253
|
+
window.showContext = showContext;
|
|
4254
|
+
window.promptInstallApp = promptInstallApp;
|
|
4255
|
+
window.toggleFullscreen = toggleFullscreen;
|
|
4256
|
+
window.syncAutoModelAvailability = syncAutoModelAvailability;
|
|
4257
|
+
window.openRepoBrowser = openRepoBrowser;
|
|
4258
|
+
window.closeRepoBrowser = closeRepoBrowser;
|
|
4259
|
+
window.loadRepoBrowserTree = loadRepoBrowserTree;
|
|
4260
|
+
window.refreshRepoBrowser = refreshRepoBrowser;
|
|
4261
|
+
window.initModeSelector = initModeSelector;
|
|
4262
|
+
window.initModelSelector = initModelSelector;
|
|
4263
|
+
window.refreshModelCatalog = refreshModelCatalog;
|
|
4264
|
+
window.retryModelMetadataRefresh = retryModelMetadataRefresh;
|
|
4265
|
+
window.isModelMetadataBlocked = () => modelMetadataBlocked || !isModelMetadataHealthy();
|
|
4266
|
+
window.saveSelectedModelsFromModal = saveSelectedModelsFromModal;
|
|
4267
|
+
window.selectedModelValue = selectedModelValue;
|
|
4268
|
+
window.selectedReasoningEffortValue = selectedReasoningEffortValue;
|
|
4269
|
+
window.getPreferredModelSelection = () => selectedModelValue();
|
|
4270
|
+
window.applyConversationPreferences = applyConversationPreferencesForConversation;
|
|
4271
|
+
window.applyModelCatalogState = updateModelCatalogState;
|
|
4272
|
+
window.updateCliStatus = updateCliStatus;
|
|
4273
|
+
window.showAuthError = showAuthError;
|
|
4274
|
+
window.registerPwaShell = registerPwaShell;
|
|
4275
|
+
window.newConversation = newConversation;
|
|
4276
|
+
window.confirmNewConversationModel = confirmNewConversationModel;
|
|
4277
|
+
window.closeNewConversationModelModal = closeNewConversationModelModal;
|
|
4278
|
+
window.deleteConv = deleteConv;
|
|
4279
|
+
window.openConversation = openConversation;
|
|
4280
|
+
window.toggleStatusView = toggleStatusView;
|
|
4281
|
+
window.refreshConversations = refreshConversations;
|
|
4282
|
+
window.renderConvList = renderConvList;
|
|
4283
|
+
window.handleAttachmentInput = handleAttachmentInput;
|
|
4284
|
+
window.retryAttachmentUpload = retryAttachmentUpload;
|
|
4285
|
+
window.removeAttachment = removeAttachment;
|
|
4286
|
+
window.clearAttachments = clearAttachments;
|
|
4287
|
+
window.openUploadedAttachmentViewer = openUploadedAttachmentViewer;
|
|
4288
|
+
window.setFilePreviewMode = setFilePreviewMode;
|
|
4289
|
+
window.toggleFilePreviewHtml = toggleFilePreviewHtml;
|
|
4290
|
+
window.closeFilePreview = closeFilePreview;
|
|
4291
|
+
window.goBackFilePreview = goBackFilePreview;
|
|
4292
|
+
window.openWorkspaceFilePreview = openWorkspaceFilePreview;
|
|
4293
|
+
window.openWorkspaceFilePreviewFromRepo = openWorkspaceFilePreviewFromRepo;
|
|
4294
|
+
window.setRepoBrowserRoot = setRepoBrowserRoot;
|
|
4295
|
+
window.setRepoBrowserViewMode = setRepoBrowserViewMode;
|
|
4296
|
+
window.toggleRepoBrowserHidden = toggleRepoBrowserHidden;
|
|
4297
|
+
window.toggleRepoBrowserHeavy = toggleRepoBrowserHeavy;
|
|
4298
|
+
window.focusRepoTree = focusRepoTree;
|
|
4299
|
+
window.setRepoCurrentPath = setRepoCurrentPath;
|
|
4300
|
+
window.confirmRepoBrowserCwdPick = confirmRepoBrowserCwdPick;
|
|
4301
|
+
window.toggleEmojiPicker = toggleEmojiPicker;
|
|
4302
|
+
window.submitRelayQuestionChoice = submitRelayQuestionChoice;
|
|
4303
|
+
window.submitRelayQuestionAnswer = submitRelayQuestionAnswer;
|
|
4304
|
+
window.submitRelayStructuredAnswer = submitRelayStructuredAnswer;
|
|
4305
|
+
window.onRelayQuestionDraftInput = onRelayQuestionDraftInput;
|
|
4306
|
+
window.handleRelayQuestionKey = handleRelayQuestionKey;
|
|
4307
|
+
window.openPendingQuestionFromBanner = openPendingQuestionFromBanner;
|
|
4308
|
+
window.submitRelayBoardAction = submitRelayBoardAction;
|
|
4309
|
+
window.compactCurrentConversation = compactCurrentConversation;
|
|
4310
|
+
window.sendMessage = sendMessage;
|
|
4311
|
+
window.syncComposerControlState = syncComposerControlState;
|
|
4312
|
+
window.persistComposerAttachments = persistComposerAttachments;
|
|
4313
|
+
window.appendMessage = appendMessage;
|
|
4314
|
+
window.loadOlderConversationMessages = loadOlderConversationMessages;
|
|
4315
|
+
window.handleKey = handleKey;
|
|
4316
|
+
window.setImageEditTarget = setImageEditTarget;
|
|
4317
|
+
window.clearImageEditTarget = clearImageEditTarget;
|
|
4318
|
+
window.jumpToImageParent = jumpToImageParent;
|
|
4319
|
+
window.autoResize = autoResize;
|
|
4320
|
+
window.updateComposerSlashMenu = updateComposerSlashMenu;
|
|
4321
|
+
window.closeSummaryModal = closeSummaryModal;
|
|
4322
|
+
window.refreshSummaryModal = refreshSummaryModal;
|
|
4323
|
+
window.renderSummaryModalContent = renderSummaryModalContent;
|
|
4324
|
+
window.setSummaryModalLoading = setSummaryModalLoading;
|
|
4325
|
+
window.openSummaryModal = openSummaryModal;
|
|
4326
|
+
window.syncChatTitleControls = syncChatTitleControls;
|
|
4327
|
+
window.closeChatActionsMenu = closeChatActionsMenu;
|
|
4328
|
+
window.openSuspendHostConfirmation = openSuspendHostConfirmation;
|
|
4329
|
+
window.confirmKillCurrentSession = confirmKillCurrentSession;
|
|
4330
|
+
window.confirmRestartWebRelay = confirmRestartWebRelay;
|
|
4331
|
+
window.confirmSuspendHost = confirmSuspendHost;
|
|
4332
|
+
window.confirmEmptyQueue = confirmEmptyQueue;
|
|
4333
|
+
window.openMessageSearchModal = openMessageSearchModal;
|
|
4334
|
+
window.closeMessageSearchModal = closeMessageSearchModal;
|
|
4335
|
+
window.openGitChangesModal = openGitChangesModal;
|
|
4336
|
+
window.closeGitChangesModal = closeGitChangesModal;
|
|
4337
|
+
window.openGitDiffViewer = openGitDiffViewer;
|
|
4338
|
+
window.closeGitDiffViewer = closeGitDiffViewer;
|
|
4339
|
+
window.setGitDiffMode = setGitDiffMode;
|
|
4340
|
+
window.copyExternalLinkUrl = copyExternalLinkUrl;
|
|
4341
|
+
window.retryExternalLinkOpen = retryExternalLinkOpen;
|
|
4342
|
+
|
|
4343
|
+
window.addEventListener('copilot:external-link-fallback', (event) => {
|
|
4344
|
+
showExternalLinkFallback(event.detail?.url);
|
|
4345
|
+
});
|
|
4346
|
+
|
|
4347
|
+
bootstrap();
|