@mseep/claudian 2.0.25
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/.env.local.example +2 -0
- package/.github/workflows/ci.yml +59 -0
- package/.github/workflows/claude-code-review.yml +57 -0
- package/.github/workflows/claude.yml +50 -0
- package/.github/workflows/duplicate-issues.yml +22 -0
- package/.github/workflows/release.yml +73 -0
- package/.github/workflows/stale.yml +21 -0
- package/.node-version +1 -0
- package/AGENTS.md +3 -0
- package/CLAUDE.md +80 -0
- package/LICENSE +21 -0
- package/README.md +190 -0
- package/assets/Preview.png +0 -0
- package/assets/sponsors/MOMA.png +0 -0
- package/bun.lock +1618 -0
- package/esbuild.config.mjs +195 -0
- package/eslint.config.mjs +143 -0
- package/jest.config.js +41 -0
- package/main.js +104915 -0
- package/manifest.json +10 -0
- package/package.json +65 -0
- package/scripts/build-css.mjs +119 -0
- package/scripts/build.mjs +19 -0
- package/scripts/postinstall.mjs +25 -0
- package/scripts/rendererSafeUnref.js +205 -0
- package/scripts/run-jest.js +19 -0
- package/scripts/sync-version.js +16 -0
- package/src/app/settings/ClaudianSettingsStorage.ts +435 -0
- package/src/app/settings/defaultSettings.ts +54 -0
- package/src/app/storage/SharedStorageService.ts +106 -0
- package/src/core/CLAUDE.md +84 -0
- package/src/core/auxiliary/AuxQueryRunner.ts +11 -0
- package/src/core/auxiliary/QueryBackedInlineEditService.ts +73 -0
- package/src/core/auxiliary/QueryBackedInstructionRefineService.ts +78 -0
- package/src/core/auxiliary/QueryBackedTitleGenerationService.ts +90 -0
- package/src/core/bootstrap/SessionStorage.ts +179 -0
- package/src/core/bootstrap/StoragePaths.ts +7 -0
- package/src/core/bootstrap/storage.ts +20 -0
- package/src/core/commands/builtInCommands.ts +141 -0
- package/src/core/mcp/McpConfigParser.ts +102 -0
- package/src/core/mcp/McpServerManager.ts +119 -0
- package/src/core/mcp/McpTester.ts +310 -0
- package/src/core/prompt/inlineEdit.ts +252 -0
- package/src/core/prompt/instructionRefine.ts +72 -0
- package/src/core/prompt/mainAgent.ts +213 -0
- package/src/core/prompt/titleGeneration.ts +44 -0
- package/src/core/providers/ProviderRegistry.ts +253 -0
- package/src/core/providers/ProviderSettingsCoordinator.ts +434 -0
- package/src/core/providers/ProviderWorkspaceRegistry.ts +119 -0
- package/src/core/providers/commands/ProviderCommandCatalog.ts +21 -0
- package/src/core/providers/commands/ProviderCommandEntry.ts +33 -0
- package/src/core/providers/commands/hiddenCommands.ts +74 -0
- package/src/core/providers/modelRouting.ts +17 -0
- package/src/core/providers/modelSelection.ts +72 -0
- package/src/core/providers/providerConfig.ts +34 -0
- package/src/core/providers/providerEnvironment.ts +364 -0
- package/src/core/providers/types.ts +544 -0
- package/src/core/runtime/ChatRuntime.ts +66 -0
- package/src/core/runtime/QueuedTurn.ts +97 -0
- package/src/core/runtime/types.ts +118 -0
- package/src/core/security/ApprovalManager.ts +142 -0
- package/src/core/storage/HomeFileAdapter.ts +75 -0
- package/src/core/storage/VaultFileAdapter.ts +132 -0
- package/src/core/tools/todo.ts +65 -0
- package/src/core/tools/toolIcons.ts +80 -0
- package/src/core/tools/toolInput.ts +119 -0
- package/src/core/tools/toolNames.ts +149 -0
- package/src/core/tools/toolResultContent.ts +26 -0
- package/src/core/types/agent.ts +28 -0
- package/src/core/types/chat.ts +177 -0
- package/src/core/types/diff.ts +31 -0
- package/src/core/types/index.ts +78 -0
- package/src/core/types/mcp.ts +97 -0
- package/src/core/types/plugins.ts +9 -0
- package/src/core/types/provider.ts +1 -0
- package/src/core/types/settings.ts +152 -0
- package/src/core/types/tools.ts +80 -0
- package/src/features/chat/CLAUDE.md +136 -0
- package/src/features/chat/ClaudianView.ts +762 -0
- package/src/features/chat/constants.ts +114 -0
- package/src/features/chat/controllers/BrowserSelectionController.ts +295 -0
- package/src/features/chat/controllers/CanvasSelectionController.ts +142 -0
- package/src/features/chat/controllers/ConversationController.ts +1103 -0
- package/src/features/chat/controllers/InputController.ts +1707 -0
- package/src/features/chat/controllers/NavigationController.ts +209 -0
- package/src/features/chat/controllers/SelectionController.ts +430 -0
- package/src/features/chat/controllers/StreamController.ts +1560 -0
- package/src/features/chat/controllers/contextRowVisibility.ts +18 -0
- package/src/features/chat/rendering/DiffRenderer.ts +134 -0
- package/src/features/chat/rendering/InlineAskUserQuestion.ts +702 -0
- package/src/features/chat/rendering/InlineExitPlanMode.ts +263 -0
- package/src/features/chat/rendering/InlinePlanApproval.ts +183 -0
- package/src/features/chat/rendering/MessageRenderer.ts +907 -0
- package/src/features/chat/rendering/SubagentRenderer.ts +678 -0
- package/src/features/chat/rendering/ThinkingBlockRenderer.ts +126 -0
- package/src/features/chat/rendering/TodoListRenderer.ts +5 -0
- package/src/features/chat/rendering/ToolCallRenderer.ts +1161 -0
- package/src/features/chat/rendering/WriteEditRenderer.ts +232 -0
- package/src/features/chat/rendering/collapsible.ts +101 -0
- package/src/features/chat/rendering/subagentLifecycleResolution.ts +25 -0
- package/src/features/chat/rendering/todoUtils.ts +29 -0
- package/src/features/chat/rewind.ts +31 -0
- package/src/features/chat/services/BangBashService.ts +56 -0
- package/src/features/chat/services/SubagentManager.ts +1107 -0
- package/src/features/chat/state/ChatState.ts +436 -0
- package/src/features/chat/state/types.ts +138 -0
- package/src/features/chat/tabs/Tab.ts +1886 -0
- package/src/features/chat/tabs/TabBar.ts +179 -0
- package/src/features/chat/tabs/TabManager.ts +1021 -0
- package/src/features/chat/tabs/providerResolution.ts +34 -0
- package/src/features/chat/tabs/types.ts +287 -0
- package/src/features/chat/ui/BangBashModeManager.ts +121 -0
- package/src/features/chat/ui/FileContext.ts +385 -0
- package/src/features/chat/ui/ImageContext.ts +366 -0
- package/src/features/chat/ui/InputToolbar.ts +1244 -0
- package/src/features/chat/ui/InstructionModeManager.ts +158 -0
- package/src/features/chat/ui/NavigationSidebar.ts +126 -0
- package/src/features/chat/ui/StatusPanel.ts +589 -0
- package/src/features/chat/ui/file-context/state/FileContextState.ts +83 -0
- package/src/features/chat/ui/file-context/view/FileChipsView.ts +70 -0
- package/src/features/chat/ui/textareaResize.ts +47 -0
- package/src/features/chat/utils/usageInfo.ts +26 -0
- package/src/features/inline-edit/ui/InlineEditModal.ts +895 -0
- package/src/features/inline-edit/ui/inlineEditMarkdownPreview.ts +55 -0
- package/src/features/settings/ClaudianSettings.ts +672 -0
- package/src/features/settings/keyboardNavigation.ts +60 -0
- package/src/features/settings/ui/EnvSnippetManager.ts +430 -0
- package/src/features/settings/ui/EnvironmentSettingsSection.ts +85 -0
- package/src/features/settings/ui/McpServerModal.ts +335 -0
- package/src/features/settings/ui/McpSettingsManager.ts +400 -0
- package/src/features/settings/ui/McpTestModal.ts +346 -0
- package/src/i18n/constants.ts +58 -0
- package/src/i18n/i18n.ts +140 -0
- package/src/i18n/locales/de.json +322 -0
- package/src/i18n/locales/en.json +322 -0
- package/src/i18n/locales/es.json +322 -0
- package/src/i18n/locales/fr.json +322 -0
- package/src/i18n/locales/ja.json +322 -0
- package/src/i18n/locales/ko.json +322 -0
- package/src/i18n/locales/pt.json +322 -0
- package/src/i18n/locales/ru.json +322 -0
- package/src/i18n/locales/zh-CN.json +322 -0
- package/src/i18n/locales/zh-TW.json +322 -0
- package/src/i18n/types.ts +248 -0
- package/src/main.ts +772 -0
- package/src/providers/acp/AcpClientConnection.ts +361 -0
- package/src/providers/acp/AcpJsonRpcTransport.ts +427 -0
- package/src/providers/acp/AcpSessionConfig.ts +139 -0
- package/src/providers/acp/AcpSessionUpdateNormalizer.ts +371 -0
- package/src/providers/acp/AcpSubprocess.ts +155 -0
- package/src/providers/acp/AcpToolStreamAdapter.ts +132 -0
- package/src/providers/acp/buildAcpUsageInfo.ts +41 -0
- package/src/providers/acp/index.ts +9 -0
- package/src/providers/acp/methodNames.ts +50 -0
- package/src/providers/acp/types.ts +566 -0
- package/src/providers/claude/CLAUDE.md +78 -0
- package/src/providers/claude/agents/AgentManager.ts +225 -0
- package/src/providers/claude/agents/AgentStorage.ts +101 -0
- package/src/providers/claude/app/ClaudeWorkspaceServices.ts +90 -0
- package/src/providers/claude/auxiliary/ClaudeInlineEditService.ts +122 -0
- package/src/providers/claude/auxiliary/ClaudeInstructionRefineService.ts +90 -0
- package/src/providers/claude/auxiliary/ClaudeTitleGenerationService.ts +129 -0
- package/src/providers/claude/auxiliary/extractAssistantText.ts +28 -0
- package/src/providers/claude/capabilities.ts +17 -0
- package/src/providers/claude/cli/findClaudeCLIPath.ts +261 -0
- package/src/providers/claude/commands/ClaudeCommandCatalog.ts +149 -0
- package/src/providers/claude/commands/probeRuntimeCommands.ts +86 -0
- package/src/providers/claude/env/ClaudeSettingsReconciler.ts +90 -0
- package/src/providers/claude/env/claudeModelEnv.ts +86 -0
- package/src/providers/claude/history/ClaudeConversationHistoryService.ts +446 -0
- package/src/providers/claude/history/ClaudeHistoryStore.ts +170 -0
- package/src/providers/claude/history/sdkAsyncSubagent.ts +92 -0
- package/src/providers/claude/history/sdkBranchFilter.ts +271 -0
- package/src/providers/claude/history/sdkHistoryTypes.ts +61 -0
- package/src/providers/claude/history/sdkMessageParsing.ts +413 -0
- package/src/providers/claude/history/sdkSessionPaths.ts +98 -0
- package/src/providers/claude/history/sdkSubagentSidecar.ts +261 -0
- package/src/providers/claude/hooks/SubagentHooks.ts +31 -0
- package/src/providers/claude/modelLabels.ts +77 -0
- package/src/providers/claude/modelOptions.ts +113 -0
- package/src/providers/claude/modelSelection.ts +17 -0
- package/src/providers/claude/plugins/PluginManager.ts +194 -0
- package/src/providers/claude/prompt/ClaudeTurnEncoder.ts +46 -0
- package/src/providers/claude/registration.ts +39 -0
- package/src/providers/claude/runtime/ClaudeApprovalHandler.ts +153 -0
- package/src/providers/claude/runtime/ClaudeChatRuntime.ts +1812 -0
- package/src/providers/claude/runtime/ClaudeCliResolver.ts +94 -0
- package/src/providers/claude/runtime/ClaudeDynamicUpdates.ts +164 -0
- package/src/providers/claude/runtime/ClaudeMessageChannel.ts +209 -0
- package/src/providers/claude/runtime/ClaudeQueryOptionsBuilder.ts +315 -0
- package/src/providers/claude/runtime/ClaudeRewindService.ts +220 -0
- package/src/providers/claude/runtime/ClaudeSessionManager.ts +92 -0
- package/src/providers/claude/runtime/ClaudeTaskResultInterpreter.ts +172 -0
- package/src/providers/claude/runtime/ClaudeUserMessageFactory.ts +83 -0
- package/src/providers/claude/runtime/claudeColdStartQuery.ts +152 -0
- package/src/providers/claude/runtime/customSpawn.ts +87 -0
- package/src/providers/claude/runtime/types.ts +134 -0
- package/src/providers/claude/sdk/messages.ts +17 -0
- package/src/providers/claude/sdk/toolResultContent.ts +4 -0
- package/src/providers/claude/sdk/typeGuards.ts +14 -0
- package/src/providers/claude/sdk/types.ts +15 -0
- package/src/providers/claude/security/ClaudePermissionUpdates.ts +44 -0
- package/src/providers/claude/settings.ts +138 -0
- package/src/providers/claude/storage/AgentVaultStorage.ts +101 -0
- package/src/providers/claude/storage/CCSettingsStorage.ts +153 -0
- package/src/providers/claude/storage/ClaudianSettingsStorage.ts +6 -0
- package/src/providers/claude/storage/McpStorage.ts +139 -0
- package/src/providers/claude/storage/SessionStorage.ts +5 -0
- package/src/providers/claude/storage/SkillStorage.ts +61 -0
- package/src/providers/claude/storage/SlashCommandStorage.ts +96 -0
- package/src/providers/claude/storage/StorageService.ts +185 -0
- package/src/providers/claude/stream/toolInputStreamState.ts +318 -0
- package/src/providers/claude/stream/transformClaudeMessage.ts +586 -0
- package/src/providers/claude/types/agent.ts +2 -0
- package/src/providers/claude/types/models.ts +168 -0
- package/src/providers/claude/types/plugins.ts +14 -0
- package/src/providers/claude/types/providerState.ts +16 -0
- package/src/providers/claude/types/settings.ts +98 -0
- package/src/providers/claude/ui/AgentSettings.ts +389 -0
- package/src/providers/claude/ui/ClaudeChatUIConfig.ts +113 -0
- package/src/providers/claude/ui/ClaudeSettingsTab.ts +407 -0
- package/src/providers/claude/ui/PluginSettingsManager.ts +149 -0
- package/src/providers/claude/ui/SlashCommandSettings.ts +527 -0
- package/src/providers/codex/CLAUDE.md +64 -0
- package/src/providers/codex/agents/CodexAgentMentionProvider.ts +33 -0
- package/src/providers/codex/app/CodexWorkspaceServices.ts +76 -0
- package/src/providers/codex/auxiliary/CodexInlineEditService.ts +9 -0
- package/src/providers/codex/auxiliary/CodexInstructionRefineService.ts +9 -0
- package/src/providers/codex/auxiliary/CodexTaskResultInterpreter.ts +29 -0
- package/src/providers/codex/auxiliary/CodexTitleGenerationService.ts +22 -0
- package/src/providers/codex/capabilities.ts +16 -0
- package/src/providers/codex/commands/CodexSkillCatalog.ts +180 -0
- package/src/providers/codex/env/CodexSettingsReconciler.ts +68 -0
- package/src/providers/codex/history/CodexConversationHistoryService.ts +212 -0
- package/src/providers/codex/history/CodexHistoryStore.ts +1672 -0
- package/src/providers/codex/modelOptions.ts +99 -0
- package/src/providers/codex/modelSelection.ts +17 -0
- package/src/providers/codex/normalization/codexSubagentNormalization.ts +227 -0
- package/src/providers/codex/normalization/codexToolNormalization.ts +390 -0
- package/src/providers/codex/prompt/encodeCodexTurn.ts +57 -0
- package/src/providers/codex/registration.ts +29 -0
- package/src/providers/codex/runtime/CodexAppServerProcess.ts +105 -0
- package/src/providers/codex/runtime/CodexAuxQueryRunner.ts +180 -0
- package/src/providers/codex/runtime/CodexBinaryLocator.ts +49 -0
- package/src/providers/codex/runtime/CodexChatRuntime.ts +1296 -0
- package/src/providers/codex/runtime/CodexCliResolver.ts +65 -0
- package/src/providers/codex/runtime/CodexExecutionTargetResolver.ts +104 -0
- package/src/providers/codex/runtime/CodexLaunchSpecBuilder.ts +85 -0
- package/src/providers/codex/runtime/CodexNotificationRouter.ts +1033 -0
- package/src/providers/codex/runtime/CodexPathMapper.ts +155 -0
- package/src/providers/codex/runtime/CodexRpcTransport.ts +171 -0
- package/src/providers/codex/runtime/CodexRuntimeContext.ts +109 -0
- package/src/providers/codex/runtime/CodexServerRequestRouter.ts +331 -0
- package/src/providers/codex/runtime/CodexSessionFileTail.ts +792 -0
- package/src/providers/codex/runtime/CodexSessionManager.ts +39 -0
- package/src/providers/codex/runtime/codexAppServerSupport.ts +58 -0
- package/src/providers/codex/runtime/codexAppServerTypes.ts +705 -0
- package/src/providers/codex/runtime/codexLaunchTypes.ts +30 -0
- package/src/providers/codex/settings.ts +236 -0
- package/src/providers/codex/skills/CodexSkillListingService.ts +173 -0
- package/src/providers/codex/storage/CodexSkillStorage.ts +250 -0
- package/src/providers/codex/storage/CodexSubagentStorage.ts +212 -0
- package/src/providers/codex/types/index.ts +16 -0
- package/src/providers/codex/types/models.ts +46 -0
- package/src/providers/codex/types/subagent.ts +23 -0
- package/src/providers/codex/ui/CodexChatUIConfig.ts +128 -0
- package/src/providers/codex/ui/CodexSettingsTab.ts +432 -0
- package/src/providers/codex/ui/CodexSkillSettings.ts +275 -0
- package/src/providers/codex/ui/CodexSubagentSettings.ts +400 -0
- package/src/providers/defaultProviderConfigs.ts +14 -0
- package/src/providers/index.ts +30 -0
- package/src/providers/opencode/agents/OpencodeAgentMentionProvider.ts +42 -0
- package/src/providers/opencode/app/OpencodeRuntimeCommandLoader.ts +67 -0
- package/src/providers/opencode/app/OpencodeWorkspaceServices.ts +55 -0
- package/src/providers/opencode/auxiliary/OpencodeInlineEditService.ts +13 -0
- package/src/providers/opencode/auxiliary/OpencodeInstructionRefineService.ts +12 -0
- package/src/providers/opencode/auxiliary/OpencodeTaskResultInterpreter.ts +29 -0
- package/src/providers/opencode/auxiliary/OpencodeTitleGenerationService.ts +27 -0
- package/src/providers/opencode/capabilities.ts +16 -0
- package/src/providers/opencode/commands/OpencodeCommandCatalog.ts +92 -0
- package/src/providers/opencode/discoveryState.ts +135 -0
- package/src/providers/opencode/env/OpencodeSettingsReconciler.ts +178 -0
- package/src/providers/opencode/history/OpencodeConversationHistoryService.ts +84 -0
- package/src/providers/opencode/history/OpencodeHistoryStore.ts +472 -0
- package/src/providers/opencode/history/OpencodeSqliteReader.ts +284 -0
- package/src/providers/opencode/internal/compareCollections.ts +72 -0
- package/src/providers/opencode/internal/providerProjection.ts +15 -0
- package/src/providers/opencode/models.ts +378 -0
- package/src/providers/opencode/modes.ts +150 -0
- package/src/providers/opencode/normalization/opencodeToolNormalization.ts +406 -0
- package/src/providers/opencode/registration.ts +27 -0
- package/src/providers/opencode/runtime/OpencodeAuxQueryRunner.ts +436 -0
- package/src/providers/opencode/runtime/OpencodeChatRuntime.ts +1603 -0
- package/src/providers/opencode/runtime/OpencodeCliResolver.ts +57 -0
- package/src/providers/opencode/runtime/OpencodeLaunchArtifacts.ts +231 -0
- package/src/providers/opencode/runtime/OpencodePaths.ts +113 -0
- package/src/providers/opencode/runtime/OpencodeRuntimeEnvironment.ts +18 -0
- package/src/providers/opencode/runtime/buildOpencodePrompt.ts +66 -0
- package/src/providers/opencode/settings.ts +427 -0
- package/src/providers/opencode/storage/OpencodeAgentStorage.ts +346 -0
- package/src/providers/opencode/types/agent.ts +37 -0
- package/src/providers/opencode/types/index.ts +9 -0
- package/src/providers/opencode/ui/OpencodeAgentSettings.ts +579 -0
- package/src/providers/opencode/ui/OpencodeChatUIConfig.ts +316 -0
- package/src/providers/opencode/ui/OpencodeSettingsTab.ts +674 -0
- package/src/providers/pi/app/PiRuntimeCommandLoader.ts +57 -0
- package/src/providers/pi/app/PiWorkspaceServices.ts +39 -0
- package/src/providers/pi/auxiliary/PiInlineEditService.ts +9 -0
- package/src/providers/pi/auxiliary/PiInstructionRefineService.ts +9 -0
- package/src/providers/pi/auxiliary/PiTaskResultInterpreter.ts +29 -0
- package/src/providers/pi/auxiliary/PiTitleGenerationService.ts +19 -0
- package/src/providers/pi/capabilities.ts +16 -0
- package/src/providers/pi/commands/PiCommandCatalog.ts +92 -0
- package/src/providers/pi/env/PiSettingsReconciler.ts +180 -0
- package/src/providers/pi/history/PiConversationHistoryService.ts +123 -0
- package/src/providers/pi/history/PiHistoryStore.ts +664 -0
- package/src/providers/pi/internal/compareCollections.ts +4 -0
- package/src/providers/pi/internal/providerProjection.ts +18 -0
- package/src/providers/pi/models.ts +302 -0
- package/src/providers/pi/normalizations/piEventNormalization.ts +211 -0
- package/src/providers/pi/normalizations/piToolNormalization.ts +97 -0
- package/src/providers/pi/registration.ts +30 -0
- package/src/providers/pi/runtime/PiAuxQueryRunner.ts +216 -0
- package/src/providers/pi/runtime/PiChatRuntime.ts +1064 -0
- package/src/providers/pi/runtime/PiCliResolver.ts +53 -0
- package/src/providers/pi/runtime/PiExtensionUiBridge.ts +161 -0
- package/src/providers/pi/runtime/PiJsonl.ts +71 -0
- package/src/providers/pi/runtime/PiLaunchSpec.ts +70 -0
- package/src/providers/pi/runtime/PiModelDiscoveryService.ts +92 -0
- package/src/providers/pi/runtime/PiRpcPayloads.ts +18 -0
- package/src/providers/pi/runtime/PiRpcTransport.ts +243 -0
- package/src/providers/pi/runtime/PiSubprocess.ts +159 -0
- package/src/providers/pi/runtime/buildPiPrompt.ts +62 -0
- package/src/providers/pi/runtime/buildPiUsageInfo.ts +69 -0
- package/src/providers/pi/settings.ts +468 -0
- package/src/providers/pi/types.ts +64 -0
- package/src/providers/pi/ui/ObsidianPiExtensionUiRenderer.ts +251 -0
- package/src/providers/pi/ui/PiChatUIConfig.ts +265 -0
- package/src/providers/pi/ui/PiExtensionUiRenderer.ts +12 -0
- package/src/providers/pi/ui/PiSettingsTab.ts +642 -0
- package/src/shared/components/ResumeSessionDropdown.ts +185 -0
- package/src/shared/components/SelectableDropdown.ts +140 -0
- package/src/shared/components/SelectionHighlight.ts +77 -0
- package/src/shared/components/SlashCommandDropdown.ts +421 -0
- package/src/shared/icons.ts +180 -0
- package/src/shared/mention/MentionDropdownController.ts +627 -0
- package/src/shared/mention/VaultMentionCache.ts +106 -0
- package/src/shared/mention/VaultMentionDataProvider.ts +51 -0
- package/src/shared/mention/types.ts +67 -0
- package/src/shared/modals/ConfirmModal.ts +60 -0
- package/src/shared/modals/ForkTargetModal.ts +47 -0
- package/src/shared/modals/InstructionConfirmModal.ts +281 -0
- package/src/style/CLAUDE.md +49 -0
- package/src/style/accessibility.css +40 -0
- package/src/style/base/animations.css +44 -0
- package/src/style/base/container.css +20 -0
- package/src/style/base/variables.css +46 -0
- package/src/style/base/visibility.css +15 -0
- package/src/style/components/code.css +97 -0
- package/src/style/components/context-footer.css +76 -0
- package/src/style/components/header.css +27 -0
- package/src/style/components/history.css +221 -0
- package/src/style/components/input.css +312 -0
- package/src/style/components/messages.css +262 -0
- package/src/style/components/nav-sidebar.css +58 -0
- package/src/style/components/status-panel.css +202 -0
- package/src/style/components/subagent.css +248 -0
- package/src/style/components/tabs.css +112 -0
- package/src/style/components/thinking.css +88 -0
- package/src/style/components/toolcalls.css +278 -0
- package/src/style/features/ask-user-question.css +315 -0
- package/src/style/features/diff.css +197 -0
- package/src/style/features/file-context.css +188 -0
- package/src/style/features/file-link.css +22 -0
- package/src/style/features/image-context.css +179 -0
- package/src/style/features/image-embed.css +40 -0
- package/src/style/features/image-modal.css +52 -0
- package/src/style/features/inline-edit.css +278 -0
- package/src/style/features/plan-mode.css +103 -0
- package/src/style/features/resume-session.css +119 -0
- package/src/style/features/slash-commands.css +91 -0
- package/src/style/index.css +63 -0
- package/src/style/modals/fork-target.css +21 -0
- package/src/style/modals/instruction.css +161 -0
- package/src/style/modals/mcp-modal.css +241 -0
- package/src/style/settings/agent-settings.css +2 -0
- package/src/style/settings/base.css +300 -0
- package/src/style/settings/env-snippets.css +366 -0
- package/src/style/settings/mcp-settings.css +211 -0
- package/src/style/settings/plugin-settings.css +164 -0
- package/src/style/settings/provider-model-picker.css +367 -0
- package/src/style/settings/slash-settings.css +16 -0
- package/src/style/toolbar/external-context.css +177 -0
- package/src/style/toolbar/mcp-selector.css +176 -0
- package/src/style/toolbar/mode-selector.css +19 -0
- package/src/style/toolbar/model-selector.css +99 -0
- package/src/style/toolbar/permission-toggle.css +56 -0
- package/src/style/toolbar/service-tier-toggle.css +39 -0
- package/src/style/toolbar/thinking-selector.css +83 -0
- package/src/types/smol-toml.d.ts +4 -0
- package/src/utils/agent.ts +50 -0
- package/src/utils/animationFrame.ts +46 -0
- package/src/utils/browser.ts +46 -0
- package/src/utils/canvas.ts +14 -0
- package/src/utils/cliBinaryLocator.ts +97 -0
- package/src/utils/context.ts +117 -0
- package/src/utils/contextMentionResolver.ts +154 -0
- package/src/utils/date.ts +31 -0
- package/src/utils/diff.ts +384 -0
- package/src/utils/editor.ts +104 -0
- package/src/utils/electronCompat.ts +53 -0
- package/src/utils/env.ts +465 -0
- package/src/utils/externalContext.ts +143 -0
- package/src/utils/externalContextScanner.ts +135 -0
- package/src/utils/fileLink.ts +263 -0
- package/src/utils/frontmatter.ts +194 -0
- package/src/utils/imageEmbed.ts +139 -0
- package/src/utils/inlineEdit.ts +22 -0
- package/src/utils/interrupt.ts +23 -0
- package/src/utils/markdown.ts +25 -0
- package/src/utils/markdownMath.ts +130 -0
- package/src/utils/mcp.ts +96 -0
- package/src/utils/obsidianCompat.ts +23 -0
- package/src/utils/path.ts +342 -0
- package/src/utils/session.ts +240 -0
- package/src/utils/slashCommand.ts +152 -0
- package/src/utils/subagentJsonl.ts +52 -0
- package/src/utils/windowsCmdShim.ts +98 -0
- package/tests/__mocks__/claude-agent-sdk.ts +317 -0
- package/tests/__mocks__/codex-sdk.ts +88 -0
- package/tests/__mocks__/obsidian.ts +434 -0
- package/tests/helpers/mockElement.ts +403 -0
- package/tests/helpers/sdkMessages.ts +291 -0
- package/tests/integration/core/agent/ClaudianService.test.ts +1845 -0
- package/tests/integration/core/mcp/mcp.test.ts +905 -0
- package/tests/integration/features/chat/imagePersistence.test.ts +38 -0
- package/tests/integration/main.test.ts +1701 -0
- package/tests/setupWindow.ts +26 -0
- package/tests/tsconfig.json +7 -0
- package/tests/unit/core/commands/builtInCommands.test.ts +239 -0
- package/tests/unit/core/mcp/McpServerManager.test.ts +405 -0
- package/tests/unit/core/mcp/McpTester.test.ts +282 -0
- package/tests/unit/core/mcp/createNodeFetch.test.ts +188 -0
- package/tests/unit/core/providers/ProviderRegistry.test.ts +275 -0
- package/tests/unit/core/providers/ProviderSettingsCoordinator.test.ts +490 -0
- package/tests/unit/core/providers/ProviderWorkspaceRegistry.test.ts +84 -0
- package/tests/unit/core/providers/modelRouting.test.ts +91 -0
- package/tests/unit/core/providers/modelSelection.test.ts +155 -0
- package/tests/unit/core/providers/providerEnvironment.test.ts +162 -0
- package/tests/unit/core/providers/tabLifecycle.test.ts +217 -0
- package/tests/unit/core/security/ApprovalManager.test.ts +152 -0
- package/tests/unit/core/storage/VaultFileAdapter.test.ts +535 -0
- package/tests/unit/core/tools/todo.test.ts +227 -0
- package/tests/unit/core/tools/toolIcons.test.ts +75 -0
- package/tests/unit/core/tools/toolInput.test.ts +350 -0
- package/tests/unit/core/tools/toolNames.test.ts +464 -0
- package/tests/unit/core/types/mcp.test.ts +115 -0
- package/tests/unit/features/chat/ClaudianView.test.ts +404 -0
- package/tests/unit/features/chat/controllers/BrowserSelectionController.test.ts +179 -0
- package/tests/unit/features/chat/controllers/CanvasSelectionController.test.ts +216 -0
- package/tests/unit/features/chat/controllers/ConversationController.test.ts +2764 -0
- package/tests/unit/features/chat/controllers/InputController.test.ts +3188 -0
- package/tests/unit/features/chat/controllers/NavigationController.test.ts +640 -0
- package/tests/unit/features/chat/controllers/SelectionController.test.ts +695 -0
- package/tests/unit/features/chat/controllers/StreamController.test.ts +2534 -0
- package/tests/unit/features/chat/controllers/contextRowVisibility.test.ts +46 -0
- package/tests/unit/features/chat/controllers/index.test.ts +16 -0
- package/tests/unit/features/chat/rendering/DiffRenderer.test.ts +355 -0
- package/tests/unit/features/chat/rendering/InlineAskUserQuestion.test.ts +1035 -0
- package/tests/unit/features/chat/rendering/InlineExitPlanMode.test.ts +191 -0
- package/tests/unit/features/chat/rendering/InlinePlanApproval.test.ts +126 -0
- package/tests/unit/features/chat/rendering/MessageRenderer.test.ts +2004 -0
- package/tests/unit/features/chat/rendering/SubagentRenderer.test.ts +917 -0
- package/tests/unit/features/chat/rendering/ThinkingBlockRenderer.test.ts +124 -0
- package/tests/unit/features/chat/rendering/TodoListRenderer.test.ts +173 -0
- package/tests/unit/features/chat/rendering/ToolCallRenderer.test.ts +909 -0
- package/tests/unit/features/chat/rendering/WriteEditRenderer.test.ts +474 -0
- package/tests/unit/features/chat/rendering/collapsible.test.ts +158 -0
- package/tests/unit/features/chat/rendering/todoUtils.test.ts +105 -0
- package/tests/unit/features/chat/rewind.test.ts +56 -0
- package/tests/unit/features/chat/services/BangBashService.test.ts +142 -0
- package/tests/unit/features/chat/services/InstructionRefineService.test.ts +371 -0
- package/tests/unit/features/chat/services/SubagentManager.test.ts +1759 -0
- package/tests/unit/features/chat/services/TitleGenerationService.test.ts +480 -0
- package/tests/unit/features/chat/state/ChatState.test.ts +581 -0
- package/tests/unit/features/chat/tabs/Tab.test.ts +4287 -0
- package/tests/unit/features/chat/tabs/TabBar.test.ts +357 -0
- package/tests/unit/features/chat/tabs/TabManager.test.ts +2962 -0
- package/tests/unit/features/chat/tabs/index.test.ts +11 -0
- package/tests/unit/features/chat/ui/BangBashModeManager.test.ts +321 -0
- package/tests/unit/features/chat/ui/ExternalContextSelector.test.ts +555 -0
- package/tests/unit/features/chat/ui/FileContextManager.test.ts +876 -0
- package/tests/unit/features/chat/ui/ImageContext.test.ts +777 -0
- package/tests/unit/features/chat/ui/InputToolbar.test.ts +1139 -0
- package/tests/unit/features/chat/ui/InstructionModeManager.test.ts +243 -0
- package/tests/unit/features/chat/ui/NavigationSidebar.test.ts +570 -0
- package/tests/unit/features/chat/ui/StatusPanel.test.ts +953 -0
- package/tests/unit/features/chat/ui/file-context/state/FileContextState.test.ts +155 -0
- package/tests/unit/features/chat/ui/textareaResize.test.ts +102 -0
- package/tests/unit/features/chat/utils/usageInfo.test.ts +56 -0
- package/tests/unit/features/inline-edit/InlineEditService.test.ts +1199 -0
- package/tests/unit/features/inline-edit/ui/InlineEditModal.openAndWait.test.ts +1482 -0
- package/tests/unit/features/inline-edit/ui/InlineEditModal.test.ts +495 -0
- package/tests/unit/features/inline-edit/ui/inlineEditMarkdownPreview.test.ts +92 -0
- package/tests/unit/features/settings/AgentSettings.test.ts +82 -0
- package/tests/unit/features/settings/keyboardNavigation.test.ts +73 -0
- package/tests/unit/features/settings/ui/CodexSkillSettings.test.ts +294 -0
- package/tests/unit/features/settings/ui/CodexSubagentSettings.test.ts +207 -0
- package/tests/unit/i18n/constants.test.ts +43 -0
- package/tests/unit/i18n/i18n.test.ts +244 -0
- package/tests/unit/i18n/locales.test.ts +134 -0
- package/tests/unit/providers/acp/AcpClientConnection.test.ts +248 -0
- package/tests/unit/providers/acp/AcpJsonRpcTransport.test.ts +186 -0
- package/tests/unit/providers/acp/AcpSessionConfig.test.ts +247 -0
- package/tests/unit/providers/acp/AcpSessionUpdateNormalizer.test.ts +145 -0
- package/tests/unit/providers/acp/AcpSubprocess.test.ts +105 -0
- package/tests/unit/providers/acp/buildAcpUsageInfo.test.ts +51 -0
- package/tests/unit/providers/claude/agents/AgentManager.test.ts +590 -0
- package/tests/unit/providers/claude/agents/AgentStorage.test.ts +434 -0
- package/tests/unit/providers/claude/agents/index.test.ts +10 -0
- package/tests/unit/providers/claude/commands/ClaudeCommandCatalog.test.ts +396 -0
- package/tests/unit/providers/claude/commands/probeRuntimeCommands.test.ts +92 -0
- package/tests/unit/providers/claude/env/ClaudeSettingsReconciler.test.ts +57 -0
- package/tests/unit/providers/claude/env/claudeModelEnv.test.ts +228 -0
- package/tests/unit/providers/claude/hooks/SubagentHooks.test.ts +83 -0
- package/tests/unit/providers/claude/plugins/PluginManager.test.ts +832 -0
- package/tests/unit/providers/claude/plugins/index.test.ts +7 -0
- package/tests/unit/providers/claude/prompt/ClaudeTurnEncoder.test.ts +145 -0
- package/tests/unit/providers/claude/prompt/instructionRefine.test.ts +185 -0
- package/tests/unit/providers/claude/prompt/systemPrompt.test.ts +163 -0
- package/tests/unit/providers/claude/prompt/titleGeneration.test.ts +20 -0
- package/tests/unit/providers/claude/runtime/ClaudeTaskResultInterpreter.test.ts +28 -0
- package/tests/unit/providers/claude/runtime/ClaudianService.test.ts +3796 -0
- package/tests/unit/providers/claude/runtime/MessageChannel.test.ts +421 -0
- package/tests/unit/providers/claude/runtime/QueryOptionsBuilder.test.ts +775 -0
- package/tests/unit/providers/claude/runtime/SessionManager.test.ts +182 -0
- package/tests/unit/providers/claude/runtime/claudeColdStartQuery.test.ts +331 -0
- package/tests/unit/providers/claude/runtime/customSpawn.test.ts +374 -0
- package/tests/unit/providers/claude/runtime/index.test.ts +13 -0
- package/tests/unit/providers/claude/runtime/types.test.ts +190 -0
- package/tests/unit/providers/claude/sdk/typeGuards.test.ts +50 -0
- package/tests/unit/providers/claude/security/ClaudePermissionUpdates.test.ts +198 -0
- package/tests/unit/providers/claude/storage/AgentVaultStorage.test.ts +413 -0
- package/tests/unit/providers/claude/storage/CCSettingsStorage.test.ts +408 -0
- package/tests/unit/providers/claude/storage/ClaudianSettingsStorage.test.ts +653 -0
- package/tests/unit/providers/claude/storage/McpStorage.test.ts +619 -0
- package/tests/unit/providers/claude/storage/SessionStorage.test.ts +680 -0
- package/tests/unit/providers/claude/storage/SkillStorage.test.ts +275 -0
- package/tests/unit/providers/claude/storage/SlashCommandStorage.test.ts +612 -0
- package/tests/unit/providers/claude/storage/storage.test.ts +360 -0
- package/tests/unit/providers/claude/storage/storageService.convenience.test.ts +447 -0
- package/tests/unit/providers/claude/stream/transformSDKMessage.test.ts +1729 -0
- package/tests/unit/providers/claude/types/types.test.ts +726 -0
- package/tests/unit/providers/claude/ui/ClaudeChatUIConfig.test.ts +173 -0
- package/tests/unit/providers/claude/ui/ClaudeSettingsTab.test.ts +466 -0
- package/tests/unit/providers/codex/agents/CodexAgentMentionProvider.test.ts +89 -0
- package/tests/unit/providers/codex/auxiliary/CodexInstructionRefineService.test.ts +81 -0
- package/tests/unit/providers/codex/capabilities.test.ts +39 -0
- package/tests/unit/providers/codex/commands/CodexSkillCatalog.test.ts +413 -0
- package/tests/unit/providers/codex/env/CodexSettingsReconciler.test.ts +106 -0
- package/tests/unit/providers/codex/fixtures/codex-session-abort.jsonl +9 -0
- package/tests/unit/providers/codex/fixtures/codex-session-agent-lifecycle.jsonl +12 -0
- package/tests/unit/providers/codex/fixtures/codex-session-persisted-tools.jsonl +15 -0
- package/tests/unit/providers/codex/fixtures/codex-session-simple.jsonl +10 -0
- package/tests/unit/providers/codex/fixtures/codex-session-tools.jsonl +12 -0
- package/tests/unit/providers/codex/fixtures/codex-session-websearch-persisted.jsonl +3 -0
- package/tests/unit/providers/codex/fixtures/codex-session-websearch.jsonl +7 -0
- package/tests/unit/providers/codex/history/CodexConversationHistoryService.test.ts +690 -0
- package/tests/unit/providers/codex/history/CodexHistoryStore.test.ts +2202 -0
- package/tests/unit/providers/codex/normalization/codexSubagentNormalization.test.ts +81 -0
- package/tests/unit/providers/codex/normalization/codexToolNormalization.test.ts +322 -0
- package/tests/unit/providers/codex/prompt/encodeCodexTurn.test.ts +168 -0
- package/tests/unit/providers/codex/runtime/CodexAppServerProcess.test.ts +255 -0
- package/tests/unit/providers/codex/runtime/CodexAuxQueryRunner.test.ts +130 -0
- package/tests/unit/providers/codex/runtime/CodexBinaryLocator.test.ts +90 -0
- package/tests/unit/providers/codex/runtime/CodexChatRuntime.test.ts +2445 -0
- package/tests/unit/providers/codex/runtime/CodexCliResolver.test.ts +103 -0
- package/tests/unit/providers/codex/runtime/CodexExecutionTargetResolver.test.ts +105 -0
- package/tests/unit/providers/codex/runtime/CodexLaunchSpecBuilder.test.ts +150 -0
- package/tests/unit/providers/codex/runtime/CodexNotificationRouter.test.ts +1248 -0
- package/tests/unit/providers/codex/runtime/CodexPathMapper.test.ts +51 -0
- package/tests/unit/providers/codex/runtime/CodexRpcTransport.test.ts +220 -0
- package/tests/unit/providers/codex/runtime/CodexRuntimeContext.test.ts +107 -0
- package/tests/unit/providers/codex/runtime/CodexServerRequestRouter.test.ts +537 -0
- package/tests/unit/providers/codex/runtime/CodexSessionFileTail.test.ts +1305 -0
- package/tests/unit/providers/codex/runtime/CodexSessionManager.test.ts +82 -0
- package/tests/unit/providers/codex/runtime/codexAppServerTypes.test.ts +336 -0
- package/tests/unit/providers/codex/settings.test.ts +189 -0
- package/tests/unit/providers/codex/skills/CodexSkillListingService.test.ts +178 -0
- package/tests/unit/providers/codex/storage/CodexSkillStorage.test.ts +342 -0
- package/tests/unit/providers/codex/storage/CodexSubagentStorage.test.ts +376 -0
- package/tests/unit/providers/codex/ui/CodexChatUIConfig.test.ts +211 -0
- package/tests/unit/providers/codex/ui/CodexSettingsTab.test.ts +578 -0
- package/tests/unit/providers/defaultProviderConfigs.test.ts +18 -0
- package/tests/unit/providers/opencode/OpencodeAuxQueryRunner.test.ts +355 -0
- package/tests/unit/providers/opencode/OpencodeChatRuntime.test.ts +808 -0
- package/tests/unit/providers/opencode/OpencodeCliResolver.test.ts +93 -0
- package/tests/unit/providers/opencode/OpencodeCommandCatalog.test.ts +75 -0
- package/tests/unit/providers/opencode/OpencodeConversationHistoryService.test.ts +103 -0
- package/tests/unit/providers/opencode/OpencodeHistoryStore.test.ts +418 -0
- package/tests/unit/providers/opencode/OpencodeLaunchArtifacts.test.ts +268 -0
- package/tests/unit/providers/opencode/OpencodePaths.test.ts +35 -0
- package/tests/unit/providers/opencode/OpencodeRuntimeCommandLoader.test.ts +129 -0
- package/tests/unit/providers/opencode/OpencodeSettingsReconciler.test.ts +96 -0
- package/tests/unit/providers/opencode/OpencodeSettingsTab.test.ts +649 -0
- package/tests/unit/providers/opencode/OpencodeSqliteReader.test.ts +185 -0
- package/tests/unit/providers/opencode/agents/OpencodeAgentMentionProvider.test.ts +56 -0
- package/tests/unit/providers/opencode/buildOpencodePrompt.test.ts +87 -0
- package/tests/unit/providers/opencode/capabilities.test.ts +39 -0
- package/tests/unit/providers/opencode/models.test.ts +273 -0
- package/tests/unit/providers/opencode/modes.test.ts +151 -0
- package/tests/unit/providers/opencode/opencodeToolNormalization.test.ts +197 -0
- package/tests/unit/providers/opencode/settings.test.ts +469 -0
- package/tests/unit/providers/opencode/storage/OpencodeAgentStorage.test.ts +377 -0
- package/tests/unit/providers/opencode/ui/OpencodeAgentSettings.test.ts +91 -0
- package/tests/unit/providers/pi/PiRuntimeCommandLoader.test.ts +149 -0
- package/tests/unit/providers/pi/capabilities.test.ts +20 -0
- package/tests/unit/providers/pi/commands/PiCommandCatalog.test.ts +69 -0
- package/tests/unit/providers/pi/env/PiSettingsReconciler.test.ts +61 -0
- package/tests/unit/providers/pi/history/PiConversationHistoryService.test.ts +147 -0
- package/tests/unit/providers/pi/history/PiHistoryStore.test.ts +523 -0
- package/tests/unit/providers/pi/models.test.ts +96 -0
- package/tests/unit/providers/pi/registration.test.ts +54 -0
- package/tests/unit/providers/pi/runtime/PiAuxQueryRunner.test.ts +172 -0
- package/tests/unit/providers/pi/runtime/PiChatRuntime.test.ts +830 -0
- package/tests/unit/providers/pi/runtime/PiCliResolver.test.ts +105 -0
- package/tests/unit/providers/pi/runtime/PiEventNormalization.test.ts +147 -0
- package/tests/unit/providers/pi/runtime/PiExtensionUiBridge.test.ts +98 -0
- package/tests/unit/providers/pi/runtime/PiJsonl.test.ts +42 -0
- package/tests/unit/providers/pi/runtime/PiLaunchSpec.test.ts +92 -0
- package/tests/unit/providers/pi/runtime/PiModelDiscoveryService.test.ts +135 -0
- package/tests/unit/providers/pi/runtime/PiRpcPayloads.test.ts +15 -0
- package/tests/unit/providers/pi/runtime/PiRpcTransport.test.ts +116 -0
- package/tests/unit/providers/pi/runtime/PiSubprocess.test.ts +151 -0
- package/tests/unit/providers/pi/runtime/buildPiPrompt.test.ts +84 -0
- package/tests/unit/providers/pi/runtime/buildPiUsageInfo.test.ts +69 -0
- package/tests/unit/providers/pi/settings.test.ts +253 -0
- package/tests/unit/providers/pi/ui/PiChatUIConfig.test.ts +161 -0
- package/tests/unit/providers/pi/ui/PiSettingsTab.test.ts +492 -0
- package/tests/unit/scripts/rendererSafeUnref.test.ts +101 -0
- package/tests/unit/shared/components/ResumeSessionDropdown.test.ts +356 -0
- package/tests/unit/shared/components/SelectableDropdown.test.ts +406 -0
- package/tests/unit/shared/components/SlashCommandDropdown.provider.test.ts +354 -0
- package/tests/unit/shared/components/SlashCommandDropdown.test.ts +508 -0
- package/tests/unit/shared/icons.test.ts +56 -0
- package/tests/unit/shared/index.test.ts +46 -0
- package/tests/unit/shared/mention/MentionDropdownController.test.ts +823 -0
- package/tests/unit/shared/mention/VaultFileCache.test.ts +195 -0
- package/tests/unit/shared/mention/VaultFolderCache.test.ts +143 -0
- package/tests/unit/shared/mention/VaultMentionDataProvider.test.ts +87 -0
- package/tests/unit/shared/modals/ConfirmModal.test.ts +111 -0
- package/tests/unit/shared/modals/ForkTargetModal.test.ts +101 -0
- package/tests/unit/shared/modals/InstructionConfirmModal.test.ts +305 -0
- package/tests/unit/utils/agent.test.ts +395 -0
- package/tests/unit/utils/animationFrame.test.ts +59 -0
- package/tests/unit/utils/browser.test.ts +73 -0
- package/tests/unit/utils/canvas.test.ts +54 -0
- package/tests/unit/utils/claudeCli.test.ts +294 -0
- package/tests/unit/utils/cliBinaryLocator.test.ts +33 -0
- package/tests/unit/utils/context.test.ts +288 -0
- package/tests/unit/utils/contextMentionResolver.test.ts +270 -0
- package/tests/unit/utils/date.test.ts +80 -0
- package/tests/unit/utils/diff.test.ts +291 -0
- package/tests/unit/utils/editor.test.ts +249 -0
- package/tests/unit/utils/electronCompat.test.ts +87 -0
- package/tests/unit/utils/env.test.ts +1240 -0
- package/tests/unit/utils/externalContext.test.ts +336 -0
- package/tests/unit/utils/externalContextScanner.test.ts +186 -0
- package/tests/unit/utils/fileLink.dom.test.ts +273 -0
- package/tests/unit/utils/fileLink.handler.test.ts +64 -0
- package/tests/unit/utils/fileLink.test.ts +233 -0
- package/tests/unit/utils/frontmatter.test.ts +434 -0
- package/tests/unit/utils/imageEmbed.test.ts +407 -0
- package/tests/unit/utils/inlineEdit.test.ts +63 -0
- package/tests/unit/utils/interrupt.test.ts +73 -0
- package/tests/unit/utils/markdown.test.ts +35 -0
- package/tests/unit/utils/markdownMath.test.ts +54 -0
- package/tests/unit/utils/mcp.test.ts +256 -0
- package/tests/unit/utils/obsidianCompat.test.ts +18 -0
- package/tests/unit/utils/path.test.ts +677 -0
- package/tests/unit/utils/sdkSession.test.ts +2359 -0
- package/tests/unit/utils/session.test.ts +971 -0
- package/tests/unit/utils/slashCommand.test.ts +778 -0
- package/tests/unit/utils/utils.test.ts +809 -0
- package/tsconfig.jest.json +8 -0
- package/tsconfig.json +26 -0
- package/versions.json +4 -0
|
@@ -0,0 +1,2962 @@
|
|
|
1
|
+
import { createMockEl } from '@test/helpers/mockElement';
|
|
2
|
+
|
|
3
|
+
import { ProviderWorkspaceRegistry } from '@/core/providers/ProviderWorkspaceRegistry';
|
|
4
|
+
import { TabManager } from '@/features/chat/tabs/TabManager';
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_MAX_TABS,
|
|
7
|
+
type PersistedTabManagerState,
|
|
8
|
+
type TabManagerCallbacks,
|
|
9
|
+
} from '@/features/chat/tabs/types';
|
|
10
|
+
import { DEFAULT_CODEX_PRIMARY_MODEL } from '@/providers/codex/types/models';
|
|
11
|
+
|
|
12
|
+
// Mock Tab module functions
|
|
13
|
+
const mockCreateTab = jest.fn();
|
|
14
|
+
const mockDestroyTab = jest.fn().mockResolvedValue(undefined);
|
|
15
|
+
const mockActivateTab = jest.fn();
|
|
16
|
+
const mockDeactivateTab = jest.fn();
|
|
17
|
+
const mockInitializeTabUI = jest.fn();
|
|
18
|
+
const mockInitializeTabControllers = jest.fn();
|
|
19
|
+
const mockInitializeTabService = jest.fn().mockResolvedValue(undefined);
|
|
20
|
+
const mockSetupServiceCallbacks = jest.fn();
|
|
21
|
+
const mockWireTabInputEvents = jest.fn();
|
|
22
|
+
const mockGetTabTitle = jest.fn().mockReturnValue('Test Tab');
|
|
23
|
+
const mockCreateChatRuntime = jest.fn();
|
|
24
|
+
const mockGetProviderSettingsSnapshot = jest.fn().mockImplementation(() => ({}));
|
|
25
|
+
const commandWarmupPolicy = { resolveMode: jest.fn().mockReturnValue('commands') };
|
|
26
|
+
|
|
27
|
+
jest.mock('@/features/chat/tabs/Tab', () => ({
|
|
28
|
+
createTab: (...args: any[]) => mockCreateTab(...args),
|
|
29
|
+
destroyTab: (...args: any[]) => mockDestroyTab(...args),
|
|
30
|
+
activateTab: (...args: any[]) => mockActivateTab(...args),
|
|
31
|
+
deactivateTab: (...args: any[]) => mockDeactivateTab(...args),
|
|
32
|
+
initializeTabUI: (...args: any[]) => mockInitializeTabUI(...args),
|
|
33
|
+
initializeTabControllers: (...args: any[]) => mockInitializeTabControllers(...args),
|
|
34
|
+
initializeTabService: (...args: any[]) => mockInitializeTabService(...args),
|
|
35
|
+
setupServiceCallbacks: (...args: any[]) => mockSetupServiceCallbacks(...args),
|
|
36
|
+
wireTabInputEvents: (...args: any[]) => mockWireTabInputEvents(...args),
|
|
37
|
+
getTabTitle: (...args: any[]) => mockGetTabTitle(...args),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
const mockChooseForkTarget = jest.fn();
|
|
41
|
+
jest.mock('@/shared/modals/ForkTargetModal', () => ({
|
|
42
|
+
chooseForkTarget: (...args: any[]) => mockChooseForkTarget(...args),
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
const mockBuildForkProviderState = jest.fn(
|
|
46
|
+
(sourceSessionId: string, resumeAt: string) => ({
|
|
47
|
+
forkSource: { sessionId: sourceSessionId, resumeAt },
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
const mockGetCapabilities = jest.fn().mockReturnValue({
|
|
51
|
+
providerId: 'claude',
|
|
52
|
+
supportsPersistentRuntime: true,
|
|
53
|
+
supportsNativeHistory: true,
|
|
54
|
+
supportsPlanMode: true,
|
|
55
|
+
supportsRewind: true,
|
|
56
|
+
supportsFork: true,
|
|
57
|
+
supportsProviderCommands: true,
|
|
58
|
+
supportsImageAttachments: true,
|
|
59
|
+
supportsInstructionMode: true,
|
|
60
|
+
supportsMcpTools: true,
|
|
61
|
+
reasoningControl: 'effort',
|
|
62
|
+
});
|
|
63
|
+
const mockCommandCatalogs: Record<string, any> = {};
|
|
64
|
+
const mockRuntimeCommandLoaders: Record<string, any> = {};
|
|
65
|
+
const mockTabWarmupPolicies: Record<string, any> = {};
|
|
66
|
+
jest.mock('@/core/providers/ProviderRegistry', () => ({
|
|
67
|
+
ProviderRegistry: {
|
|
68
|
+
createChatRuntime: (...args: any[]) => mockCreateChatRuntime(...args),
|
|
69
|
+
getConversationHistoryService: () => ({
|
|
70
|
+
buildForkProviderState: mockBuildForkProviderState,
|
|
71
|
+
}),
|
|
72
|
+
getCapabilities: (...args: any[]) => mockGetCapabilities(...args),
|
|
73
|
+
resolveProviderForModel: (model: string) => (
|
|
74
|
+
model.startsWith('opencode:') ? 'opencode'
|
|
75
|
+
: model.startsWith('gpt-') || /^o\d/.test(model) ? 'codex' : 'claude'
|
|
76
|
+
),
|
|
77
|
+
},
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
jest.mock('@/core/providers/ProviderWorkspaceRegistry', () => ({
|
|
81
|
+
ProviderWorkspaceRegistry: {
|
|
82
|
+
getCommandCatalog: (providerId: string) => mockCommandCatalogs[providerId] ?? null,
|
|
83
|
+
getRuntimeCommandLoader: (providerId: string) => mockRuntimeCommandLoaders[providerId] ?? null,
|
|
84
|
+
getTabWarmupPolicy: (providerId: string) => mockTabWarmupPolicies[providerId] ?? null,
|
|
85
|
+
setServices: (providerId: string, services: any) => {
|
|
86
|
+
if (services?.commandCatalog) {
|
|
87
|
+
mockCommandCatalogs[providerId] = services.commandCatalog;
|
|
88
|
+
} else {
|
|
89
|
+
delete mockCommandCatalogs[providerId];
|
|
90
|
+
}
|
|
91
|
+
if (services?.runtimeCommandLoader) {
|
|
92
|
+
mockRuntimeCommandLoaders[providerId] = services.runtimeCommandLoader;
|
|
93
|
+
} else {
|
|
94
|
+
delete mockRuntimeCommandLoaders[providerId];
|
|
95
|
+
}
|
|
96
|
+
if (services?.tabWarmupPolicy) {
|
|
97
|
+
mockTabWarmupPolicies[providerId] = services.tabWarmupPolicy;
|
|
98
|
+
} else {
|
|
99
|
+
delete mockTabWarmupPolicies[providerId];
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
}));
|
|
104
|
+
|
|
105
|
+
jest.mock('@/core/providers/ProviderSettingsCoordinator', () => ({
|
|
106
|
+
ProviderSettingsCoordinator: {
|
|
107
|
+
getProviderSettingsSnapshot: (...args: any[]) => mockGetProviderSettingsSnapshot(...args),
|
|
108
|
+
},
|
|
109
|
+
}));
|
|
110
|
+
|
|
111
|
+
function createMockPlugin(overrides: Record<string, any> = {}): any {
|
|
112
|
+
return {
|
|
113
|
+
app: {
|
|
114
|
+
workspace: {
|
|
115
|
+
setActiveLeaf: jest.fn(),
|
|
116
|
+
revealLeaf: jest.fn(),
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
settings: {
|
|
120
|
+
maxTabs: DEFAULT_MAX_TABS,
|
|
121
|
+
...(overrides.settings || {}),
|
|
122
|
+
},
|
|
123
|
+
getConversationById: jest.fn().mockResolvedValue(null),
|
|
124
|
+
getConversationSync: jest.fn().mockReturnValue(null),
|
|
125
|
+
getConversationList: jest.fn().mockReturnValue([]),
|
|
126
|
+
findConversationAcrossViews: jest.fn().mockReturnValue(null),
|
|
127
|
+
...overrides,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function createMockMcpManager(): any {
|
|
132
|
+
return {};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function createMockView(): any {
|
|
136
|
+
return {
|
|
137
|
+
leaf: { id: 'leaf-1' },
|
|
138
|
+
getTabManager: jest.fn().mockReturnValue(null),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function flushMicrotasks(count = 4): Promise<void> {
|
|
143
|
+
for (let i = 0; i < count; i++) {
|
|
144
|
+
await Promise.resolve();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function createMockTabData(overrides: Record<string, any> = {}): any {
|
|
149
|
+
const defaultState = {
|
|
150
|
+
isStreaming: false,
|
|
151
|
+
hasPendingConversationSave: false,
|
|
152
|
+
needsAttention: false,
|
|
153
|
+
messages: [],
|
|
154
|
+
currentConversationId: null,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const defaultControllers = {
|
|
158
|
+
conversationController: {
|
|
159
|
+
save: jest.fn().mockResolvedValue(undefined),
|
|
160
|
+
switchTo: jest.fn().mockResolvedValue(undefined),
|
|
161
|
+
initializeWelcome: jest.fn(),
|
|
162
|
+
},
|
|
163
|
+
inputController: {
|
|
164
|
+
handleApprovalRequest: jest.fn(),
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// Extract state and controllers from overrides to merge properly
|
|
169
|
+
const { state: stateOverrides, controllers: controllersOverrides, ...restOverrides } = overrides;
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
id: `tab-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
|
173
|
+
providerId: 'claude',
|
|
174
|
+
conversationId: null,
|
|
175
|
+
service: null,
|
|
176
|
+
serviceInitialized: false,
|
|
177
|
+
state: {
|
|
178
|
+
...defaultState,
|
|
179
|
+
...(stateOverrides || {}),
|
|
180
|
+
},
|
|
181
|
+
controllers: {
|
|
182
|
+
...defaultControllers,
|
|
183
|
+
...(controllersOverrides || {}),
|
|
184
|
+
},
|
|
185
|
+
dom: {
|
|
186
|
+
contentEl: createMockEl(),
|
|
187
|
+
},
|
|
188
|
+
ui: {
|
|
189
|
+
externalContextSelector: null,
|
|
190
|
+
slashCommandDropdown: null,
|
|
191
|
+
},
|
|
192
|
+
...restOverrides,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function createManager(options: {
|
|
197
|
+
plugin?: any;
|
|
198
|
+
callbacks?: TabManagerCallbacks;
|
|
199
|
+
tabFactory?: (counter: number) => any;
|
|
200
|
+
} = {}): TabManager {
|
|
201
|
+
jest.clearAllMocks();
|
|
202
|
+
let tabCounter = 0;
|
|
203
|
+
const factory = options.tabFactory ?? ((n: number) => createMockTabData({ id: `tab-${n}` }));
|
|
204
|
+
mockCreateTab.mockImplementation(() => {
|
|
205
|
+
tabCounter++;
|
|
206
|
+
return factory(tabCounter);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
return new TabManager(
|
|
210
|
+
options.plugin ?? createMockPlugin(),
|
|
211
|
+
createMockMcpManager(),
|
|
212
|
+
createMockEl(),
|
|
213
|
+
createMockView(),
|
|
214
|
+
options.callbacks
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
beforeEach(() => {
|
|
219
|
+
for (const providerId of Object.keys(mockCommandCatalogs)) {
|
|
220
|
+
delete mockCommandCatalogs[providerId];
|
|
221
|
+
}
|
|
222
|
+
for (const providerId of Object.keys(mockRuntimeCommandLoaders)) {
|
|
223
|
+
delete mockRuntimeCommandLoaders[providerId];
|
|
224
|
+
}
|
|
225
|
+
for (const providerId of Object.keys(mockTabWarmupPolicies)) {
|
|
226
|
+
delete mockTabWarmupPolicies[providerId];
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe('TabManager - Tab Lifecycle', () => {
|
|
231
|
+
let callbacks: TabManagerCallbacks;
|
|
232
|
+
|
|
233
|
+
beforeEach(() => {
|
|
234
|
+
callbacks = {
|
|
235
|
+
onTabCreated: jest.fn(),
|
|
236
|
+
onTabSwitched: jest.fn(),
|
|
237
|
+
onTabClosed: jest.fn(),
|
|
238
|
+
onTabStreamingChanged: jest.fn(),
|
|
239
|
+
onTabTitleChanged: jest.fn(),
|
|
240
|
+
onTabAttentionChanged: jest.fn(),
|
|
241
|
+
};
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe('createTab', () => {
|
|
245
|
+
it('should create a new tab', async () => {
|
|
246
|
+
const manager = createManager({ callbacks });
|
|
247
|
+
|
|
248
|
+
const tab = await manager.createTab();
|
|
249
|
+
|
|
250
|
+
expect(tab).toBeDefined();
|
|
251
|
+
expect(mockCreateTab).toHaveBeenCalled();
|
|
252
|
+
expect(mockInitializeTabUI).toHaveBeenCalled();
|
|
253
|
+
expect(mockInitializeTabControllers).toHaveBeenCalled();
|
|
254
|
+
expect(mockWireTabInputEvents).toHaveBeenCalled();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('should call onTabCreated callback', async () => {
|
|
258
|
+
const manager = createManager({ callbacks });
|
|
259
|
+
|
|
260
|
+
await manager.createTab();
|
|
261
|
+
|
|
262
|
+
expect(callbacks.onTabCreated).toHaveBeenCalled();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('should activate first tab automatically', async () => {
|
|
266
|
+
const manager = createManager({ callbacks });
|
|
267
|
+
|
|
268
|
+
await manager.createTab();
|
|
269
|
+
|
|
270
|
+
expect(mockActivateTab).toHaveBeenCalled();
|
|
271
|
+
// Service initialization is now lazy (on first query), not on switch
|
|
272
|
+
expect(mockInitializeTabService).not.toHaveBeenCalled();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('should enforce max tabs limit', async () => {
|
|
276
|
+
const manager = createManager({ callbacks });
|
|
277
|
+
|
|
278
|
+
for (let i = 0; i < DEFAULT_MAX_TABS; i++) {
|
|
279
|
+
await manager.createTab();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const extraTab = await manager.createTab();
|
|
283
|
+
|
|
284
|
+
expect(extraTab).toBeNull();
|
|
285
|
+
expect(manager.getTabCount()).toBe(DEFAULT_MAX_TABS);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('should use provided tab ID for restoration', async () => {
|
|
289
|
+
const manager = createManager({ callbacks });
|
|
290
|
+
mockCreateTab.mockImplementationOnce(() =>
|
|
291
|
+
createMockTabData({ id: 'restored-tab-id' })
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
await manager.createTab('conv-123', 'restored-tab-id');
|
|
295
|
+
|
|
296
|
+
expect(mockCreateTab).toHaveBeenCalledWith(
|
|
297
|
+
expect.objectContaining({ tabId: 'restored-tab-id' })
|
|
298
|
+
);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe('switchToTab', () => {
|
|
303
|
+
it('should switch to existing tab', async () => {
|
|
304
|
+
const manager = createManager({ callbacks });
|
|
305
|
+
|
|
306
|
+
const tab1 = await manager.createTab();
|
|
307
|
+
const tab2 = await manager.createTab();
|
|
308
|
+
|
|
309
|
+
// First, switch to tab2 to make it active (tab1 is active after creation)
|
|
310
|
+
await manager.switchToTab(tab2!.id);
|
|
311
|
+
|
|
312
|
+
jest.clearAllMocks();
|
|
313
|
+
|
|
314
|
+
await manager.switchToTab(tab1!.id);
|
|
315
|
+
|
|
316
|
+
expect(mockDeactivateTab).toHaveBeenCalled();
|
|
317
|
+
expect(mockActivateTab).toHaveBeenCalled();
|
|
318
|
+
expect(callbacks.onTabSwitched).toHaveBeenCalled();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('should not switch to non-existent tab', async () => {
|
|
322
|
+
const manager = createManager({ callbacks });
|
|
323
|
+
await manager.createTab();
|
|
324
|
+
|
|
325
|
+
jest.clearAllMocks();
|
|
326
|
+
await manager.switchToTab('non-existent-id');
|
|
327
|
+
|
|
328
|
+
expect(mockActivateTab).not.toHaveBeenCalled();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('should NOT initialize service on switch (lazy until first query)', async () => {
|
|
332
|
+
const manager = createManager({ callbacks });
|
|
333
|
+
|
|
334
|
+
await manager.createTab();
|
|
335
|
+
|
|
336
|
+
// Service initialization is now lazy (on first query), not on switch
|
|
337
|
+
expect(mockInitializeTabService).not.toHaveBeenCalled();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('notifies active tab change before cold conversation load completes', async () => {
|
|
341
|
+
const callbacks: TabManagerCallbacks = {
|
|
342
|
+
onActiveTabChanged: jest.fn(),
|
|
343
|
+
onTabSwitched: jest.fn(),
|
|
344
|
+
};
|
|
345
|
+
const manager = createManager({ callbacks });
|
|
346
|
+
const tab1 = await manager.createTab();
|
|
347
|
+
const tab2 = await manager.createTab();
|
|
348
|
+
let resolveSwitchTo!: () => void;
|
|
349
|
+
const pendingSwitch = new Promise<void>(resolve => {
|
|
350
|
+
resolveSwitchTo = resolve;
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
tab1!.conversationId = 'conv-1';
|
|
354
|
+
tab1!.state.messages = [];
|
|
355
|
+
tab1!.controllers.conversationController!.switchTo = jest.fn().mockReturnValue(pendingSwitch);
|
|
356
|
+
jest.clearAllMocks();
|
|
357
|
+
|
|
358
|
+
const switchPromise = manager.switchToTab(tab1!.id);
|
|
359
|
+
|
|
360
|
+
expect(callbacks.onActiveTabChanged).toHaveBeenCalledWith(tab2!.id, tab1!.id);
|
|
361
|
+
expect(callbacks.onTabSwitched).not.toHaveBeenCalled();
|
|
362
|
+
|
|
363
|
+
resolveSwitchTo();
|
|
364
|
+
await switchPromise;
|
|
365
|
+
|
|
366
|
+
expect(callbacks.onTabSwitched).toHaveBeenCalledWith(tab2!.id, tab1!.id);
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
describe('closeTab', () => {
|
|
371
|
+
it('should close a tab', async () => {
|
|
372
|
+
const manager = createManager({ callbacks });
|
|
373
|
+
|
|
374
|
+
const tab1 = await manager.createTab();
|
|
375
|
+
await manager.createTab(); // Need at least 2 tabs to close one
|
|
376
|
+
|
|
377
|
+
const closed = await manager.closeTab(tab1!.id);
|
|
378
|
+
|
|
379
|
+
expect(closed).toBe(true);
|
|
380
|
+
expect(mockDestroyTab).toHaveBeenCalled();
|
|
381
|
+
expect(callbacks.onTabClosed).toHaveBeenCalledWith(tab1!.id);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('should not close streaming tab unless forced', async () => {
|
|
385
|
+
const streamingTab = createMockTabData({
|
|
386
|
+
id: 'streaming-tab',
|
|
387
|
+
state: { isStreaming: true },
|
|
388
|
+
});
|
|
389
|
+
mockCreateTab.mockReturnValueOnce(streamingTab);
|
|
390
|
+
|
|
391
|
+
const manager = createManager({ callbacks });
|
|
392
|
+
await manager.createTab();
|
|
393
|
+
|
|
394
|
+
const closed = await manager.closeTab('streaming-tab');
|
|
395
|
+
|
|
396
|
+
expect(closed).toBe(false);
|
|
397
|
+
expect(mockDestroyTab).not.toHaveBeenCalled();
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('should close streaming tab when forced', async () => {
|
|
401
|
+
const streamingTab = createMockTabData({
|
|
402
|
+
id: 'streaming-tab',
|
|
403
|
+
state: { isStreaming: true },
|
|
404
|
+
});
|
|
405
|
+
mockCreateTab.mockReturnValueOnce(streamingTab);
|
|
406
|
+
|
|
407
|
+
const manager = createManager({ callbacks });
|
|
408
|
+
await manager.createTab();
|
|
409
|
+
await manager.createTab(); // Need second tab
|
|
410
|
+
|
|
411
|
+
const closed = await manager.closeTab('streaming-tab', true);
|
|
412
|
+
|
|
413
|
+
expect(closed).toBe(true);
|
|
414
|
+
expect(mockDestroyTab).toHaveBeenCalled();
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('should switch to another tab after closing active tab', async () => {
|
|
418
|
+
const manager = createManager({ callbacks });
|
|
419
|
+
|
|
420
|
+
// Create two tabs (variables intentionally unused - we just need tabs to exist)
|
|
421
|
+
await manager.createTab();
|
|
422
|
+
await manager.createTab();
|
|
423
|
+
|
|
424
|
+
// Close active tab
|
|
425
|
+
await manager.closeTab(manager.getActiveTabId()!);
|
|
426
|
+
|
|
427
|
+
// Should have switched to remaining tab
|
|
428
|
+
expect(manager.getTabCount()).toBe(1);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('should prefer previous tab when closing a middle tab', async () => {
|
|
432
|
+
const manager = createManager({ callbacks });
|
|
433
|
+
|
|
434
|
+
const tab1 = await manager.createTab();
|
|
435
|
+
const tab2 = await manager.createTab();
|
|
436
|
+
await manager.createTab();
|
|
437
|
+
|
|
438
|
+
await manager.switchToTab(tab2!.id);
|
|
439
|
+
|
|
440
|
+
const switchSpy = jest.spyOn(manager, 'switchToTab');
|
|
441
|
+
|
|
442
|
+
await manager.closeTab(tab2!.id);
|
|
443
|
+
|
|
444
|
+
expect(switchSpy).toHaveBeenCalledWith(tab1!.id);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('should fall back to next tab when closing the first tab', async () => {
|
|
448
|
+
const manager = createManager({ callbacks });
|
|
449
|
+
|
|
450
|
+
const tab1 = await manager.createTab();
|
|
451
|
+
const tab2 = await manager.createTab();
|
|
452
|
+
await manager.createTab();
|
|
453
|
+
|
|
454
|
+
await manager.switchToTab(tab1!.id);
|
|
455
|
+
|
|
456
|
+
const switchSpy = jest.spyOn(manager, 'switchToTab');
|
|
457
|
+
|
|
458
|
+
await manager.closeTab(tab1!.id);
|
|
459
|
+
|
|
460
|
+
expect(switchSpy).toHaveBeenCalledWith(tab2!.id);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it('should create new tab if all tabs are closed', async () => {
|
|
464
|
+
const manager = createManager({ callbacks });
|
|
465
|
+
|
|
466
|
+
const tab = await manager.createTab();
|
|
467
|
+
await manager.closeTab(tab!.id, true);
|
|
468
|
+
|
|
469
|
+
expect(manager.getTabCount()).toBe(1);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it('should save conversation before closing', async () => {
|
|
473
|
+
const mockSave = jest.fn().mockResolvedValue(undefined);
|
|
474
|
+
const tabWithSave = createMockTabData({ id: 'tab-with-save' });
|
|
475
|
+
tabWithSave.controllers.conversationController.save = mockSave;
|
|
476
|
+
|
|
477
|
+
mockCreateTab.mockReturnValueOnce(tabWithSave);
|
|
478
|
+
|
|
479
|
+
const manager = createManager({ callbacks });
|
|
480
|
+
await manager.createTab();
|
|
481
|
+
await manager.createTab(); // Need second tab
|
|
482
|
+
|
|
483
|
+
await manager.closeTab('tab-with-save', true);
|
|
484
|
+
|
|
485
|
+
expect(mockSave).toHaveBeenCalled();
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('should switch to next tab when closing first tab', async () => {
|
|
489
|
+
const manager = createManager({ callbacks });
|
|
490
|
+
|
|
491
|
+
const tab1 = await manager.createTab();
|
|
492
|
+
const tab2 = await manager.createTab();
|
|
493
|
+
await manager.createTab(); // tab-3
|
|
494
|
+
|
|
495
|
+
await manager.switchToTab(tab1!.id);
|
|
496
|
+
expect(manager.getActiveTabId()).toBe(tab1!.id);
|
|
497
|
+
|
|
498
|
+
await manager.closeTab(tab1!.id);
|
|
499
|
+
|
|
500
|
+
// Should switch to tab-2 (next tab, not previous since there is none)
|
|
501
|
+
expect(manager.getActiveTabId()).toBe(tab2!.id);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it('should switch to previous tab when closing middle tab', async () => {
|
|
505
|
+
const manager = createManager({ callbacks });
|
|
506
|
+
|
|
507
|
+
const tab1 = await manager.createTab();
|
|
508
|
+
const tab2 = await manager.createTab();
|
|
509
|
+
await manager.createTab(); // tab-3
|
|
510
|
+
|
|
511
|
+
await manager.switchToTab(tab2!.id);
|
|
512
|
+
expect(manager.getActiveTabId()).toBe(tab2!.id);
|
|
513
|
+
|
|
514
|
+
await manager.closeTab(tab2!.id);
|
|
515
|
+
|
|
516
|
+
// Should switch to tab-1 (previous tab)
|
|
517
|
+
expect(manager.getActiveTabId()).toBe(tab1!.id);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it('should switch to previous tab when closing last tab in list', async () => {
|
|
521
|
+
const manager = createManager({ callbacks });
|
|
522
|
+
|
|
523
|
+
await manager.createTab(); // tab-1
|
|
524
|
+
const tab2 = await manager.createTab();
|
|
525
|
+
const tab3 = await manager.createTab();
|
|
526
|
+
|
|
527
|
+
await manager.switchToTab(tab3!.id);
|
|
528
|
+
expect(manager.getActiveTabId()).toBe(tab3!.id);
|
|
529
|
+
|
|
530
|
+
await manager.closeTab(tab3!.id);
|
|
531
|
+
|
|
532
|
+
// Should switch to tab-2 (previous tab)
|
|
533
|
+
expect(manager.getActiveTabId()).toBe(tab2!.id);
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
describe('TabManager - Tab Queries', () => {
|
|
539
|
+
let manager: TabManager;
|
|
540
|
+
|
|
541
|
+
beforeEach(async () => {
|
|
542
|
+
manager = createManager();
|
|
543
|
+
await manager.createTab();
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
describe('getActiveTab', () => {
|
|
547
|
+
it('should return the active tab', () => {
|
|
548
|
+
const activeTab = manager.getActiveTab();
|
|
549
|
+
expect(activeTab).toBeDefined();
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
describe('getActiveTabId', () => {
|
|
554
|
+
it('should return the active tab ID', () => {
|
|
555
|
+
const activeTabId = manager.getActiveTabId();
|
|
556
|
+
expect(activeTabId).toBeDefined();
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
describe('getTab', () => {
|
|
561
|
+
it('should return tab by ID', () => {
|
|
562
|
+
const activeTabId = manager.getActiveTabId()!;
|
|
563
|
+
const tab = manager.getTab(activeTabId);
|
|
564
|
+
expect(tab).toBeDefined();
|
|
565
|
+
expect(tab?.id).toBe(activeTabId);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it('should return null for non-existent tab', () => {
|
|
569
|
+
const tab = manager.getTab('non-existent');
|
|
570
|
+
expect(tab).toBeNull();
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
describe('getAllTabs', () => {
|
|
575
|
+
it('should return all tabs', async () => {
|
|
576
|
+
await manager.createTab();
|
|
577
|
+
await manager.createTab();
|
|
578
|
+
|
|
579
|
+
const tabs = manager.getAllTabs();
|
|
580
|
+
expect(tabs.length).toBe(3);
|
|
581
|
+
});
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
describe('getTabCount', () => {
|
|
585
|
+
it('should return correct count', async () => {
|
|
586
|
+
expect(manager.getTabCount()).toBe(1);
|
|
587
|
+
|
|
588
|
+
await manager.createTab();
|
|
589
|
+
expect(manager.getTabCount()).toBe(2);
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
describe('canCreateTab', () => {
|
|
594
|
+
it('should return true when under limit', () => {
|
|
595
|
+
expect(manager.canCreateTab()).toBe(true);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it('should return false when at limit', async () => {
|
|
599
|
+
for (let i = 1; i < DEFAULT_MAX_TABS; i++) {
|
|
600
|
+
await manager.createTab();
|
|
601
|
+
}
|
|
602
|
+
expect(manager.canCreateTab()).toBe(false);
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
describe('TabManager - Tab Bar Data', () => {
|
|
608
|
+
let manager: TabManager;
|
|
609
|
+
|
|
610
|
+
beforeEach(async () => {
|
|
611
|
+
manager = createManager({
|
|
612
|
+
tabFactory: (n) => createMockTabData({
|
|
613
|
+
id: `tab-${n}`,
|
|
614
|
+
state: {
|
|
615
|
+
isStreaming: n === 2,
|
|
616
|
+
needsAttention: n === 3,
|
|
617
|
+
},
|
|
618
|
+
}),
|
|
619
|
+
});
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
describe('getTabBarItems', () => {
|
|
623
|
+
it('should return tab bar items with correct structure', async () => {
|
|
624
|
+
await manager.createTab();
|
|
625
|
+
await manager.createTab();
|
|
626
|
+
|
|
627
|
+
const items = manager.getTabBarItems();
|
|
628
|
+
|
|
629
|
+
expect(items.length).toBe(2);
|
|
630
|
+
expect(items[0]).toHaveProperty('id');
|
|
631
|
+
expect(items[0]).toHaveProperty('index');
|
|
632
|
+
expect(items[0]).toHaveProperty('title');
|
|
633
|
+
expect(items[0]).toHaveProperty('providerId');
|
|
634
|
+
expect(items[0]).toHaveProperty('isActive');
|
|
635
|
+
expect(items[0]).toHaveProperty('isStreaming');
|
|
636
|
+
expect(items[0]).toHaveProperty('needsAttention');
|
|
637
|
+
expect(items[0]).toHaveProperty('canClose');
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it('should have 1-based indices', async () => {
|
|
641
|
+
await manager.createTab();
|
|
642
|
+
await manager.createTab();
|
|
643
|
+
await manager.createTab();
|
|
644
|
+
|
|
645
|
+
const items = manager.getTabBarItems();
|
|
646
|
+
|
|
647
|
+
expect(items[0].index).toBe(1);
|
|
648
|
+
expect(items[1].index).toBe(2);
|
|
649
|
+
expect(items[2].index).toBe(3);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it('should mark streaming tabs', async () => {
|
|
653
|
+
await manager.createTab(); // Not streaming
|
|
654
|
+
await manager.createTab(); // Streaming
|
|
655
|
+
|
|
656
|
+
const items = manager.getTabBarItems();
|
|
657
|
+
|
|
658
|
+
expect(items[0].isStreaming).toBe(false);
|
|
659
|
+
expect(items[1].isStreaming).toBe(true);
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
it('should resolve badge provider from the live tab context', async () => {
|
|
663
|
+
manager = createManager({
|
|
664
|
+
plugin: createMockPlugin({
|
|
665
|
+
getConversationSync: jest.fn().mockImplementation((conversationId: string) => (
|
|
666
|
+
conversationId === 'conv-codex'
|
|
667
|
+
? { id: 'conv-codex', providerId: 'codex' }
|
|
668
|
+
: null
|
|
669
|
+
)),
|
|
670
|
+
}),
|
|
671
|
+
tabFactory: () => createMockTabData({
|
|
672
|
+
id: 'tab-1',
|
|
673
|
+
providerId: 'claude',
|
|
674
|
+
conversationId: 'conv-codex',
|
|
675
|
+
state: { isStreaming: true },
|
|
676
|
+
}),
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
await manager.createTab();
|
|
680
|
+
|
|
681
|
+
const items = manager.getTabBarItems();
|
|
682
|
+
|
|
683
|
+
expect(items[0].providerId).toBe('codex');
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
describe('TabManager - Conversation Management', () => {
|
|
689
|
+
let manager: TabManager;
|
|
690
|
+
let plugin: any;
|
|
691
|
+
|
|
692
|
+
beforeEach(async () => {
|
|
693
|
+
plugin = createMockPlugin();
|
|
694
|
+
manager = createManager({ plugin });
|
|
695
|
+
await manager.createTab();
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
describe('openConversation', () => {
|
|
699
|
+
it('should switch to tab if conversation is already open', async () => {
|
|
700
|
+
const tabWithConv = createMockTabData({
|
|
701
|
+
id: 'tab-with-conv',
|
|
702
|
+
conversationId: 'conv-123',
|
|
703
|
+
});
|
|
704
|
+
mockCreateTab.mockReturnValueOnce(tabWithConv);
|
|
705
|
+
await manager.createTab();
|
|
706
|
+
|
|
707
|
+
const switchSpy = jest.spyOn(manager, 'switchToTab');
|
|
708
|
+
await manager.openConversation('conv-123');
|
|
709
|
+
|
|
710
|
+
expect(switchSpy).toHaveBeenCalledWith('tab-with-conv');
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
it('should create new tab when preferNewTab is true', async () => {
|
|
714
|
+
plugin.getConversationById.mockResolvedValue({ id: 'conv-new' });
|
|
715
|
+
|
|
716
|
+
await manager.openConversation('conv-new', true);
|
|
717
|
+
|
|
718
|
+
expect(mockCreateTab).toHaveBeenCalledWith(
|
|
719
|
+
expect.objectContaining({
|
|
720
|
+
conversation: { id: 'conv-new' },
|
|
721
|
+
})
|
|
722
|
+
);
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
it('should create a background tab without switching focus', async () => {
|
|
726
|
+
plugin.getConversationById.mockResolvedValue({ id: 'conv-background' });
|
|
727
|
+
const initialActiveTabId = manager.getActiveTabId();
|
|
728
|
+
|
|
729
|
+
await manager.openConversation('conv-background', {
|
|
730
|
+
preferNewTab: true,
|
|
731
|
+
activate: false,
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
expect(mockCreateTab).toHaveBeenCalledWith(
|
|
735
|
+
expect.objectContaining({
|
|
736
|
+
conversation: { id: 'conv-background' },
|
|
737
|
+
})
|
|
738
|
+
);
|
|
739
|
+
expect(manager.getActiveTabId()).toBe(initialActiveTabId);
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
it('should check for cross-view duplicates', async () => {
|
|
743
|
+
plugin.findConversationAcrossViews.mockReturnValue({
|
|
744
|
+
view: { leaf: { id: 'other-leaf' }, getTabManager: () => ({ switchToTab: jest.fn() }) },
|
|
745
|
+
tabId: 'other-tab',
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
await manager.openConversation('conv-123');
|
|
749
|
+
|
|
750
|
+
expect(plugin.app.workspace.revealLeaf).toHaveBeenCalledWith({ id: 'other-leaf' });
|
|
751
|
+
});
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
describe('createNewConversation', () => {
|
|
755
|
+
it('should create new conversation in active tab', async () => {
|
|
756
|
+
const activeTab = manager.getActiveTab();
|
|
757
|
+
const createNew = jest.fn().mockResolvedValue(undefined);
|
|
758
|
+
activeTab!.controllers.conversationController = { createNew } as any;
|
|
759
|
+
|
|
760
|
+
await manager.createNewConversation();
|
|
761
|
+
|
|
762
|
+
expect(createNew).toHaveBeenCalled();
|
|
763
|
+
});
|
|
764
|
+
});
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
describe('TabManager - Persistence', () => {
|
|
768
|
+
let manager: TabManager;
|
|
769
|
+
|
|
770
|
+
beforeEach(async () => {
|
|
771
|
+
manager = createManager({
|
|
772
|
+
tabFactory: (n) => createMockTabData({
|
|
773
|
+
id: `tab-${n}`,
|
|
774
|
+
conversationId: n === 2 ? 'conv-456' : null,
|
|
775
|
+
}),
|
|
776
|
+
});
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
describe('getPersistedState', () => {
|
|
780
|
+
it('should return current tab state for persistence', async () => {
|
|
781
|
+
await manager.createTab();
|
|
782
|
+
await manager.createTab();
|
|
783
|
+
|
|
784
|
+
const state = manager.getPersistedState();
|
|
785
|
+
|
|
786
|
+
expect(state.openTabs).toHaveLength(2);
|
|
787
|
+
expect(state.activeTabId).toBeDefined();
|
|
788
|
+
expect(state.openTabs[0]).toHaveProperty('tabId');
|
|
789
|
+
expect(state.openTabs[0]).toHaveProperty('conversationId');
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
it('should persist draftModel for blank tabs', async () => {
|
|
793
|
+
const blankManager = createManager({
|
|
794
|
+
tabFactory: () => createMockTabData({
|
|
795
|
+
id: 'blank-opencode',
|
|
796
|
+
conversationId: null,
|
|
797
|
+
lifecycleState: 'blank',
|
|
798
|
+
draftModel: 'opencode:google/gemini-3.1-pro-preview',
|
|
799
|
+
providerId: 'opencode',
|
|
800
|
+
}),
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
await blankManager.createTab();
|
|
804
|
+
|
|
805
|
+
expect(blankManager.getPersistedState()).toEqual({
|
|
806
|
+
activeTabId: 'blank-opencode',
|
|
807
|
+
openTabs: [{
|
|
808
|
+
tabId: 'blank-opencode',
|
|
809
|
+
conversationId: null,
|
|
810
|
+
draftModel: 'opencode:google/gemini-3.1-pro-preview',
|
|
811
|
+
}],
|
|
812
|
+
});
|
|
813
|
+
});
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
describe('restoreState', () => {
|
|
817
|
+
it('should restore tabs from persisted state', async () => {
|
|
818
|
+
const persistedState: PersistedTabManagerState = {
|
|
819
|
+
openTabs: [
|
|
820
|
+
{ tabId: 'restored-1', conversationId: 'conv-1' },
|
|
821
|
+
{ tabId: 'restored-2', conversationId: 'conv-2' },
|
|
822
|
+
],
|
|
823
|
+
activeTabId: 'restored-2',
|
|
824
|
+
};
|
|
825
|
+
|
|
826
|
+
await manager.restoreState(persistedState);
|
|
827
|
+
|
|
828
|
+
expect(mockCreateTab).toHaveBeenCalledTimes(2);
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
it('should restore draftModel for blank tabs', async () => {
|
|
832
|
+
const persistedState: PersistedTabManagerState = {
|
|
833
|
+
openTabs: [
|
|
834
|
+
{
|
|
835
|
+
tabId: 'restored-blank',
|
|
836
|
+
conversationId: null,
|
|
837
|
+
draftModel: 'opencode:google/gemini-3.1-pro-preview',
|
|
838
|
+
},
|
|
839
|
+
],
|
|
840
|
+
activeTabId: 'restored-blank',
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
await manager.restoreState(persistedState);
|
|
844
|
+
|
|
845
|
+
expect(mockCreateTab).toHaveBeenCalledWith(expect.objectContaining({
|
|
846
|
+
tabId: 'restored-blank',
|
|
847
|
+
draftModel: 'opencode:google/gemini-3.1-pro-preview',
|
|
848
|
+
}));
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
it('should switch to previously active tab', async () => {
|
|
852
|
+
mockCreateTab.mockImplementation((opts: any) =>
|
|
853
|
+
createMockTabData({ id: opts.tabId || 'default-tab' })
|
|
854
|
+
);
|
|
855
|
+
|
|
856
|
+
const persistedState: PersistedTabManagerState = {
|
|
857
|
+
openTabs: [
|
|
858
|
+
{ tabId: 'restored-1', conversationId: null },
|
|
859
|
+
{ tabId: 'restored-2', conversationId: null },
|
|
860
|
+
],
|
|
861
|
+
activeTabId: 'restored-2',
|
|
862
|
+
};
|
|
863
|
+
|
|
864
|
+
await manager.restoreState(persistedState);
|
|
865
|
+
|
|
866
|
+
expect(manager.getActiveTabId()).toBe('restored-2');
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
it('should create default tab if no tabs restored', async () => {
|
|
870
|
+
// Reset mock to return valid tab data
|
|
871
|
+
mockCreateTab.mockReturnValue(createMockTabData({ id: 'default-tab' }));
|
|
872
|
+
|
|
873
|
+
await manager.restoreState({ openTabs: [], activeTabId: null });
|
|
874
|
+
|
|
875
|
+
expect(mockCreateTab).toHaveBeenCalled();
|
|
876
|
+
expect(manager.getTabCount()).toBe(1);
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
it('should handle tab restoration errors gracefully', async () => {
|
|
880
|
+
let callCount = 0;
|
|
881
|
+
mockCreateTab.mockImplementation(() => {
|
|
882
|
+
callCount++;
|
|
883
|
+
if (callCount === 1) {
|
|
884
|
+
throw new Error('Tab creation failed');
|
|
885
|
+
}
|
|
886
|
+
return createMockTabData({ id: `tab-${callCount}` });
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
const persistedState: PersistedTabManagerState = {
|
|
890
|
+
openTabs: [
|
|
891
|
+
{ tabId: 'fail-tab', conversationId: null },
|
|
892
|
+
{ tabId: 'success-tab', conversationId: null },
|
|
893
|
+
],
|
|
894
|
+
activeTabId: null,
|
|
895
|
+
};
|
|
896
|
+
|
|
897
|
+
// Should not throw
|
|
898
|
+
await expect(manager.restoreState(persistedState)).resolves.not.toThrow();
|
|
899
|
+
|
|
900
|
+
// Should have created at least one tab
|
|
901
|
+
expect(manager.getTabCount()).toBeGreaterThanOrEqual(1);
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
it('keeps non-active restored pre-session OpenCode tabs cold until the final active tab is chosen', async () => {
|
|
905
|
+
const runtimeCommandLoader = {
|
|
906
|
+
isAvailable: jest.fn().mockReturnValue(true),
|
|
907
|
+
loadCommands: jest.fn().mockResolvedValue([{ id: 'acp:review', name: 'review', content: '' }]),
|
|
908
|
+
};
|
|
909
|
+
const mockCatalog = {
|
|
910
|
+
setRuntimeCommands: jest.fn(),
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
ProviderWorkspaceRegistry.setServices('opencode', {
|
|
914
|
+
commandCatalog: mockCatalog as any,
|
|
915
|
+
runtimeCommandLoader: runtimeCommandLoader as any,
|
|
916
|
+
tabWarmupPolicy: commandWarmupPolicy as any,
|
|
917
|
+
});
|
|
918
|
+
mockGetCapabilities.mockImplementation((providerId: string) => ({
|
|
919
|
+
providerId,
|
|
920
|
+
supportsPersistentRuntime: true,
|
|
921
|
+
supportsNativeHistory: true,
|
|
922
|
+
supportsPlanMode: providerId === 'claude',
|
|
923
|
+
supportsRewind: providerId === 'claude',
|
|
924
|
+
supportsFork: providerId === 'claude',
|
|
925
|
+
supportsProviderCommands: providerId === 'opencode' || providerId === 'claude',
|
|
926
|
+
reasoningControl: providerId === 'opencode' ? 'effort' : 'none',
|
|
927
|
+
}));
|
|
928
|
+
|
|
929
|
+
const plugin = createMockPlugin({
|
|
930
|
+
getConversationById: jest.fn().mockImplementation(async (conversationId: string) => {
|
|
931
|
+
if (conversationId === 'conv-opencode') {
|
|
932
|
+
return {
|
|
933
|
+
id: 'conv-opencode',
|
|
934
|
+
messages: [{ id: 'm1' }],
|
|
935
|
+
providerState: {},
|
|
936
|
+
sessionId: null,
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
return {
|
|
941
|
+
id: 'conv-claude',
|
|
942
|
+
messages: [],
|
|
943
|
+
providerState: {},
|
|
944
|
+
sessionId: 'session-1',
|
|
945
|
+
};
|
|
946
|
+
}),
|
|
947
|
+
});
|
|
948
|
+
const manager = createManager({
|
|
949
|
+
plugin,
|
|
950
|
+
tabFactory: (n) => createMockTabData({
|
|
951
|
+
id: n === 1 ? 'restored-opencode' : 'restored-claude',
|
|
952
|
+
providerId: n === 1 ? 'opencode' : 'claude',
|
|
953
|
+
conversationId: n === 1 ? 'conv-opencode' : 'conv-claude',
|
|
954
|
+
lifecycleState: 'bound_cold',
|
|
955
|
+
ui: {
|
|
956
|
+
externalContextSelector: {
|
|
957
|
+
getExternalContexts: jest.fn().mockReturnValue([]),
|
|
958
|
+
},
|
|
959
|
+
},
|
|
960
|
+
}),
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
await manager.restoreState({
|
|
964
|
+
openTabs: [
|
|
965
|
+
{ tabId: 'restored-opencode', conversationId: 'conv-opencode' },
|
|
966
|
+
{ tabId: 'restored-claude', conversationId: 'conv-claude' },
|
|
967
|
+
],
|
|
968
|
+
activeTabId: 'restored-claude',
|
|
969
|
+
});
|
|
970
|
+
await flushMicrotasks();
|
|
971
|
+
|
|
972
|
+
expect(manager.getActiveTabId()).toBe('restored-claude');
|
|
973
|
+
expect(runtimeCommandLoader.loadCommands).not.toHaveBeenCalled();
|
|
974
|
+
expect(mockCatalog.setRuntimeCommands).not.toHaveBeenCalled();
|
|
975
|
+
});
|
|
976
|
+
});
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
describe('TabManager - Broadcast', () => {
|
|
980
|
+
let manager: TabManager;
|
|
981
|
+
|
|
982
|
+
beforeEach(async () => {
|
|
983
|
+
manager = createManager({
|
|
984
|
+
tabFactory: (n) => createMockTabData({
|
|
985
|
+
id: `tab-${n}`,
|
|
986
|
+
service: { someMethod: jest.fn() },
|
|
987
|
+
serviceInitialized: true,
|
|
988
|
+
}),
|
|
989
|
+
});
|
|
990
|
+
await manager.createTab();
|
|
991
|
+
await manager.createTab();
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
describe('broadcastToAllTabs', () => {
|
|
995
|
+
it('should call function on all initialized services', async () => {
|
|
996
|
+
const broadcastFn = jest.fn().mockResolvedValue(undefined);
|
|
997
|
+
|
|
998
|
+
await manager.broadcastToAllTabs(broadcastFn);
|
|
999
|
+
|
|
1000
|
+
expect(broadcastFn).toHaveBeenCalledTimes(2);
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
it('should handle errors in broadcast gracefully', async () => {
|
|
1004
|
+
const broadcastFn = jest.fn()
|
|
1005
|
+
.mockResolvedValueOnce(undefined)
|
|
1006
|
+
.mockRejectedValueOnce(new Error('Broadcast failed'));
|
|
1007
|
+
|
|
1008
|
+
// Should not throw
|
|
1009
|
+
await expect(manager.broadcastToAllTabs(broadcastFn)).resolves.not.toThrow();
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
it('should skip tabs without initialized services', async () => {
|
|
1013
|
+
// Create tab without initialized service
|
|
1014
|
+
mockCreateTab.mockReturnValueOnce(
|
|
1015
|
+
createMockTabData({ service: null, serviceInitialized: false })
|
|
1016
|
+
);
|
|
1017
|
+
await manager.createTab();
|
|
1018
|
+
|
|
1019
|
+
const broadcastFn = jest.fn().mockResolvedValue(undefined);
|
|
1020
|
+
await manager.broadcastToAllTabs(broadcastFn);
|
|
1021
|
+
|
|
1022
|
+
// Should only be called for the 2 initialized tabs, not the 3rd
|
|
1023
|
+
expect(broadcastFn).toHaveBeenCalledTimes(2);
|
|
1024
|
+
});
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
describe('broadcastToProviderTabs', () => {
|
|
1028
|
+
it('should only call initialized runtimes for the requested provider', async () => {
|
|
1029
|
+
manager = createManager({
|
|
1030
|
+
tabFactory: (n) => createMockTabData({
|
|
1031
|
+
id: `tab-${n}`,
|
|
1032
|
+
providerId: n === 1 ? 'claude' : 'opencode',
|
|
1033
|
+
service: {
|
|
1034
|
+
providerId: n === 1 ? 'claude' : 'opencode',
|
|
1035
|
+
},
|
|
1036
|
+
serviceInitialized: true,
|
|
1037
|
+
}),
|
|
1038
|
+
});
|
|
1039
|
+
await manager.createTab();
|
|
1040
|
+
await manager.createTab();
|
|
1041
|
+
|
|
1042
|
+
const broadcastFn = jest.fn().mockResolvedValue(undefined);
|
|
1043
|
+
await manager.broadcastToProviderTabs('opencode', broadcastFn);
|
|
1044
|
+
|
|
1045
|
+
expect(broadcastFn).toHaveBeenCalledTimes(1);
|
|
1046
|
+
expect(broadcastFn).toHaveBeenCalledWith(expect.objectContaining({ providerId: 'opencode' }));
|
|
1047
|
+
});
|
|
1048
|
+
});
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
describe('TabManager - SDK Commands', () => {
|
|
1052
|
+
beforeEach(() => {
|
|
1053
|
+
mockGetCapabilities.mockReset();
|
|
1054
|
+
mockGetCapabilities.mockReturnValue({
|
|
1055
|
+
providerId: 'claude',
|
|
1056
|
+
supportsPersistentRuntime: true,
|
|
1057
|
+
supportsNativeHistory: true,
|
|
1058
|
+
supportsPlanMode: true,
|
|
1059
|
+
supportsRewind: true,
|
|
1060
|
+
supportsFork: true,
|
|
1061
|
+
supportsProviderCommands: true,
|
|
1062
|
+
reasoningControl: 'effort',
|
|
1063
|
+
});
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
it('should return commands from the target tab runtime when it is ready', async () => {
|
|
1067
|
+
const supportedCommands = [{ id: 'sdk:commit', name: 'commit', content: '' }];
|
|
1068
|
+
const readyService = {
|
|
1069
|
+
providerId: 'claude',
|
|
1070
|
+
isReady: jest.fn().mockReturnValue(true),
|
|
1071
|
+
getSupportedCommands: jest.fn().mockResolvedValue(supportedCommands),
|
|
1072
|
+
};
|
|
1073
|
+
const manager = createManager({
|
|
1074
|
+
tabFactory: () => createMockTabData({
|
|
1075
|
+
id: 'tab-ready',
|
|
1076
|
+
providerId: 'claude',
|
|
1077
|
+
service: readyService,
|
|
1078
|
+
}),
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
const tab = await manager.createTab();
|
|
1082
|
+
|
|
1083
|
+
await expect(manager.getSdkCommands(tab!.id)).resolves.toEqual(supportedCommands);
|
|
1084
|
+
expect(readyService.getSupportedCommands).toHaveBeenCalledTimes(1);
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
it('should reuse commands from another ready tab with the same provider', async () => {
|
|
1088
|
+
const supportedCommands = [{ id: 'sdk:commit', name: 'commit', content: '' }];
|
|
1089
|
+
const readyClaudeService = {
|
|
1090
|
+
providerId: 'claude',
|
|
1091
|
+
isReady: jest.fn().mockReturnValue(true),
|
|
1092
|
+
getSupportedCommands: jest.fn().mockResolvedValue(supportedCommands),
|
|
1093
|
+
};
|
|
1094
|
+
const manager = createManager({
|
|
1095
|
+
tabFactory: (n) => createMockTabData({
|
|
1096
|
+
id: `tab-${n}`,
|
|
1097
|
+
providerId: 'claude',
|
|
1098
|
+
service: n === 1 ? readyClaudeService : null,
|
|
1099
|
+
}),
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
await manager.createTab();
|
|
1103
|
+
const lazyClaudeTab = await manager.createTab();
|
|
1104
|
+
|
|
1105
|
+
await expect(manager.getSdkCommands(lazyClaudeTab!.id)).resolves.toEqual(supportedCommands);
|
|
1106
|
+
expect(readyClaudeService.getSupportedCommands).toHaveBeenCalledTimes(1);
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
it('should not leak commands across providers', async () => {
|
|
1110
|
+
const claudeCommands = [{ id: 'sdk:commit', name: 'commit', content: '' }];
|
|
1111
|
+
const readyClaudeService = {
|
|
1112
|
+
providerId: 'claude',
|
|
1113
|
+
isReady: jest.fn().mockReturnValue(true),
|
|
1114
|
+
getSupportedCommands: jest.fn().mockResolvedValue(claudeCommands),
|
|
1115
|
+
};
|
|
1116
|
+
const manager = createManager({
|
|
1117
|
+
tabFactory: (n) => createMockTabData({
|
|
1118
|
+
id: `tab-${n}`,
|
|
1119
|
+
providerId: n === 2 ? 'codex' : 'claude',
|
|
1120
|
+
service: n === 1 ? readyClaudeService : null,
|
|
1121
|
+
}),
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
await manager.createTab();
|
|
1125
|
+
const codexTab = await manager.createTab();
|
|
1126
|
+
mockGetCapabilities.mockImplementation((providerId: string) => ({
|
|
1127
|
+
providerId,
|
|
1128
|
+
supportsPersistentRuntime: true,
|
|
1129
|
+
supportsNativeHistory: true,
|
|
1130
|
+
supportsPlanMode: providerId === 'claude',
|
|
1131
|
+
supportsRewind: providerId === 'claude',
|
|
1132
|
+
supportsFork: providerId === 'claude',
|
|
1133
|
+
supportsProviderCommands: providerId === 'claude',
|
|
1134
|
+
reasoningControl: providerId === 'claude' ? 'effort' : 'none',
|
|
1135
|
+
}));
|
|
1136
|
+
|
|
1137
|
+
await expect(manager.getSdkCommands(codexTab!.id)).resolves.toEqual([]);
|
|
1138
|
+
expect(readyClaudeService.getSupportedCommands).not.toHaveBeenCalled();
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
it('should resolve blank-tab SDK command provider from draftModel instead of stale providerId', async () => {
|
|
1142
|
+
const claudeCommands = [{ id: 'sdk:commit', name: 'commit', content: '' }];
|
|
1143
|
+
const readyClaudeService = {
|
|
1144
|
+
providerId: 'claude',
|
|
1145
|
+
isReady: jest.fn().mockReturnValue(true),
|
|
1146
|
+
getSupportedCommands: jest.fn().mockResolvedValue(claudeCommands),
|
|
1147
|
+
};
|
|
1148
|
+
const manager = createManager({
|
|
1149
|
+
tabFactory: (n) => createMockTabData({
|
|
1150
|
+
id: `tab-${n}`,
|
|
1151
|
+
lifecycleState: n === 2 ? 'blank' : 'bound_cold',
|
|
1152
|
+
draftModel: n === 2 ? DEFAULT_CODEX_PRIMARY_MODEL : null,
|
|
1153
|
+
providerId: 'claude',
|
|
1154
|
+
service: n === 1 ? readyClaudeService : null,
|
|
1155
|
+
}),
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
await manager.createTab();
|
|
1159
|
+
const blankCodexTab = await manager.createTab();
|
|
1160
|
+
mockGetCapabilities.mockImplementation((providerId: string) => ({
|
|
1161
|
+
providerId,
|
|
1162
|
+
supportsPersistentRuntime: true,
|
|
1163
|
+
supportsNativeHistory: true,
|
|
1164
|
+
supportsPlanMode: providerId === 'claude',
|
|
1165
|
+
supportsRewind: providerId === 'claude',
|
|
1166
|
+
supportsFork: providerId === 'claude',
|
|
1167
|
+
supportsProviderCommands: providerId === 'claude',
|
|
1168
|
+
reasoningControl: providerId === 'claude' ? 'effort' : 'none',
|
|
1169
|
+
}));
|
|
1170
|
+
|
|
1171
|
+
await expect(manager.getSdkCommands(blankCodexTab!.id)).resolves.toEqual([]);
|
|
1172
|
+
expect(readyClaudeService.getSupportedCommands).not.toHaveBeenCalled();
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
it('should keep inactive blank OpenCode tabs cold when SDK commands are requested', async () => {
|
|
1176
|
+
const mockCatalog = {
|
|
1177
|
+
setRuntimeCommands: jest.fn(),
|
|
1178
|
+
};
|
|
1179
|
+
const runtimeCommandLoader = {
|
|
1180
|
+
isAvailable: jest.fn().mockReturnValue(true),
|
|
1181
|
+
loadCommands: jest.fn(),
|
|
1182
|
+
};
|
|
1183
|
+
|
|
1184
|
+
ProviderWorkspaceRegistry.setServices('opencode', {
|
|
1185
|
+
commandCatalog: mockCatalog as any,
|
|
1186
|
+
runtimeCommandLoader: runtimeCommandLoader as any,
|
|
1187
|
+
tabWarmupPolicy: commandWarmupPolicy as any,
|
|
1188
|
+
});
|
|
1189
|
+
mockGetCapabilities.mockImplementation((providerId: string) => ({
|
|
1190
|
+
providerId,
|
|
1191
|
+
supportsPersistentRuntime: true,
|
|
1192
|
+
supportsNativeHistory: true,
|
|
1193
|
+
supportsPlanMode: providerId === 'claude',
|
|
1194
|
+
supportsRewind: providerId === 'claude',
|
|
1195
|
+
supportsFork: providerId === 'claude',
|
|
1196
|
+
supportsProviderCommands: providerId === 'opencode' || providerId === 'claude',
|
|
1197
|
+
reasoningControl: providerId === 'opencode' ? 'effort' : 'none',
|
|
1198
|
+
}));
|
|
1199
|
+
const manager = createManager({
|
|
1200
|
+
plugin: createMockPlugin(),
|
|
1201
|
+
tabFactory: (n) => createMockTabData(
|
|
1202
|
+
n === 1
|
|
1203
|
+
? {
|
|
1204
|
+
id: 'tab-claude',
|
|
1205
|
+
providerId: 'claude',
|
|
1206
|
+
}
|
|
1207
|
+
: {
|
|
1208
|
+
id: 'tab-opencode',
|
|
1209
|
+
providerId: 'opencode',
|
|
1210
|
+
draftModel: 'opencode:openai/gpt-5',
|
|
1211
|
+
lifecycleState: 'blank',
|
|
1212
|
+
ui: {
|
|
1213
|
+
externalContextSelector: {
|
|
1214
|
+
getExternalContexts: jest.fn().mockReturnValue([]),
|
|
1215
|
+
},
|
|
1216
|
+
},
|
|
1217
|
+
}
|
|
1218
|
+
),
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
await manager.createTab();
|
|
1222
|
+
const tab = await manager.createTab(undefined, 'tab-opencode', { activate: false });
|
|
1223
|
+
|
|
1224
|
+
await expect(manager.getSdkCommands(tab!.id)).resolves.toEqual([]);
|
|
1225
|
+
expect(mockInitializeTabService).not.toHaveBeenCalled();
|
|
1226
|
+
expect(mockSetupServiceCallbacks).not.toHaveBeenCalled();
|
|
1227
|
+
expect(runtimeCommandLoader.loadCommands).not.toHaveBeenCalled();
|
|
1228
|
+
expect(mockCatalog.setRuntimeCommands).toHaveBeenLastCalledWith([]);
|
|
1229
|
+
expect(tab!.lifecycleState).toBe('blank');
|
|
1230
|
+
expect(tab!.serviceInitialized).toBe(false);
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
it('should invalidate cached OpenCode commands when the saved session context changes', async () => {
|
|
1234
|
+
const firstCommands = [{ id: 'acp:review', name: 'review', content: '' }];
|
|
1235
|
+
const secondCommands = [{ id: 'acp:compact', name: 'compact', content: '' }];
|
|
1236
|
+
const mockCatalog = {
|
|
1237
|
+
setRuntimeCommands: jest.fn(),
|
|
1238
|
+
};
|
|
1239
|
+
const runtimeCommandLoader = {
|
|
1240
|
+
isAvailable: jest.fn().mockReturnValue(true),
|
|
1241
|
+
loadCommands: jest.fn()
|
|
1242
|
+
.mockResolvedValueOnce(firstCommands)
|
|
1243
|
+
.mockResolvedValueOnce(secondCommands),
|
|
1244
|
+
};
|
|
1245
|
+
|
|
1246
|
+
ProviderWorkspaceRegistry.setServices('opencode', {
|
|
1247
|
+
commandCatalog: mockCatalog as any,
|
|
1248
|
+
runtimeCommandLoader: runtimeCommandLoader as any,
|
|
1249
|
+
tabWarmupPolicy: commandWarmupPolicy as any,
|
|
1250
|
+
});
|
|
1251
|
+
mockGetCapabilities.mockImplementation((providerId: string) => ({
|
|
1252
|
+
providerId,
|
|
1253
|
+
supportsPersistentRuntime: true,
|
|
1254
|
+
supportsNativeHistory: true,
|
|
1255
|
+
supportsPlanMode: providerId === 'claude',
|
|
1256
|
+
supportsRewind: providerId === 'claude',
|
|
1257
|
+
supportsFork: providerId === 'claude',
|
|
1258
|
+
supportsProviderCommands: providerId === 'opencode' || providerId === 'claude',
|
|
1259
|
+
reasoningControl: providerId === 'opencode' ? 'effort' : 'none',
|
|
1260
|
+
}));
|
|
1261
|
+
const resetSdkSkillsCache = jest.fn();
|
|
1262
|
+
const plugin = createMockPlugin({
|
|
1263
|
+
getConversationById: jest.fn()
|
|
1264
|
+
.mockResolvedValueOnce({
|
|
1265
|
+
id: 'conv-opencode',
|
|
1266
|
+
messages: [{ id: 'm1' }],
|
|
1267
|
+
providerState: { databasePath: '/persisted/opencode.db' },
|
|
1268
|
+
sessionId: 'session-1',
|
|
1269
|
+
})
|
|
1270
|
+
.mockResolvedValueOnce({
|
|
1271
|
+
id: 'conv-opencode',
|
|
1272
|
+
messages: [{ id: 'm1' }],
|
|
1273
|
+
providerState: { databasePath: '/persisted/opencode.db' },
|
|
1274
|
+
sessionId: 'session-1',
|
|
1275
|
+
})
|
|
1276
|
+
.mockResolvedValueOnce({
|
|
1277
|
+
id: 'conv-opencode',
|
|
1278
|
+
messages: [{ id: 'm1' }],
|
|
1279
|
+
providerState: { databasePath: '/persisted/opencode.db' },
|
|
1280
|
+
sessionId: 'session-1',
|
|
1281
|
+
})
|
|
1282
|
+
.mockResolvedValueOnce({
|
|
1283
|
+
id: 'conv-opencode',
|
|
1284
|
+
messages: [{ id: 'm1' }],
|
|
1285
|
+
providerState: { databasePath: '/persisted/opencode.db' },
|
|
1286
|
+
sessionId: 'session-1',
|
|
1287
|
+
})
|
|
1288
|
+
.mockResolvedValueOnce({
|
|
1289
|
+
id: 'conv-opencode',
|
|
1290
|
+
messages: [{ id: 'm1' }],
|
|
1291
|
+
providerState: { databasePath: '/persisted/opencode.db' },
|
|
1292
|
+
sessionId: 'session-2',
|
|
1293
|
+
}),
|
|
1294
|
+
});
|
|
1295
|
+
const manager = createManager({
|
|
1296
|
+
plugin,
|
|
1297
|
+
tabFactory: (n) => createMockTabData(
|
|
1298
|
+
n === 1
|
|
1299
|
+
? {
|
|
1300
|
+
id: 'tab-claude',
|
|
1301
|
+
providerId: 'claude',
|
|
1302
|
+
}
|
|
1303
|
+
: {
|
|
1304
|
+
id: 'tab-opencode',
|
|
1305
|
+
providerId: 'opencode',
|
|
1306
|
+
conversationId: 'conv-opencode',
|
|
1307
|
+
lifecycleState: 'bound_cold',
|
|
1308
|
+
ui: {
|
|
1309
|
+
externalContextSelector: {
|
|
1310
|
+
getExternalContexts: jest.fn().mockReturnValue([]),
|
|
1311
|
+
},
|
|
1312
|
+
slashCommandDropdown: {
|
|
1313
|
+
resetSdkSkillsCache,
|
|
1314
|
+
},
|
|
1315
|
+
},
|
|
1316
|
+
}
|
|
1317
|
+
),
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
await manager.createTab();
|
|
1321
|
+
const tab = await manager.createTab('conv-opencode', 'tab-opencode', { activate: false });
|
|
1322
|
+
|
|
1323
|
+
await expect(manager.getSdkCommands(tab!.id)).resolves.toEqual(firstCommands);
|
|
1324
|
+
await expect(manager.getSdkCommands(tab!.id)).resolves.toEqual(firstCommands);
|
|
1325
|
+
|
|
1326
|
+
await expect(manager.getSdkCommands(tab!.id)).resolves.toEqual(secondCommands);
|
|
1327
|
+
expect(runtimeCommandLoader.loadCommands).toHaveBeenCalledTimes(2);
|
|
1328
|
+
expect(resetSdkSkillsCache).not.toHaveBeenCalled();
|
|
1329
|
+
});
|
|
1330
|
+
|
|
1331
|
+
it('should prime active blank OpenCode tabs automatically', async () => {
|
|
1332
|
+
const supportedCommands = [{ id: 'acp:review', name: 'review', content: '' }];
|
|
1333
|
+
const mockCatalog = {
|
|
1334
|
+
setRuntimeCommands: jest.fn(),
|
|
1335
|
+
};
|
|
1336
|
+
const runtimeCommandLoader = {
|
|
1337
|
+
isAvailable: jest.fn().mockReturnValue(true),
|
|
1338
|
+
loadCommands: jest.fn().mockResolvedValue(supportedCommands),
|
|
1339
|
+
};
|
|
1340
|
+
|
|
1341
|
+
ProviderWorkspaceRegistry.setServices('opencode', {
|
|
1342
|
+
commandCatalog: mockCatalog as any,
|
|
1343
|
+
runtimeCommandLoader: runtimeCommandLoader as any,
|
|
1344
|
+
tabWarmupPolicy: commandWarmupPolicy as any,
|
|
1345
|
+
});
|
|
1346
|
+
mockGetCapabilities.mockImplementation((providerId: string) => ({
|
|
1347
|
+
providerId,
|
|
1348
|
+
supportsPersistentRuntime: true,
|
|
1349
|
+
supportsNativeHistory: true,
|
|
1350
|
+
supportsPlanMode: providerId === 'claude',
|
|
1351
|
+
supportsRewind: providerId === 'claude',
|
|
1352
|
+
supportsFork: providerId === 'claude',
|
|
1353
|
+
supportsProviderCommands: providerId === 'opencode' || providerId === 'claude',
|
|
1354
|
+
reasoningControl: providerId === 'opencode' ? 'effort' : 'none',
|
|
1355
|
+
}));
|
|
1356
|
+
const manager = createManager({
|
|
1357
|
+
plugin: createMockPlugin(),
|
|
1358
|
+
tabFactory: () => createMockTabData({
|
|
1359
|
+
id: 'tab-opencode',
|
|
1360
|
+
providerId: 'opencode',
|
|
1361
|
+
draftModel: 'opencode:openai/gpt-5',
|
|
1362
|
+
lifecycleState: 'blank',
|
|
1363
|
+
ui: {
|
|
1364
|
+
externalContextSelector: {
|
|
1365
|
+
getExternalContexts: jest.fn().mockReturnValue([]),
|
|
1366
|
+
},
|
|
1367
|
+
},
|
|
1368
|
+
}),
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
const tab = await manager.createTab();
|
|
1372
|
+
await flushMicrotasks();
|
|
1373
|
+
|
|
1374
|
+
expect(runtimeCommandLoader.loadCommands).toHaveBeenCalledTimes(1);
|
|
1375
|
+
expect(mockCatalog.setRuntimeCommands).toHaveBeenLastCalledWith(supportedCommands);
|
|
1376
|
+
expect(tab!.lifecycleState).toBe('blank');
|
|
1377
|
+
expect(tab!.serviceInitialized).toBe(false);
|
|
1378
|
+
});
|
|
1379
|
+
|
|
1380
|
+
it('should prime the active restored OpenCode conversation tab automatically', async () => {
|
|
1381
|
+
const supportedCommands = [{ id: 'acp:review', name: 'review', content: '' }];
|
|
1382
|
+
const mockCatalog = {
|
|
1383
|
+
setRuntimeCommands: jest.fn(),
|
|
1384
|
+
};
|
|
1385
|
+
const runtimeCommandLoader = {
|
|
1386
|
+
isAvailable: jest.fn().mockReturnValue(true),
|
|
1387
|
+
loadCommands: jest.fn().mockResolvedValue(supportedCommands),
|
|
1388
|
+
};
|
|
1389
|
+
|
|
1390
|
+
ProviderWorkspaceRegistry.setServices('opencode', {
|
|
1391
|
+
commandCatalog: mockCatalog as any,
|
|
1392
|
+
runtimeCommandLoader: runtimeCommandLoader as any,
|
|
1393
|
+
tabWarmupPolicy: commandWarmupPolicy as any,
|
|
1394
|
+
});
|
|
1395
|
+
mockGetCapabilities.mockImplementation((providerId: string) => ({
|
|
1396
|
+
providerId,
|
|
1397
|
+
supportsPersistentRuntime: true,
|
|
1398
|
+
supportsNativeHistory: true,
|
|
1399
|
+
supportsPlanMode: providerId === 'claude',
|
|
1400
|
+
supportsRewind: providerId === 'claude',
|
|
1401
|
+
supportsFork: providerId === 'claude',
|
|
1402
|
+
supportsProviderCommands: providerId === 'opencode' || providerId === 'claude',
|
|
1403
|
+
reasoningControl: providerId === 'opencode' ? 'effort' : 'none',
|
|
1404
|
+
}));
|
|
1405
|
+
const plugin = createMockPlugin({
|
|
1406
|
+
getConversationById: jest.fn().mockResolvedValue({
|
|
1407
|
+
id: 'conv-opencode',
|
|
1408
|
+
messages: [{ id: 'm1' }],
|
|
1409
|
+
providerState: { databasePath: '/persisted/opencode.db' },
|
|
1410
|
+
sessionId: 'session-1',
|
|
1411
|
+
}),
|
|
1412
|
+
});
|
|
1413
|
+
const manager = createManager({
|
|
1414
|
+
plugin,
|
|
1415
|
+
tabFactory: () => createMockTabData({
|
|
1416
|
+
id: 'tab-opencode-restored',
|
|
1417
|
+
providerId: 'opencode',
|
|
1418
|
+
conversationId: 'conv-opencode',
|
|
1419
|
+
lifecycleState: 'bound_cold',
|
|
1420
|
+
ui: {
|
|
1421
|
+
externalContextSelector: {
|
|
1422
|
+
getExternalContexts: jest.fn().mockReturnValue([]),
|
|
1423
|
+
},
|
|
1424
|
+
},
|
|
1425
|
+
}),
|
|
1426
|
+
});
|
|
1427
|
+
|
|
1428
|
+
await manager.createTab('conv-opencode', 'tab-opencode-restored', { activate: false });
|
|
1429
|
+
await flushMicrotasks();
|
|
1430
|
+
|
|
1431
|
+
expect(runtimeCommandLoader.loadCommands).toHaveBeenCalledTimes(1);
|
|
1432
|
+
expect(mockCatalog.setRuntimeCommands).toHaveBeenLastCalledWith(supportedCommands);
|
|
1433
|
+
});
|
|
1434
|
+
|
|
1435
|
+
it('should prime the active restored pre-session OpenCode conversation tab automatically', async () => {
|
|
1436
|
+
const supportedCommands = [{ id: 'acp:review', name: 'review', content: '' }];
|
|
1437
|
+
const mockCatalog = {
|
|
1438
|
+
setRuntimeCommands: jest.fn(),
|
|
1439
|
+
};
|
|
1440
|
+
const runtimeCommandLoader = {
|
|
1441
|
+
isAvailable: jest.fn().mockReturnValue(true),
|
|
1442
|
+
loadCommands: jest.fn().mockResolvedValue(supportedCommands),
|
|
1443
|
+
};
|
|
1444
|
+
|
|
1445
|
+
ProviderWorkspaceRegistry.setServices('opencode', {
|
|
1446
|
+
commandCatalog: mockCatalog as any,
|
|
1447
|
+
runtimeCommandLoader: runtimeCommandLoader as any,
|
|
1448
|
+
tabWarmupPolicy: commandWarmupPolicy as any,
|
|
1449
|
+
});
|
|
1450
|
+
mockGetCapabilities.mockImplementation((providerId: string) => ({
|
|
1451
|
+
providerId,
|
|
1452
|
+
supportsPersistentRuntime: true,
|
|
1453
|
+
supportsNativeHistory: true,
|
|
1454
|
+
supportsPlanMode: providerId === 'claude',
|
|
1455
|
+
supportsRewind: providerId === 'claude',
|
|
1456
|
+
supportsFork: providerId === 'claude',
|
|
1457
|
+
supportsProviderCommands: providerId === 'opencode' || providerId === 'claude',
|
|
1458
|
+
reasoningControl: providerId === 'opencode' ? 'effort' : 'none',
|
|
1459
|
+
}));
|
|
1460
|
+
const plugin = createMockPlugin({
|
|
1461
|
+
getConversationById: jest.fn().mockResolvedValue({
|
|
1462
|
+
id: 'conv-opencode',
|
|
1463
|
+
messages: [{ id: 'm1' }],
|
|
1464
|
+
providerState: {},
|
|
1465
|
+
sessionId: null,
|
|
1466
|
+
}),
|
|
1467
|
+
});
|
|
1468
|
+
const manager = createManager({
|
|
1469
|
+
plugin,
|
|
1470
|
+
tabFactory: () => createMockTabData({
|
|
1471
|
+
id: 'tab-opencode-pre-session',
|
|
1472
|
+
providerId: 'opencode',
|
|
1473
|
+
conversationId: 'conv-opencode',
|
|
1474
|
+
lifecycleState: 'bound_cold',
|
|
1475
|
+
ui: {
|
|
1476
|
+
externalContextSelector: {
|
|
1477
|
+
getExternalContexts: jest.fn().mockReturnValue([]),
|
|
1478
|
+
},
|
|
1479
|
+
},
|
|
1480
|
+
}),
|
|
1481
|
+
});
|
|
1482
|
+
|
|
1483
|
+
await manager.createTab('conv-opencode', 'tab-opencode-pre-session', { activate: false });
|
|
1484
|
+
await flushMicrotasks();
|
|
1485
|
+
|
|
1486
|
+
expect(runtimeCommandLoader.loadCommands).toHaveBeenCalledTimes(1);
|
|
1487
|
+
expect(mockCatalog.setRuntimeCommands).toHaveBeenLastCalledWith(supportedCommands);
|
|
1488
|
+
});
|
|
1489
|
+
|
|
1490
|
+
it('should keep inactive restored OpenCode conversation tabs cold', async () => {
|
|
1491
|
+
const mockCatalog = {
|
|
1492
|
+
setRuntimeCommands: jest.fn(),
|
|
1493
|
+
};
|
|
1494
|
+
const runtimeCommandLoader = {
|
|
1495
|
+
isAvailable: jest.fn().mockReturnValue(true),
|
|
1496
|
+
loadCommands: jest.fn(),
|
|
1497
|
+
};
|
|
1498
|
+
|
|
1499
|
+
ProviderWorkspaceRegistry.setServices('opencode', {
|
|
1500
|
+
commandCatalog: mockCatalog as any,
|
|
1501
|
+
runtimeCommandLoader: runtimeCommandLoader as any,
|
|
1502
|
+
tabWarmupPolicy: commandWarmupPolicy as any,
|
|
1503
|
+
});
|
|
1504
|
+
mockGetCapabilities.mockImplementation((providerId: string) => ({
|
|
1505
|
+
providerId,
|
|
1506
|
+
supportsPersistentRuntime: true,
|
|
1507
|
+
supportsNativeHistory: true,
|
|
1508
|
+
supportsPlanMode: providerId === 'claude',
|
|
1509
|
+
supportsRewind: providerId === 'claude',
|
|
1510
|
+
supportsFork: providerId === 'claude',
|
|
1511
|
+
supportsProviderCommands: providerId === 'opencode' || providerId === 'claude',
|
|
1512
|
+
reasoningControl: providerId === 'opencode' ? 'effort' : 'none',
|
|
1513
|
+
}));
|
|
1514
|
+
const plugin = createMockPlugin({
|
|
1515
|
+
getConversationById: jest.fn().mockResolvedValue({
|
|
1516
|
+
id: 'conv-opencode',
|
|
1517
|
+
messages: [{ id: 'm1' }],
|
|
1518
|
+
providerState: { databasePath: '/persisted/opencode.db' },
|
|
1519
|
+
sessionId: 'session-1',
|
|
1520
|
+
}),
|
|
1521
|
+
});
|
|
1522
|
+
const manager = createManager({
|
|
1523
|
+
plugin,
|
|
1524
|
+
tabFactory: (n) => createMockTabData({
|
|
1525
|
+
id: n === 1 ? 'tab-claude' : 'tab-opencode-restored',
|
|
1526
|
+
providerId: n === 1 ? 'claude' : 'opencode',
|
|
1527
|
+
conversationId: n === 1 ? 'conv-claude' : 'conv-opencode',
|
|
1528
|
+
lifecycleState: n === 1 ? 'bound_active' : 'bound_cold',
|
|
1529
|
+
ui: {
|
|
1530
|
+
externalContextSelector: {
|
|
1531
|
+
getExternalContexts: jest.fn().mockReturnValue([]),
|
|
1532
|
+
},
|
|
1533
|
+
},
|
|
1534
|
+
}),
|
|
1535
|
+
});
|
|
1536
|
+
|
|
1537
|
+
await manager.createTab('conv-claude', 'tab-claude');
|
|
1538
|
+
runtimeCommandLoader.loadCommands.mockClear();
|
|
1539
|
+
mockCatalog.setRuntimeCommands.mockClear();
|
|
1540
|
+
|
|
1541
|
+
await manager.createTab('conv-opencode', 'tab-opencode-restored', { activate: false });
|
|
1542
|
+
await flushMicrotasks();
|
|
1543
|
+
|
|
1544
|
+
expect(runtimeCommandLoader.loadCommands).not.toHaveBeenCalled();
|
|
1545
|
+
expect(mockCatalog.setRuntimeCommands).not.toHaveBeenCalled();
|
|
1546
|
+
});
|
|
1547
|
+
|
|
1548
|
+
it('should not borrow ready OpenCode commands from another tab session', async () => {
|
|
1549
|
+
const readyCommands = [{ id: 'acp:review', name: 'review', content: '' }];
|
|
1550
|
+
const loaderCommands = [{ id: 'acp:compact', name: 'compact', content: '' }];
|
|
1551
|
+
const readyService = {
|
|
1552
|
+
providerId: 'opencode',
|
|
1553
|
+
isReady: jest.fn().mockReturnValue(true),
|
|
1554
|
+
getSupportedCommands: jest.fn().mockResolvedValue(readyCommands),
|
|
1555
|
+
};
|
|
1556
|
+
const mockCatalog = {
|
|
1557
|
+
setRuntimeCommands: jest.fn(),
|
|
1558
|
+
};
|
|
1559
|
+
const runtimeCommandLoader = {
|
|
1560
|
+
isAvailable: jest.fn().mockReturnValue(true),
|
|
1561
|
+
loadCommands: jest.fn().mockResolvedValue(loaderCommands),
|
|
1562
|
+
};
|
|
1563
|
+
|
|
1564
|
+
ProviderWorkspaceRegistry.setServices('opencode', {
|
|
1565
|
+
commandCatalog: mockCatalog as any,
|
|
1566
|
+
runtimeCommandLoader: runtimeCommandLoader as any,
|
|
1567
|
+
tabWarmupPolicy: commandWarmupPolicy as any,
|
|
1568
|
+
});
|
|
1569
|
+
mockGetCapabilities.mockImplementation((providerId: string) => ({
|
|
1570
|
+
providerId,
|
|
1571
|
+
supportsPersistentRuntime: true,
|
|
1572
|
+
supportsNativeHistory: true,
|
|
1573
|
+
supportsPlanMode: providerId === 'claude',
|
|
1574
|
+
supportsRewind: providerId === 'claude',
|
|
1575
|
+
supportsFork: providerId === 'claude',
|
|
1576
|
+
supportsProviderCommands: providerId === 'opencode' || providerId === 'claude',
|
|
1577
|
+
reasoningControl: providerId === 'opencode' ? 'effort' : 'none',
|
|
1578
|
+
}));
|
|
1579
|
+
const plugin = createMockPlugin({
|
|
1580
|
+
getConversationById: jest.fn().mockResolvedValue({
|
|
1581
|
+
id: 'conv-opencode',
|
|
1582
|
+
messages: [{ id: 'm1' }],
|
|
1583
|
+
providerState: { databasePath: '/persisted/opencode.db' },
|
|
1584
|
+
sessionId: 'session-2',
|
|
1585
|
+
}),
|
|
1586
|
+
});
|
|
1587
|
+
const manager = createManager({
|
|
1588
|
+
plugin,
|
|
1589
|
+
tabFactory: (n) => createMockTabData({
|
|
1590
|
+
id: `tab-${n}`,
|
|
1591
|
+
providerId: 'opencode',
|
|
1592
|
+
conversationId: n === 2 ? 'conv-opencode' : null,
|
|
1593
|
+
lifecycleState: n === 2 ? 'bound_cold' : 'bound_active',
|
|
1594
|
+
service: n === 1 ? readyService : null,
|
|
1595
|
+
ui: {
|
|
1596
|
+
externalContextSelector: {
|
|
1597
|
+
getExternalContexts: jest.fn().mockReturnValue([]),
|
|
1598
|
+
},
|
|
1599
|
+
},
|
|
1600
|
+
}),
|
|
1601
|
+
});
|
|
1602
|
+
|
|
1603
|
+
await manager.createTab();
|
|
1604
|
+
readyService.getSupportedCommands.mockClear();
|
|
1605
|
+
const coldTab = await manager.createTab('conv-opencode');
|
|
1606
|
+
|
|
1607
|
+
await expect(manager.getSdkCommands(coldTab!.id)).resolves.toEqual(loaderCommands);
|
|
1608
|
+
expect(readyService.getSupportedCommands).not.toHaveBeenCalled();
|
|
1609
|
+
expect(runtimeCommandLoader.loadCommands).toHaveBeenCalledTimes(1);
|
|
1610
|
+
});
|
|
1611
|
+
|
|
1612
|
+
it('should keep an active restored Claude conversation tab cold', async () => {
|
|
1613
|
+
ProviderWorkspaceRegistry.setServices('claude', {
|
|
1614
|
+
commandCatalog: {
|
|
1615
|
+
setRuntimeCommands: jest.fn(),
|
|
1616
|
+
} as any,
|
|
1617
|
+
});
|
|
1618
|
+
mockGetCapabilities.mockImplementation((providerId: string) => ({
|
|
1619
|
+
providerId,
|
|
1620
|
+
supportsPersistentRuntime: true,
|
|
1621
|
+
supportsNativeHistory: true,
|
|
1622
|
+
supportsPlanMode: true,
|
|
1623
|
+
supportsRewind: true,
|
|
1624
|
+
supportsFork: true,
|
|
1625
|
+
supportsProviderCommands: providerId === 'claude',
|
|
1626
|
+
reasoningControl: 'effort',
|
|
1627
|
+
}));
|
|
1628
|
+
const plugin = createMockPlugin({
|
|
1629
|
+
getConversationById: jest.fn().mockResolvedValue({
|
|
1630
|
+
id: 'conv-claude',
|
|
1631
|
+
messages: [{ id: 'm1' }],
|
|
1632
|
+
providerState: {},
|
|
1633
|
+
sessionId: 'session-1',
|
|
1634
|
+
}),
|
|
1635
|
+
});
|
|
1636
|
+
const manager = createManager({
|
|
1637
|
+
plugin,
|
|
1638
|
+
tabFactory: () => createMockTabData({
|
|
1639
|
+
id: 'tab-claude-restored',
|
|
1640
|
+
providerId: 'claude',
|
|
1641
|
+
conversationId: 'conv-claude',
|
|
1642
|
+
lifecycleState: 'bound_cold',
|
|
1643
|
+
ui: {
|
|
1644
|
+
externalContextSelector: {
|
|
1645
|
+
getExternalContexts: jest.fn().mockReturnValue([]),
|
|
1646
|
+
},
|
|
1647
|
+
},
|
|
1648
|
+
}),
|
|
1649
|
+
});
|
|
1650
|
+
|
|
1651
|
+
const tab = await manager.createTab('conv-claude', 'tab-claude-restored', { activate: false });
|
|
1652
|
+
await flushMicrotasks();
|
|
1653
|
+
|
|
1654
|
+
expect(mockInitializeTabService).not.toHaveBeenCalled();
|
|
1655
|
+
expect(mockSetupServiceCallbacks).not.toHaveBeenCalled();
|
|
1656
|
+
expect(tab!.service).toBeNull();
|
|
1657
|
+
expect(tab!.serviceInitialized).toBe(false);
|
|
1658
|
+
});
|
|
1659
|
+
});
|
|
1660
|
+
|
|
1661
|
+
describe('TabManager - Provider Command Catalog', () => {
|
|
1662
|
+
const mockCatalogEntries = [
|
|
1663
|
+
{
|
|
1664
|
+
id: 'codex-skill-analyze', providerId: 'codex', kind: 'skill',
|
|
1665
|
+
name: 'analyze', description: 'Analyze code', content: '',
|
|
1666
|
+
scope: 'vault', source: 'user', isEditable: true, isDeletable: true,
|
|
1667
|
+
displayPrefix: '$', insertPrefix: '$',
|
|
1668
|
+
},
|
|
1669
|
+
];
|
|
1670
|
+
|
|
1671
|
+
const mockCatalog = {
|
|
1672
|
+
listDropdownEntries: jest.fn().mockResolvedValue(mockCatalogEntries),
|
|
1673
|
+
listVaultEntries: jest.fn().mockResolvedValue(mockCatalogEntries),
|
|
1674
|
+
saveVaultEntry: jest.fn(),
|
|
1675
|
+
deleteVaultEntry: jest.fn(),
|
|
1676
|
+
setRuntimeCommands: jest.fn(),
|
|
1677
|
+
getDropdownConfig: jest.fn().mockReturnValue({
|
|
1678
|
+
triggerChars: ['/', '$'],
|
|
1679
|
+
builtInPrefix: '/',
|
|
1680
|
+
skillPrefix: '$',
|
|
1681
|
+
commandPrefix: '/',
|
|
1682
|
+
}),
|
|
1683
|
+
refresh: jest.fn(),
|
|
1684
|
+
};
|
|
1685
|
+
|
|
1686
|
+
afterEach(() => {
|
|
1687
|
+
ProviderWorkspaceRegistry.setServices('codex', undefined);
|
|
1688
|
+
ProviderWorkspaceRegistry.setServices('claude', undefined);
|
|
1689
|
+
ProviderWorkspaceRegistry.setServices('opencode', undefined);
|
|
1690
|
+
});
|
|
1691
|
+
|
|
1692
|
+
it('should pass provider catalog config to initializeTabUI for Codex tab', async () => {
|
|
1693
|
+
ProviderWorkspaceRegistry.setServices('codex', { commandCatalog: mockCatalog as any });
|
|
1694
|
+
|
|
1695
|
+
const manager = createManager({
|
|
1696
|
+
tabFactory: () => createMockTabData({ id: 'tab-1', providerId: 'codex' }),
|
|
1697
|
+
});
|
|
1698
|
+
|
|
1699
|
+
await manager.createTab();
|
|
1700
|
+
|
|
1701
|
+
const options = mockInitializeTabUI.mock.calls[0][2];
|
|
1702
|
+
const catalogConfig = options.getProviderCatalogConfig();
|
|
1703
|
+
|
|
1704
|
+
expect(catalogConfig).not.toBeNull();
|
|
1705
|
+
expect(catalogConfig.config.triggerChars).toEqual(['/', '$']);
|
|
1706
|
+
expect(catalogConfig.config.skillPrefix).toBe('$');
|
|
1707
|
+
});
|
|
1708
|
+
|
|
1709
|
+
it('should provide scan-backed entries for Codex without runtime', async () => {
|
|
1710
|
+
ProviderWorkspaceRegistry.setServices('codex', { commandCatalog: mockCatalog as any });
|
|
1711
|
+
|
|
1712
|
+
const manager = createManager({
|
|
1713
|
+
tabFactory: () => createMockTabData({ id: 'tab-1', providerId: 'codex' }),
|
|
1714
|
+
});
|
|
1715
|
+
|
|
1716
|
+
await manager.createTab();
|
|
1717
|
+
|
|
1718
|
+
const options = mockInitializeTabUI.mock.calls[0][2];
|
|
1719
|
+
const catalogConfig = options.getProviderCatalogConfig();
|
|
1720
|
+
const entries = await catalogConfig.getEntries();
|
|
1721
|
+
|
|
1722
|
+
expect(entries).toHaveLength(1);
|
|
1723
|
+
expect(entries[0].name).toBe('analyze');
|
|
1724
|
+
expect(entries[0].displayPrefix).toBe('$');
|
|
1725
|
+
});
|
|
1726
|
+
|
|
1727
|
+
it('should resolve the blank-tab catalog from draftModel instead of stale providerId', async () => {
|
|
1728
|
+
const claudeCatalog = {
|
|
1729
|
+
listDropdownEntries: jest.fn().mockResolvedValue([
|
|
1730
|
+
{
|
|
1731
|
+
id: 'claude-command-test', providerId: 'claude', kind: 'command',
|
|
1732
|
+
name: 'claude-only', description: 'Claude command', content: '',
|
|
1733
|
+
scope: 'vault', source: 'user', isEditable: true, isDeletable: true,
|
|
1734
|
+
displayPrefix: '/', insertPrefix: '/',
|
|
1735
|
+
},
|
|
1736
|
+
]),
|
|
1737
|
+
listVaultEntries: jest.fn().mockResolvedValue([]),
|
|
1738
|
+
saveVaultEntry: jest.fn(),
|
|
1739
|
+
deleteVaultEntry: jest.fn(),
|
|
1740
|
+
setRuntimeCommands: jest.fn(),
|
|
1741
|
+
getDropdownConfig: jest.fn().mockReturnValue({
|
|
1742
|
+
triggerChars: ['/'],
|
|
1743
|
+
builtInPrefix: '/',
|
|
1744
|
+
skillPrefix: '/',
|
|
1745
|
+
commandPrefix: '/',
|
|
1746
|
+
}),
|
|
1747
|
+
refresh: jest.fn(),
|
|
1748
|
+
};
|
|
1749
|
+
ProviderWorkspaceRegistry.setServices('claude', { commandCatalog: claudeCatalog as any });
|
|
1750
|
+
ProviderWorkspaceRegistry.setServices('codex', { commandCatalog: mockCatalog as any });
|
|
1751
|
+
|
|
1752
|
+
const manager = createManager({
|
|
1753
|
+
tabFactory: () => createMockTabData({
|
|
1754
|
+
id: 'tab-1',
|
|
1755
|
+
lifecycleState: 'blank',
|
|
1756
|
+
draftModel: DEFAULT_CODEX_PRIMARY_MODEL,
|
|
1757
|
+
providerId: 'claude',
|
|
1758
|
+
}),
|
|
1759
|
+
});
|
|
1760
|
+
|
|
1761
|
+
await manager.createTab();
|
|
1762
|
+
|
|
1763
|
+
const options = mockInitializeTabUI.mock.calls[0][2];
|
|
1764
|
+
const catalogConfig = options.getProviderCatalogConfig();
|
|
1765
|
+
const entries = await catalogConfig.getEntries();
|
|
1766
|
+
|
|
1767
|
+
expect(catalogConfig).not.toBeNull();
|
|
1768
|
+
expect(catalogConfig.config.skillPrefix).toBe('$');
|
|
1769
|
+
expect(entries).toHaveLength(1);
|
|
1770
|
+
expect(entries[0].providerId).toBe('codex');
|
|
1771
|
+
expect(mockCatalog.listDropdownEntries).toHaveBeenCalledWith({ includeBuiltIns: false });
|
|
1772
|
+
expect(claudeCatalog.listDropdownEntries).not.toHaveBeenCalled();
|
|
1773
|
+
});
|
|
1774
|
+
|
|
1775
|
+
it('should refresh Claude runtime commands before listing catalog entries', async () => {
|
|
1776
|
+
const supportedCommands = [{ id: 'sdk:commit', name: 'commit', content: '', source: 'sdk' }];
|
|
1777
|
+
const readyService = {
|
|
1778
|
+
providerId: 'claude',
|
|
1779
|
+
isReady: jest.fn().mockReturnValue(true),
|
|
1780
|
+
getSupportedCommands: jest.fn().mockResolvedValue(supportedCommands),
|
|
1781
|
+
};
|
|
1782
|
+
const claudeCatalog = {
|
|
1783
|
+
listDropdownEntries: jest.fn().mockResolvedValue([]),
|
|
1784
|
+
listVaultEntries: jest.fn().mockResolvedValue([]),
|
|
1785
|
+
saveVaultEntry: jest.fn(),
|
|
1786
|
+
deleteVaultEntry: jest.fn(),
|
|
1787
|
+
setRuntimeCommands: jest.fn(),
|
|
1788
|
+
getDropdownConfig: jest.fn().mockReturnValue({
|
|
1789
|
+
triggerChars: ['/'],
|
|
1790
|
+
builtInPrefix: '/',
|
|
1791
|
+
skillPrefix: '/',
|
|
1792
|
+
commandPrefix: '/',
|
|
1793
|
+
}),
|
|
1794
|
+
refresh: jest.fn(),
|
|
1795
|
+
};
|
|
1796
|
+
ProviderWorkspaceRegistry.setServices('claude', { commandCatalog: claudeCatalog as any });
|
|
1797
|
+
|
|
1798
|
+
const manager = createManager({
|
|
1799
|
+
tabFactory: () => createMockTabData({
|
|
1800
|
+
id: 'tab-1',
|
|
1801
|
+
providerId: 'claude',
|
|
1802
|
+
service: readyService,
|
|
1803
|
+
}),
|
|
1804
|
+
});
|
|
1805
|
+
|
|
1806
|
+
await manager.createTab();
|
|
1807
|
+
|
|
1808
|
+
const options = mockInitializeTabUI.mock.calls[0][2];
|
|
1809
|
+
const catalogConfig = options.getProviderCatalogConfig();
|
|
1810
|
+
await catalogConfig.getEntries();
|
|
1811
|
+
|
|
1812
|
+
expect(readyService.getSupportedCommands).toHaveBeenCalledTimes(1);
|
|
1813
|
+
expect(claudeCatalog.setRuntimeCommands).toHaveBeenCalledWith(supportedCommands);
|
|
1814
|
+
expect(claudeCatalog.listDropdownEntries).toHaveBeenCalledWith({ includeBuiltIns: false });
|
|
1815
|
+
});
|
|
1816
|
+
|
|
1817
|
+
it('should clear Claude runtime commands when revalidation returns no commands', async () => {
|
|
1818
|
+
const readyService = {
|
|
1819
|
+
providerId: 'claude',
|
|
1820
|
+
isReady: jest.fn().mockReturnValue(true),
|
|
1821
|
+
getSupportedCommands: jest.fn().mockResolvedValue([]),
|
|
1822
|
+
};
|
|
1823
|
+
const claudeCatalog = {
|
|
1824
|
+
listDropdownEntries: jest.fn().mockResolvedValue([]),
|
|
1825
|
+
listVaultEntries: jest.fn().mockResolvedValue([]),
|
|
1826
|
+
saveVaultEntry: jest.fn(),
|
|
1827
|
+
deleteVaultEntry: jest.fn(),
|
|
1828
|
+
setRuntimeCommands: jest.fn(),
|
|
1829
|
+
getDropdownConfig: jest.fn().mockReturnValue({
|
|
1830
|
+
triggerChars: ['/'],
|
|
1831
|
+
builtInPrefix: '/',
|
|
1832
|
+
skillPrefix: '/',
|
|
1833
|
+
commandPrefix: '/',
|
|
1834
|
+
}),
|
|
1835
|
+
refresh: jest.fn(),
|
|
1836
|
+
};
|
|
1837
|
+
ProviderWorkspaceRegistry.setServices('claude', { commandCatalog: claudeCatalog as any });
|
|
1838
|
+
|
|
1839
|
+
const manager = createManager({
|
|
1840
|
+
tabFactory: () => createMockTabData({
|
|
1841
|
+
id: 'tab-1',
|
|
1842
|
+
providerId: 'claude',
|
|
1843
|
+
service: readyService,
|
|
1844
|
+
}),
|
|
1845
|
+
});
|
|
1846
|
+
|
|
1847
|
+
const tab = await manager.createTab();
|
|
1848
|
+
|
|
1849
|
+
await expect(manager.getSdkCommands(tab!.id)).resolves.toEqual([]);
|
|
1850
|
+
expect(claudeCatalog.setRuntimeCommands).toHaveBeenCalledWith([]);
|
|
1851
|
+
});
|
|
1852
|
+
|
|
1853
|
+
it('starts blank-tab provider warmup in the background from the provider-change callback', async () => {
|
|
1854
|
+
const manager = createManager();
|
|
1855
|
+
const tab = await manager.createTab();
|
|
1856
|
+
const options = mockInitializeTabUI.mock.calls[0][2];
|
|
1857
|
+
|
|
1858
|
+
let releaseWarmup!: () => void;
|
|
1859
|
+
const prewarmSpy = jest.spyOn(manager as any, 'prewarmProviderTab').mockImplementation(
|
|
1860
|
+
() => new Promise<void>((resolve) => {
|
|
1861
|
+
releaseWarmup = resolve;
|
|
1862
|
+
}),
|
|
1863
|
+
);
|
|
1864
|
+
|
|
1865
|
+
let settled = false;
|
|
1866
|
+
const callbackPromise = Promise.resolve(options.onProviderChanged('opencode')).then(() => {
|
|
1867
|
+
settled = true;
|
|
1868
|
+
});
|
|
1869
|
+
|
|
1870
|
+
await Promise.resolve();
|
|
1871
|
+
|
|
1872
|
+
expect(prewarmSpy).toHaveBeenCalledWith(tab);
|
|
1873
|
+
await callbackPromise;
|
|
1874
|
+
expect(settled).toBe(true);
|
|
1875
|
+
|
|
1876
|
+
releaseWarmup();
|
|
1877
|
+
});
|
|
1878
|
+
|
|
1879
|
+
it('should return null catalog config when provider has no catalog', async () => {
|
|
1880
|
+
// No catalog assigned to registry for 'claude'
|
|
1881
|
+
|
|
1882
|
+
const manager = createManager({
|
|
1883
|
+
tabFactory: () => createMockTabData({ id: 'tab-1', providerId: 'claude' }),
|
|
1884
|
+
});
|
|
1885
|
+
|
|
1886
|
+
await manager.createTab();
|
|
1887
|
+
|
|
1888
|
+
const options = mockInitializeTabUI.mock.calls[0][2];
|
|
1889
|
+
const catalogConfig = options.getProviderCatalogConfig();
|
|
1890
|
+
|
|
1891
|
+
expect(catalogConfig).toBeNull();
|
|
1892
|
+
});
|
|
1893
|
+
});
|
|
1894
|
+
|
|
1895
|
+
describe('TabManager - Cleanup', () => {
|
|
1896
|
+
let manager: TabManager;
|
|
1897
|
+
|
|
1898
|
+
beforeEach(async () => {
|
|
1899
|
+
manager = createManager();
|
|
1900
|
+
await manager.createTab();
|
|
1901
|
+
await manager.createTab();
|
|
1902
|
+
});
|
|
1903
|
+
|
|
1904
|
+
describe('destroy', () => {
|
|
1905
|
+
it('should destroy all tabs', async () => {
|
|
1906
|
+
await manager.destroy();
|
|
1907
|
+
|
|
1908
|
+
expect(mockDestroyTab).toHaveBeenCalledTimes(2);
|
|
1909
|
+
expect(manager.getTabCount()).toBe(0);
|
|
1910
|
+
});
|
|
1911
|
+
|
|
1912
|
+
it('should save all conversations before destroying', async () => {
|
|
1913
|
+
const tabs = manager.getAllTabs();
|
|
1914
|
+
const saveFns = tabs.map(tab => tab.controllers.conversationController?.save);
|
|
1915
|
+
|
|
1916
|
+
await manager.destroy();
|
|
1917
|
+
|
|
1918
|
+
saveFns.forEach(save => {
|
|
1919
|
+
expect(save).toHaveBeenCalled();
|
|
1920
|
+
});
|
|
1921
|
+
});
|
|
1922
|
+
|
|
1923
|
+
it('should clear active tab ID', async () => {
|
|
1924
|
+
expect(manager.getActiveTabId()).not.toBeNull();
|
|
1925
|
+
|
|
1926
|
+
await manager.destroy();
|
|
1927
|
+
|
|
1928
|
+
expect(manager.getActiveTabId()).toBeNull();
|
|
1929
|
+
});
|
|
1930
|
+
});
|
|
1931
|
+
});
|
|
1932
|
+
|
|
1933
|
+
describe('TabManager - Callback Wiring', () => {
|
|
1934
|
+
beforeEach(() => {
|
|
1935
|
+
jest.clearAllMocks();
|
|
1936
|
+
});
|
|
1937
|
+
|
|
1938
|
+
describe('ChatState callbacks during tab creation', () => {
|
|
1939
|
+
it('should wire onStreamingChanged callback to TabManager callbacks', async () => {
|
|
1940
|
+
const onTabStreamingChanged = jest.fn();
|
|
1941
|
+
const callbacks: TabManagerCallbacks = { onTabStreamingChanged };
|
|
1942
|
+
|
|
1943
|
+
let capturedCallbacks: any;
|
|
1944
|
+
mockCreateTab.mockImplementation((opts: any) => {
|
|
1945
|
+
capturedCallbacks = opts;
|
|
1946
|
+
return createMockTabData({ id: 'test-tab' });
|
|
1947
|
+
});
|
|
1948
|
+
|
|
1949
|
+
const manager = new TabManager(createMockPlugin(), createMockMcpManager(), createMockEl(), createMockView(), callbacks);
|
|
1950
|
+
await manager.createTab();
|
|
1951
|
+
|
|
1952
|
+
// Trigger the onStreamingChanged callback
|
|
1953
|
+
capturedCallbacks.onStreamingChanged(true);
|
|
1954
|
+
|
|
1955
|
+
expect(onTabStreamingChanged).toHaveBeenCalledWith('test-tab', true);
|
|
1956
|
+
});
|
|
1957
|
+
|
|
1958
|
+
it('should wire onTitleChanged callback to TabManager callbacks', async () => {
|
|
1959
|
+
const onTabTitleChanged = jest.fn();
|
|
1960
|
+
const callbacks: TabManagerCallbacks = { onTabTitleChanged };
|
|
1961
|
+
|
|
1962
|
+
let capturedCallbacks: any;
|
|
1963
|
+
mockCreateTab.mockImplementation((opts: any) => {
|
|
1964
|
+
capturedCallbacks = opts;
|
|
1965
|
+
return createMockTabData({ id: 'test-tab' });
|
|
1966
|
+
});
|
|
1967
|
+
|
|
1968
|
+
const manager = new TabManager(createMockPlugin(), createMockMcpManager(), createMockEl(), createMockView(), callbacks);
|
|
1969
|
+
await manager.createTab();
|
|
1970
|
+
|
|
1971
|
+
capturedCallbacks.onTitleChanged('New Title');
|
|
1972
|
+
|
|
1973
|
+
expect(onTabTitleChanged).toHaveBeenCalledWith('test-tab', 'New Title');
|
|
1974
|
+
});
|
|
1975
|
+
|
|
1976
|
+
it('should wire onAttentionChanged callback to TabManager callbacks', async () => {
|
|
1977
|
+
const onTabAttentionChanged = jest.fn();
|
|
1978
|
+
const callbacks: TabManagerCallbacks = { onTabAttentionChanged };
|
|
1979
|
+
|
|
1980
|
+
let capturedCallbacks: any;
|
|
1981
|
+
mockCreateTab.mockImplementation((opts: any) => {
|
|
1982
|
+
capturedCallbacks = opts;
|
|
1983
|
+
return createMockTabData({ id: 'test-tab' });
|
|
1984
|
+
});
|
|
1985
|
+
|
|
1986
|
+
const manager = new TabManager(createMockPlugin(), createMockMcpManager(), createMockEl(), createMockView(), callbacks);
|
|
1987
|
+
await manager.createTab();
|
|
1988
|
+
|
|
1989
|
+
capturedCallbacks.onAttentionChanged(true);
|
|
1990
|
+
|
|
1991
|
+
expect(onTabAttentionChanged).toHaveBeenCalledWith('test-tab', true);
|
|
1992
|
+
});
|
|
1993
|
+
|
|
1994
|
+
it('should wire onConversationIdChanged callback to sync tab conversationId', async () => {
|
|
1995
|
+
const onTabConversationChanged = jest.fn();
|
|
1996
|
+
const callbacks: TabManagerCallbacks = { onTabConversationChanged };
|
|
1997
|
+
|
|
1998
|
+
let capturedCallbacks: any;
|
|
1999
|
+
const tabData = createMockTabData({ id: 'test-tab', conversationId: null });
|
|
2000
|
+
mockCreateTab.mockImplementation((opts: any) => {
|
|
2001
|
+
capturedCallbacks = opts;
|
|
2002
|
+
return tabData;
|
|
2003
|
+
});
|
|
2004
|
+
|
|
2005
|
+
const manager = new TabManager(createMockPlugin(), createMockMcpManager(), createMockEl(), createMockView(), callbacks);
|
|
2006
|
+
await manager.createTab();
|
|
2007
|
+
|
|
2008
|
+
// Trigger the onConversationIdChanged callback (simulating conversation creation)
|
|
2009
|
+
capturedCallbacks.onConversationIdChanged('new-conv-id');
|
|
2010
|
+
|
|
2011
|
+
// Tab's conversationId should be synced
|
|
2012
|
+
expect(tabData.conversationId).toBe('new-conv-id');
|
|
2013
|
+
expect(onTabConversationChanged).toHaveBeenCalledWith('test-tab', 'new-conv-id');
|
|
2014
|
+
});
|
|
2015
|
+
});
|
|
2016
|
+
});
|
|
2017
|
+
|
|
2018
|
+
describe('TabManager - openConversation Current Tab Path', () => {
|
|
2019
|
+
let manager: TabManager;
|
|
2020
|
+
let plugin: any;
|
|
2021
|
+
|
|
2022
|
+
beforeEach(async () => {
|
|
2023
|
+
plugin = createMockPlugin();
|
|
2024
|
+
manager = createManager({ plugin });
|
|
2025
|
+
await manager.createTab();
|
|
2026
|
+
});
|
|
2027
|
+
|
|
2028
|
+
it('should open conversation in current tab when preferNewTab is false', async () => {
|
|
2029
|
+
const activeTab = manager.getActiveTab();
|
|
2030
|
+
const switchTo = jest.fn().mockResolvedValue(undefined);
|
|
2031
|
+
activeTab!.controllers.conversationController = { switchTo } as any;
|
|
2032
|
+
|
|
2033
|
+
plugin.getConversationById.mockResolvedValue({ id: 'conv-to-open' });
|
|
2034
|
+
|
|
2035
|
+
await manager.openConversation('conv-to-open', false);
|
|
2036
|
+
|
|
2037
|
+
expect(switchTo).toHaveBeenCalledWith('conv-to-open');
|
|
2038
|
+
});
|
|
2039
|
+
|
|
2040
|
+
it('should open conversation in current tab by default (preferNewTab defaults to false)', async () => {
|
|
2041
|
+
const activeTab = manager.getActiveTab();
|
|
2042
|
+
const switchTo = jest.fn().mockResolvedValue(undefined);
|
|
2043
|
+
activeTab!.controllers.conversationController = { switchTo } as any;
|
|
2044
|
+
|
|
2045
|
+
plugin.getConversationById.mockResolvedValue({ id: 'conv-default' });
|
|
2046
|
+
|
|
2047
|
+
await manager.openConversation('conv-default');
|
|
2048
|
+
|
|
2049
|
+
expect(switchTo).toHaveBeenCalledWith('conv-default');
|
|
2050
|
+
});
|
|
2051
|
+
|
|
2052
|
+
it('should not modify tab.conversationId directly (waits for callback)', async () => {
|
|
2053
|
+
const activeTab = manager.getActiveTab();
|
|
2054
|
+
const switchTo = jest.fn().mockResolvedValue(undefined);
|
|
2055
|
+
activeTab!.controllers.conversationController = { switchTo } as any;
|
|
2056
|
+
activeTab!.conversationId = null;
|
|
2057
|
+
|
|
2058
|
+
plugin.getConversationById.mockResolvedValue({ id: 'conv-123' });
|
|
2059
|
+
|
|
2060
|
+
await manager.openConversation('conv-123', false);
|
|
2061
|
+
|
|
2062
|
+
// conversationId should NOT be set by openConversation - it's synced via callback
|
|
2063
|
+
expect(activeTab!.conversationId).toBeNull();
|
|
2064
|
+
});
|
|
2065
|
+
|
|
2066
|
+
it('should not open in current tab if at max tabs and preferNewTab is true', async () => {
|
|
2067
|
+
for (let i = 0; i < DEFAULT_MAX_TABS - 1; i++) {
|
|
2068
|
+
await manager.createTab();
|
|
2069
|
+
}
|
|
2070
|
+
expect(manager.getTabCount()).toBe(DEFAULT_MAX_TABS);
|
|
2071
|
+
|
|
2072
|
+
const activeTab = manager.getActiveTab();
|
|
2073
|
+
const switchTo = jest.fn().mockResolvedValue(undefined);
|
|
2074
|
+
activeTab!.controllers.conversationController = { switchTo } as any;
|
|
2075
|
+
|
|
2076
|
+
plugin.getConversationById.mockResolvedValue({ id: 'conv-max' });
|
|
2077
|
+
|
|
2078
|
+
// preferNewTab=true but at max, so should open in current tab
|
|
2079
|
+
await manager.openConversation('conv-max', true);
|
|
2080
|
+
|
|
2081
|
+
expect(switchTo).toHaveBeenCalledWith('conv-max');
|
|
2082
|
+
});
|
|
2083
|
+
});
|
|
2084
|
+
|
|
2085
|
+
describe('TabManager - Service Initialization Errors', () => {
|
|
2086
|
+
it('should restore state without pre-warming any tabs', async () => {
|
|
2087
|
+
mockCreateTab.mockReturnValue(
|
|
2088
|
+
createMockTabData({ id: 'test-tab', serviceInitialized: false })
|
|
2089
|
+
);
|
|
2090
|
+
|
|
2091
|
+
const manager = new TabManager(
|
|
2092
|
+
createMockPlugin(),
|
|
2093
|
+
createMockMcpManager(),
|
|
2094
|
+
createMockEl(),
|
|
2095
|
+
createMockView()
|
|
2096
|
+
);
|
|
2097
|
+
|
|
2098
|
+
const persistedState: PersistedTabManagerState = {
|
|
2099
|
+
openTabs: [{ tabId: 'restored-tab', conversationId: null }],
|
|
2100
|
+
activeTabId: 'restored-tab',
|
|
2101
|
+
};
|
|
2102
|
+
|
|
2103
|
+
await manager.restoreState(persistedState);
|
|
2104
|
+
|
|
2105
|
+
// No pre-warm: all restored tabs stay cold until send
|
|
2106
|
+
expect(mockInitializeTabService).not.toHaveBeenCalled();
|
|
2107
|
+
});
|
|
2108
|
+
});
|
|
2109
|
+
|
|
2110
|
+
describe('TabManager - Concurrent Switch Guard', () => {
|
|
2111
|
+
it('should prevent concurrent tab switches', async () => {
|
|
2112
|
+
const callbacks: TabManagerCallbacks = {
|
|
2113
|
+
onTabSwitched: jest.fn(),
|
|
2114
|
+
};
|
|
2115
|
+
const manager = createManager({ callbacks });
|
|
2116
|
+
|
|
2117
|
+
const tab1 = await manager.createTab();
|
|
2118
|
+
const tab2 = await manager.createTab();
|
|
2119
|
+
|
|
2120
|
+
// Set up tab-1 to trigger the async conversationController.switchTo path
|
|
2121
|
+
// so that switchToTab hangs mid-execution with isSwitchingTab = true
|
|
2122
|
+
let resolveSwitchTo!: () => void;
|
|
2123
|
+
const hangingPromise = new Promise<void>(resolve => {
|
|
2124
|
+
resolveSwitchTo = resolve;
|
|
2125
|
+
});
|
|
2126
|
+
tab1!.conversationId = 'conv-1';
|
|
2127
|
+
tab1!.state.messages = [];
|
|
2128
|
+
tab1!.controllers.conversationController!.switchTo = jest.fn().mockReturnValue(hangingPromise);
|
|
2129
|
+
|
|
2130
|
+
jest.clearAllMocks();
|
|
2131
|
+
|
|
2132
|
+
// Start first switch to tab-1 (will hang on conversationController.switchTo)
|
|
2133
|
+
const firstSwitch = manager.switchToTab(tab1!.id);
|
|
2134
|
+
|
|
2135
|
+
// While first switch is in progress, try a second switch.
|
|
2136
|
+
// isSwitchingTab is true, so this should return immediately (lines 143-144)
|
|
2137
|
+
await manager.switchToTab(tab2!.id);
|
|
2138
|
+
|
|
2139
|
+
expect(mockDeactivateTab).toHaveBeenCalledTimes(1);
|
|
2140
|
+
expect(mockActivateTab).toHaveBeenCalledTimes(1);
|
|
2141
|
+
|
|
2142
|
+
// Resolve the hanging first switch
|
|
2143
|
+
resolveSwitchTo();
|
|
2144
|
+
await firstSwitch;
|
|
2145
|
+
|
|
2146
|
+
expect(callbacks.onTabSwitched).toHaveBeenCalledTimes(1);
|
|
2147
|
+
|
|
2148
|
+
// After first switch completes, isSwitchingTab is false
|
|
2149
|
+
// and subsequent switches should work normally
|
|
2150
|
+
await manager.switchToTab(tab2!.id);
|
|
2151
|
+
expect(callbacks.onTabSwitched).toHaveBeenCalledTimes(2);
|
|
2152
|
+
});
|
|
2153
|
+
});
|
|
2154
|
+
|
|
2155
|
+
describe('TabManager - closeTab Edge Cases', () => {
|
|
2156
|
+
it('should return false for non-existent tab', async () => {
|
|
2157
|
+
const manager = createManager();
|
|
2158
|
+
await manager.createTab();
|
|
2159
|
+
|
|
2160
|
+
const result = await manager.closeTab('non-existent-tab');
|
|
2161
|
+
expect(result).toBe(false);
|
|
2162
|
+
});
|
|
2163
|
+
|
|
2164
|
+
it('should not close last empty tab (preserves warm service)', async () => {
|
|
2165
|
+
const manager = createManager({
|
|
2166
|
+
tabFactory: () => createMockTabData({ id: 'only-tab' }),
|
|
2167
|
+
});
|
|
2168
|
+
await manager.createTab();
|
|
2169
|
+
|
|
2170
|
+
const result = await manager.closeTab('only-tab');
|
|
2171
|
+
expect(result).toBe(false);
|
|
2172
|
+
expect(manager.getTabCount()).toBe(1);
|
|
2173
|
+
});
|
|
2174
|
+
|
|
2175
|
+
it('should create new blank tab (stays cold) when closing the last tab with conversation', async () => {
|
|
2176
|
+
const callbacks: TabManagerCallbacks = {
|
|
2177
|
+
onTabCreated: jest.fn(),
|
|
2178
|
+
onTabClosed: jest.fn(),
|
|
2179
|
+
};
|
|
2180
|
+
|
|
2181
|
+
const manager = createManager({
|
|
2182
|
+
callbacks,
|
|
2183
|
+
tabFactory: (n) => createMockTabData({
|
|
2184
|
+
id: `tab-${n}`,
|
|
2185
|
+
conversationId: n === 1 ? 'conv-existing' : null,
|
|
2186
|
+
}),
|
|
2187
|
+
});
|
|
2188
|
+
await manager.createTab();
|
|
2189
|
+
|
|
2190
|
+
jest.clearAllMocks();
|
|
2191
|
+
|
|
2192
|
+
// Close the only tab (has conversationId so it bypasses the last-empty-tab guard)
|
|
2193
|
+
const result = await manager.closeTab('tab-1');
|
|
2194
|
+
|
|
2195
|
+
expect(result).toBe(true);
|
|
2196
|
+
expect(manager.getTabCount()).toBe(1); // New tab was created
|
|
2197
|
+
expect(mockCreateTab).toHaveBeenCalled();
|
|
2198
|
+
// No pre-warm: replacement blank tabs stay cold until send
|
|
2199
|
+
expect(mockInitializeTabService).not.toHaveBeenCalled();
|
|
2200
|
+
expect(callbacks.onTabClosed).toHaveBeenCalledWith('tab-1');
|
|
2201
|
+
});
|
|
2202
|
+
});
|
|
2203
|
+
|
|
2204
|
+
describe('TabManager - forkToNewTab', () => {
|
|
2205
|
+
it('should propagate currentNote from context to forked conversation', async () => {
|
|
2206
|
+
const mockCreateConversation = jest.fn().mockResolvedValue({ id: 'fork-conv-1', providerId: 'claude' });
|
|
2207
|
+
const mockUpdateConversation = jest.fn().mockResolvedValue(undefined);
|
|
2208
|
+
|
|
2209
|
+
const plugin = createMockPlugin({
|
|
2210
|
+
createConversation: mockCreateConversation,
|
|
2211
|
+
updateConversation: mockUpdateConversation,
|
|
2212
|
+
});
|
|
2213
|
+
|
|
2214
|
+
const manager = createManager({ plugin });
|
|
2215
|
+
await manager.createTab();
|
|
2216
|
+
|
|
2217
|
+
await manager.forkToNewTab({
|
|
2218
|
+
messages: [
|
|
2219
|
+
{ id: 'msg-1', role: 'user', content: 'hello', timestamp: 1 },
|
|
2220
|
+
{ id: 'msg-2', role: 'assistant', content: 'hi', timestamp: 2 },
|
|
2221
|
+
] as any,
|
|
2222
|
+
sourceSessionId: 'session-1',
|
|
2223
|
+
resumeAt: 'assistant-uuid-1',
|
|
2224
|
+
currentNote: 'notes/test.md',
|
|
2225
|
+
});
|
|
2226
|
+
|
|
2227
|
+
expect(mockUpdateConversation).toHaveBeenCalledWith('fork-conv-1', expect.objectContaining({
|
|
2228
|
+
currentNote: 'notes/test.md',
|
|
2229
|
+
}));
|
|
2230
|
+
});
|
|
2231
|
+
|
|
2232
|
+
it('should not set currentNote when context has none', async () => {
|
|
2233
|
+
const mockCreateConversation = jest.fn().mockResolvedValue({ id: 'fork-conv-2', providerId: 'claude' });
|
|
2234
|
+
const mockUpdateConversation = jest.fn().mockResolvedValue(undefined);
|
|
2235
|
+
|
|
2236
|
+
const plugin = createMockPlugin({
|
|
2237
|
+
createConversation: mockCreateConversation,
|
|
2238
|
+
updateConversation: mockUpdateConversation,
|
|
2239
|
+
});
|
|
2240
|
+
|
|
2241
|
+
const manager = createManager({ plugin });
|
|
2242
|
+
await manager.createTab();
|
|
2243
|
+
|
|
2244
|
+
await manager.forkToNewTab({
|
|
2245
|
+
messages: [
|
|
2246
|
+
{ id: 'msg-1', role: 'user', content: 'hello', timestamp: 1 },
|
|
2247
|
+
{ id: 'msg-2', role: 'assistant', content: 'hi', timestamp: 2 },
|
|
2248
|
+
] as any,
|
|
2249
|
+
sourceSessionId: 'session-1',
|
|
2250
|
+
resumeAt: 'assistant-uuid-1',
|
|
2251
|
+
});
|
|
2252
|
+
|
|
2253
|
+
const updateCall = mockUpdateConversation.mock.calls[0][1];
|
|
2254
|
+
expect(updateCall.currentNote).toBeUndefined();
|
|
2255
|
+
});
|
|
2256
|
+
});
|
|
2257
|
+
|
|
2258
|
+
describe('TabManager - forkInCurrentTab', () => {
|
|
2259
|
+
it('should create fork conversation and switch active tab to it', async () => {
|
|
2260
|
+
const mockCreateConversation = jest.fn().mockResolvedValue({ id: 'fork-conv-1', providerId: 'claude' });
|
|
2261
|
+
const mockUpdateConversation = jest.fn().mockResolvedValue(undefined);
|
|
2262
|
+
const mockSwitchTo = jest.fn().mockResolvedValue(undefined);
|
|
2263
|
+
|
|
2264
|
+
const plugin = createMockPlugin({
|
|
2265
|
+
createConversation: mockCreateConversation,
|
|
2266
|
+
updateConversation: mockUpdateConversation,
|
|
2267
|
+
});
|
|
2268
|
+
|
|
2269
|
+
let tabCounter = 0;
|
|
2270
|
+
mockCreateTab.mockImplementation(() => {
|
|
2271
|
+
tabCounter++;
|
|
2272
|
+
return createMockTabData({
|
|
2273
|
+
id: `tab-${tabCounter}`,
|
|
2274
|
+
controllers: {
|
|
2275
|
+
conversationController: {
|
|
2276
|
+
save: jest.fn().mockResolvedValue(undefined),
|
|
2277
|
+
switchTo: mockSwitchTo,
|
|
2278
|
+
initializeWelcome: jest.fn(),
|
|
2279
|
+
},
|
|
2280
|
+
inputController: { handleApprovalRequest: jest.fn() },
|
|
2281
|
+
},
|
|
2282
|
+
});
|
|
2283
|
+
});
|
|
2284
|
+
|
|
2285
|
+
const manager = new TabManager(
|
|
2286
|
+
plugin,
|
|
2287
|
+
createMockMcpManager(),
|
|
2288
|
+
createMockEl(),
|
|
2289
|
+
createMockView()
|
|
2290
|
+
);
|
|
2291
|
+
await manager.createTab();
|
|
2292
|
+
|
|
2293
|
+
const success = await manager.forkInCurrentTab({
|
|
2294
|
+
messages: [{ id: 'msg-1', role: 'user', content: 'hello', timestamp: 1 }] as any,
|
|
2295
|
+
sourceSessionId: 'session-1',
|
|
2296
|
+
resumeAt: 'assistant-uuid-1',
|
|
2297
|
+
currentNote: 'notes/test.md',
|
|
2298
|
+
sourceTitle: 'My Chat',
|
|
2299
|
+
forkAtUserMessage: 1,
|
|
2300
|
+
});
|
|
2301
|
+
|
|
2302
|
+
expect(success).toBe(true);
|
|
2303
|
+
expect(mockCreateConversation).toHaveBeenCalled();
|
|
2304
|
+
expect(mockUpdateConversation).toHaveBeenCalledWith('fork-conv-1', expect.objectContaining({
|
|
2305
|
+
providerState: { forkSource: { sessionId: 'session-1', resumeAt: 'assistant-uuid-1' } },
|
|
2306
|
+
currentNote: 'notes/test.md',
|
|
2307
|
+
}));
|
|
2308
|
+
expect(mockSwitchTo).toHaveBeenCalledWith('fork-conv-1');
|
|
2309
|
+
});
|
|
2310
|
+
|
|
2311
|
+
it('should return false when no active tab exists', async () => {
|
|
2312
|
+
const plugin = createMockPlugin({
|
|
2313
|
+
createConversation: jest.fn().mockResolvedValue({ id: 'fork-conv-2', providerId: 'claude' }),
|
|
2314
|
+
updateConversation: jest.fn().mockResolvedValue(undefined),
|
|
2315
|
+
});
|
|
2316
|
+
|
|
2317
|
+
const manager = createManager({ plugin });
|
|
2318
|
+
// Don't create any tabs
|
|
2319
|
+
|
|
2320
|
+
const success = await manager.forkInCurrentTab({
|
|
2321
|
+
messages: [] as any,
|
|
2322
|
+
sourceSessionId: 'session-1',
|
|
2323
|
+
resumeAt: 'assistant-uuid-1',
|
|
2324
|
+
});
|
|
2325
|
+
|
|
2326
|
+
expect(success).toBe(false);
|
|
2327
|
+
});
|
|
2328
|
+
|
|
2329
|
+
it('should not check tab count limit', async () => {
|
|
2330
|
+
const mockCreateConversation = jest.fn().mockResolvedValue({ id: 'fork-conv-3', providerId: 'claude' });
|
|
2331
|
+
const mockUpdateConversation = jest.fn().mockResolvedValue(undefined);
|
|
2332
|
+
const mockSwitchTo = jest.fn().mockResolvedValue(undefined);
|
|
2333
|
+
|
|
2334
|
+
const plugin = createMockPlugin({
|
|
2335
|
+
createConversation: mockCreateConversation,
|
|
2336
|
+
updateConversation: mockUpdateConversation,
|
|
2337
|
+
settings: { maxTabs: 3 },
|
|
2338
|
+
});
|
|
2339
|
+
|
|
2340
|
+
let tabCounter = 0;
|
|
2341
|
+
mockCreateTab.mockImplementation(() => {
|
|
2342
|
+
tabCounter++;
|
|
2343
|
+
return createMockTabData({
|
|
2344
|
+
id: `tab-${tabCounter}`,
|
|
2345
|
+
controllers: {
|
|
2346
|
+
conversationController: {
|
|
2347
|
+
save: jest.fn().mockResolvedValue(undefined),
|
|
2348
|
+
switchTo: mockSwitchTo,
|
|
2349
|
+
initializeWelcome: jest.fn(),
|
|
2350
|
+
},
|
|
2351
|
+
inputController: { handleApprovalRequest: jest.fn() },
|
|
2352
|
+
},
|
|
2353
|
+
});
|
|
2354
|
+
});
|
|
2355
|
+
|
|
2356
|
+
const manager = new TabManager(
|
|
2357
|
+
plugin,
|
|
2358
|
+
createMockMcpManager(),
|
|
2359
|
+
createMockEl(),
|
|
2360
|
+
createMockView()
|
|
2361
|
+
);
|
|
2362
|
+
|
|
2363
|
+
// Fill all tabs to max
|
|
2364
|
+
await manager.createTab();
|
|
2365
|
+
await manager.createTab();
|
|
2366
|
+
await manager.createTab();
|
|
2367
|
+
|
|
2368
|
+
// forkInCurrentTab should still work even at max tabs
|
|
2369
|
+
const success = await manager.forkInCurrentTab({
|
|
2370
|
+
messages: [{ id: 'msg-1', role: 'user', content: 'hello', timestamp: 1 }] as any,
|
|
2371
|
+
sourceSessionId: 'session-1',
|
|
2372
|
+
resumeAt: 'assistant-uuid-1',
|
|
2373
|
+
});
|
|
2374
|
+
|
|
2375
|
+
expect(success).toBe(true);
|
|
2376
|
+
expect(mockSwitchTo).toHaveBeenCalled();
|
|
2377
|
+
});
|
|
2378
|
+
});
|
|
2379
|
+
|
|
2380
|
+
describe('TabManager - switchToTab Session Sync', () => {
|
|
2381
|
+
it('should sync service session for already-loaded tab with conversation', async () => {
|
|
2382
|
+
jest.clearAllMocks();
|
|
2383
|
+
|
|
2384
|
+
const mockSyncConversationState = jest.fn();
|
|
2385
|
+
const mockService = {
|
|
2386
|
+
syncConversationState: mockSyncConversationState,
|
|
2387
|
+
cleanup: jest.fn(),
|
|
2388
|
+
ensureReady: jest.fn().mockResolvedValue(true),
|
|
2389
|
+
onReadyStateChange: jest.fn(() => () => {}),
|
|
2390
|
+
isReady: jest.fn().mockReturnValue(true),
|
|
2391
|
+
};
|
|
2392
|
+
|
|
2393
|
+
let tabCounter = 0;
|
|
2394
|
+
mockCreateTab.mockImplementation(() => {
|
|
2395
|
+
tabCounter++;
|
|
2396
|
+
const tab = createMockTabData({
|
|
2397
|
+
id: `tab-${tabCounter}`,
|
|
2398
|
+
conversationId: tabCounter === 2 ? 'conv-loaded' : null,
|
|
2399
|
+
service: tabCounter === 2 ? mockService : null,
|
|
2400
|
+
serviceInitialized: tabCounter === 2,
|
|
2401
|
+
});
|
|
2402
|
+
// For tab-2, simulate already having messages loaded
|
|
2403
|
+
if (tabCounter === 2) {
|
|
2404
|
+
tab.state.messages = [{ id: 'msg-1', role: 'user', content: 'test' }] as any;
|
|
2405
|
+
}
|
|
2406
|
+
return tab;
|
|
2407
|
+
});
|
|
2408
|
+
|
|
2409
|
+
const plugin = createMockPlugin();
|
|
2410
|
+
plugin.getConversationSync = jest.fn().mockReturnValue({
|
|
2411
|
+
id: 'conv-loaded',
|
|
2412
|
+
messages: [{ id: 'msg-1', role: 'user', content: 'test' }],
|
|
2413
|
+
sessionId: 'session-xyz',
|
|
2414
|
+
externalContextPaths: ['/some/path'],
|
|
2415
|
+
});
|
|
2416
|
+
|
|
2417
|
+
const manager = new TabManager(
|
|
2418
|
+
plugin,
|
|
2419
|
+
createMockMcpManager(),
|
|
2420
|
+
createMockEl(),
|
|
2421
|
+
createMockView()
|
|
2422
|
+
);
|
|
2423
|
+
|
|
2424
|
+
await manager.createTab(); // tab-1, active
|
|
2425
|
+
await manager.createTab(); // tab-2, auto-switches and triggers session sync
|
|
2426
|
+
|
|
2427
|
+
// Should have synced the service session during auto-switch to tab-2
|
|
2428
|
+
expect(mockSyncConversationState).toHaveBeenCalledWith(
|
|
2429
|
+
expect.objectContaining({ id: 'conv-loaded', sessionId: 'session-xyz' }),
|
|
2430
|
+
['/some/path'],
|
|
2431
|
+
);
|
|
2432
|
+
});
|
|
2433
|
+
|
|
2434
|
+
it('should use persistentExternalContextPaths when conversation has no messages', async () => {
|
|
2435
|
+
jest.clearAllMocks();
|
|
2436
|
+
|
|
2437
|
+
const mockSyncConversationState = jest.fn();
|
|
2438
|
+
const mockService = {
|
|
2439
|
+
syncConversationState: mockSyncConversationState,
|
|
2440
|
+
};
|
|
2441
|
+
|
|
2442
|
+
let tabCounter = 0;
|
|
2443
|
+
mockCreateTab.mockImplementation(() => {
|
|
2444
|
+
tabCounter++;
|
|
2445
|
+
const tab = createMockTabData({
|
|
2446
|
+
id: `tab-${tabCounter}`,
|
|
2447
|
+
conversationId: tabCounter === 2 ? 'conv-empty' : null,
|
|
2448
|
+
service: tabCounter === 2 ? mockService : null,
|
|
2449
|
+
serviceInitialized: tabCounter === 2,
|
|
2450
|
+
});
|
|
2451
|
+
// Tab has local messages but the persisted conversation does not
|
|
2452
|
+
if (tabCounter === 2) {
|
|
2453
|
+
tab.state.messages = [{ id: 'msg-1', role: 'user', content: 'test' }] as any;
|
|
2454
|
+
}
|
|
2455
|
+
return tab;
|
|
2456
|
+
});
|
|
2457
|
+
|
|
2458
|
+
const plugin = createMockPlugin({
|
|
2459
|
+
settings: {
|
|
2460
|
+
maxTabs: DEFAULT_MAX_TABS,
|
|
2461
|
+
persistentExternalContextPaths: ['/persistent/path'],
|
|
2462
|
+
},
|
|
2463
|
+
});
|
|
2464
|
+
plugin.getConversationSync = jest.fn().mockReturnValue({
|
|
2465
|
+
id: 'conv-empty',
|
|
2466
|
+
messages: [],
|
|
2467
|
+
sessionId: 'session-abc',
|
|
2468
|
+
externalContextPaths: [],
|
|
2469
|
+
});
|
|
2470
|
+
|
|
2471
|
+
const manager = new TabManager(
|
|
2472
|
+
plugin,
|
|
2473
|
+
createMockMcpManager(),
|
|
2474
|
+
createMockEl(),
|
|
2475
|
+
createMockView()
|
|
2476
|
+
);
|
|
2477
|
+
|
|
2478
|
+
await manager.createTab(); // tab-1
|
|
2479
|
+
await manager.createTab(); // tab-2, auto-switches and triggers session sync
|
|
2480
|
+
|
|
2481
|
+
// conversation.messages is empty, so should fall back to persistentExternalContextPaths
|
|
2482
|
+
expect(mockSyncConversationState).toHaveBeenCalledWith(
|
|
2483
|
+
expect.objectContaining({ id: 'conv-empty', sessionId: 'session-abc' }),
|
|
2484
|
+
['/persistent/path'],
|
|
2485
|
+
);
|
|
2486
|
+
});
|
|
2487
|
+
|
|
2488
|
+
it('should not sync service session for an already-loaded streaming tab', async () => {
|
|
2489
|
+
jest.clearAllMocks();
|
|
2490
|
+
|
|
2491
|
+
const mockSyncConversationState = jest.fn();
|
|
2492
|
+
const mockService = {
|
|
2493
|
+
syncConversationState: mockSyncConversationState,
|
|
2494
|
+
};
|
|
2495
|
+
|
|
2496
|
+
let tabCounter = 0;
|
|
2497
|
+
mockCreateTab.mockImplementation(() => {
|
|
2498
|
+
tabCounter++;
|
|
2499
|
+
|
|
2500
|
+
if (tabCounter === 1) {
|
|
2501
|
+
return createMockTabData({ id: 'tab-1' });
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
return createMockTabData({
|
|
2505
|
+
id: 'tab-2',
|
|
2506
|
+
conversationId: 'conv-streaming',
|
|
2507
|
+
service: mockService,
|
|
2508
|
+
serviceInitialized: true,
|
|
2509
|
+
state: {
|
|
2510
|
+
isStreaming: true,
|
|
2511
|
+
messages: [{ id: 'msg-1', role: 'user', content: 'test' }],
|
|
2512
|
+
},
|
|
2513
|
+
});
|
|
2514
|
+
});
|
|
2515
|
+
|
|
2516
|
+
const plugin = createMockPlugin();
|
|
2517
|
+
plugin.getConversationSync = jest.fn().mockReturnValue({
|
|
2518
|
+
id: 'conv-streaming',
|
|
2519
|
+
messages: [{ id: 'msg-1', role: 'user', content: 'test' }],
|
|
2520
|
+
sessionId: 'session-stream',
|
|
2521
|
+
externalContextPaths: ['/some/path'],
|
|
2522
|
+
});
|
|
2523
|
+
|
|
2524
|
+
const manager = new TabManager(
|
|
2525
|
+
plugin,
|
|
2526
|
+
createMockMcpManager(),
|
|
2527
|
+
createMockEl(),
|
|
2528
|
+
createMockView()
|
|
2529
|
+
);
|
|
2530
|
+
|
|
2531
|
+
await manager.createTab();
|
|
2532
|
+
const backgroundStreamingTab = await manager.createTab(undefined, undefined, { activate: false });
|
|
2533
|
+
|
|
2534
|
+
jest.clearAllMocks();
|
|
2535
|
+
|
|
2536
|
+
await manager.switchToTab(backgroundStreamingTab!.id);
|
|
2537
|
+
|
|
2538
|
+
expect(plugin.getConversationSync).not.toHaveBeenCalled();
|
|
2539
|
+
expect(mockSyncConversationState).not.toHaveBeenCalled();
|
|
2540
|
+
});
|
|
2541
|
+
|
|
2542
|
+
it('should not sync service session when local conversation state is pending save', async () => {
|
|
2543
|
+
jest.clearAllMocks();
|
|
2544
|
+
|
|
2545
|
+
const mockSyncConversationState = jest.fn();
|
|
2546
|
+
const mockService = {
|
|
2547
|
+
syncConversationState: mockSyncConversationState,
|
|
2548
|
+
};
|
|
2549
|
+
|
|
2550
|
+
let tabCounter = 0;
|
|
2551
|
+
mockCreateTab.mockImplementation(() => {
|
|
2552
|
+
tabCounter++;
|
|
2553
|
+
|
|
2554
|
+
if (tabCounter === 1) {
|
|
2555
|
+
return createMockTabData({ id: 'tab-1' });
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
return createMockTabData({
|
|
2559
|
+
id: 'tab-2',
|
|
2560
|
+
conversationId: 'conv-pending-save',
|
|
2561
|
+
service: mockService,
|
|
2562
|
+
serviceInitialized: true,
|
|
2563
|
+
state: {
|
|
2564
|
+
hasPendingConversationSave: true,
|
|
2565
|
+
messages: [{ id: 'msg-1', role: 'user', content: 'test' }],
|
|
2566
|
+
},
|
|
2567
|
+
});
|
|
2568
|
+
});
|
|
2569
|
+
|
|
2570
|
+
const plugin = createMockPlugin();
|
|
2571
|
+
plugin.getConversationSync = jest.fn().mockReturnValue({
|
|
2572
|
+
id: 'conv-pending-save',
|
|
2573
|
+
messages: [],
|
|
2574
|
+
sessionId: null,
|
|
2575
|
+
externalContextPaths: [],
|
|
2576
|
+
});
|
|
2577
|
+
|
|
2578
|
+
const manager = new TabManager(
|
|
2579
|
+
plugin,
|
|
2580
|
+
createMockMcpManager(),
|
|
2581
|
+
createMockEl(),
|
|
2582
|
+
createMockView()
|
|
2583
|
+
);
|
|
2584
|
+
|
|
2585
|
+
await manager.createTab();
|
|
2586
|
+
const pendingSaveTab = await manager.createTab(undefined, undefined, { activate: false });
|
|
2587
|
+
|
|
2588
|
+
jest.clearAllMocks();
|
|
2589
|
+
|
|
2590
|
+
await manager.switchToTab(pendingSaveTab!.id);
|
|
2591
|
+
|
|
2592
|
+
expect(plugin.getConversationSync).not.toHaveBeenCalled();
|
|
2593
|
+
expect(mockSyncConversationState).not.toHaveBeenCalled();
|
|
2594
|
+
});
|
|
2595
|
+
|
|
2596
|
+
it('should initialize welcome for new tab without conversation', async () => {
|
|
2597
|
+
jest.clearAllMocks();
|
|
2598
|
+
|
|
2599
|
+
const mockInitializeWelcome = jest.fn();
|
|
2600
|
+
let tabCounter = 0;
|
|
2601
|
+
mockCreateTab.mockImplementation(() => {
|
|
2602
|
+
tabCounter++;
|
|
2603
|
+
const tab = createMockTabData({ id: `tab-${tabCounter}` });
|
|
2604
|
+
tab.controllers.conversationController = {
|
|
2605
|
+
...tab.controllers.conversationController,
|
|
2606
|
+
initializeWelcome: mockInitializeWelcome,
|
|
2607
|
+
};
|
|
2608
|
+
return tab;
|
|
2609
|
+
});
|
|
2610
|
+
|
|
2611
|
+
const manager = new TabManager(
|
|
2612
|
+
createMockPlugin(),
|
|
2613
|
+
createMockMcpManager(),
|
|
2614
|
+
createMockEl(),
|
|
2615
|
+
createMockView()
|
|
2616
|
+
);
|
|
2617
|
+
|
|
2618
|
+
await manager.createTab(); // tab-1
|
|
2619
|
+
await manager.createTab(); // tab-2 (now active)
|
|
2620
|
+
|
|
2621
|
+
// Switch to tab-1 first so we can switch back to tab-2
|
|
2622
|
+
await manager.switchToTab('tab-1');
|
|
2623
|
+
mockInitializeWelcome.mockClear();
|
|
2624
|
+
|
|
2625
|
+
// Switch to tab-2 (no conversationId, no messages -> should call initializeWelcome)
|
|
2626
|
+
await manager.switchToTab('tab-2');
|
|
2627
|
+
|
|
2628
|
+
expect(mockInitializeWelcome).toHaveBeenCalled();
|
|
2629
|
+
});
|
|
2630
|
+
});
|
|
2631
|
+
|
|
2632
|
+
describe('TabManager - handleForkRequest (modal dispatch)', () => {
|
|
2633
|
+
it('should fork to new tab when user selects "new-tab"', async () => {
|
|
2634
|
+
mockChooseForkTarget.mockResolvedValue('new-tab');
|
|
2635
|
+
|
|
2636
|
+
const mockCreateConversation = jest.fn().mockResolvedValue({ id: 'fork-conv-1', providerId: 'claude' });
|
|
2637
|
+
const mockUpdateConversation = jest.fn().mockResolvedValue(undefined);
|
|
2638
|
+
|
|
2639
|
+
const plugin = createMockPlugin({
|
|
2640
|
+
createConversation: mockCreateConversation,
|
|
2641
|
+
updateConversation: mockUpdateConversation,
|
|
2642
|
+
});
|
|
2643
|
+
|
|
2644
|
+
let capturedForkCallback: any;
|
|
2645
|
+
mockInitializeTabControllers.mockImplementation(
|
|
2646
|
+
(_tab: any, _plugin: any, _view: any, forkCb: any) => {
|
|
2647
|
+
capturedForkCallback = forkCb;
|
|
2648
|
+
}
|
|
2649
|
+
);
|
|
2650
|
+
|
|
2651
|
+
const manager = createManager({ plugin });
|
|
2652
|
+
await manager.createTab();
|
|
2653
|
+
|
|
2654
|
+
// Invoke the fork callback that was passed to initializeTabControllers
|
|
2655
|
+
await capturedForkCallback({
|
|
2656
|
+
messages: [{ id: 'msg-1', role: 'user', content: 'hello', timestamp: 1 }],
|
|
2657
|
+
sourceSessionId: 'session-1',
|
|
2658
|
+
resumeAt: 'asst-uuid-1',
|
|
2659
|
+
sourceTitle: 'Test Chat',
|
|
2660
|
+
forkAtUserMessage: 1,
|
|
2661
|
+
});
|
|
2662
|
+
|
|
2663
|
+
expect(mockChooseForkTarget).toHaveBeenCalled();
|
|
2664
|
+
expect(mockCreateConversation).toHaveBeenCalled();
|
|
2665
|
+
expect(mockUpdateConversation).toHaveBeenCalled();
|
|
2666
|
+
});
|
|
2667
|
+
|
|
2668
|
+
it('should fork in current tab when user selects "current-tab"', async () => {
|
|
2669
|
+
mockChooseForkTarget.mockResolvedValue('current-tab');
|
|
2670
|
+
|
|
2671
|
+
const mockCreateConversation = jest.fn().mockResolvedValue({ id: 'fork-conv-2', providerId: 'claude' });
|
|
2672
|
+
const mockUpdateConversation = jest.fn().mockResolvedValue(undefined);
|
|
2673
|
+
const mockSwitchTo = jest.fn().mockResolvedValue(undefined);
|
|
2674
|
+
|
|
2675
|
+
const plugin = createMockPlugin({
|
|
2676
|
+
createConversation: mockCreateConversation,
|
|
2677
|
+
updateConversation: mockUpdateConversation,
|
|
2678
|
+
});
|
|
2679
|
+
|
|
2680
|
+
let capturedForkCallback: any;
|
|
2681
|
+
let tabCounter = 0;
|
|
2682
|
+
mockCreateTab.mockImplementation(() => {
|
|
2683
|
+
tabCounter++;
|
|
2684
|
+
return createMockTabData({
|
|
2685
|
+
id: `tab-${tabCounter}`,
|
|
2686
|
+
controllers: {
|
|
2687
|
+
conversationController: {
|
|
2688
|
+
save: jest.fn().mockResolvedValue(undefined),
|
|
2689
|
+
switchTo: mockSwitchTo,
|
|
2690
|
+
initializeWelcome: jest.fn(),
|
|
2691
|
+
},
|
|
2692
|
+
inputController: { handleApprovalRequest: jest.fn() },
|
|
2693
|
+
},
|
|
2694
|
+
});
|
|
2695
|
+
});
|
|
2696
|
+
mockInitializeTabControllers.mockImplementation(
|
|
2697
|
+
(_tab: any, _plugin: any, _view: any, forkCb: any) => {
|
|
2698
|
+
capturedForkCallback = forkCb;
|
|
2699
|
+
}
|
|
2700
|
+
);
|
|
2701
|
+
|
|
2702
|
+
const manager = new TabManager(
|
|
2703
|
+
plugin,
|
|
2704
|
+
createMockMcpManager(),
|
|
2705
|
+
createMockEl(),
|
|
2706
|
+
createMockView()
|
|
2707
|
+
);
|
|
2708
|
+
await manager.createTab();
|
|
2709
|
+
|
|
2710
|
+
await capturedForkCallback({
|
|
2711
|
+
messages: [],
|
|
2712
|
+
sourceSessionId: 'session-1',
|
|
2713
|
+
resumeAt: 'asst-uuid-1',
|
|
2714
|
+
});
|
|
2715
|
+
|
|
2716
|
+
expect(mockChooseForkTarget).toHaveBeenCalled();
|
|
2717
|
+
expect(mockSwitchTo).toHaveBeenCalledWith('fork-conv-2');
|
|
2718
|
+
});
|
|
2719
|
+
|
|
2720
|
+
it('should do nothing when user cancels modal', async () => {
|
|
2721
|
+
mockChooseForkTarget.mockResolvedValue(null);
|
|
2722
|
+
|
|
2723
|
+
const mockCreateConversation = jest.fn();
|
|
2724
|
+
const plugin = createMockPlugin({ createConversation: mockCreateConversation });
|
|
2725
|
+
|
|
2726
|
+
let capturedForkCallback: any;
|
|
2727
|
+
mockInitializeTabControllers.mockImplementation(
|
|
2728
|
+
(_tab: any, _plugin: any, _view: any, forkCb: any) => {
|
|
2729
|
+
capturedForkCallback = forkCb;
|
|
2730
|
+
}
|
|
2731
|
+
);
|
|
2732
|
+
|
|
2733
|
+
const manager = createManager({ plugin });
|
|
2734
|
+
await manager.createTab();
|
|
2735
|
+
|
|
2736
|
+
await capturedForkCallback({
|
|
2737
|
+
messages: [],
|
|
2738
|
+
sourceSessionId: 'session-1',
|
|
2739
|
+
resumeAt: 'asst-uuid-1',
|
|
2740
|
+
});
|
|
2741
|
+
|
|
2742
|
+
expect(mockChooseForkTarget).toHaveBeenCalled();
|
|
2743
|
+
expect(mockCreateConversation).not.toHaveBeenCalled();
|
|
2744
|
+
});
|
|
2745
|
+
});
|
|
2746
|
+
|
|
2747
|
+
describe('TabManager - forkToNewTab at max tabs', () => {
|
|
2748
|
+
it('should return null when at max tabs', async () => {
|
|
2749
|
+
jest.clearAllMocks();
|
|
2750
|
+
|
|
2751
|
+
const plugin = createMockPlugin();
|
|
2752
|
+
// MIN_TABS is 3, so maxTabs must be >= 3 to avoid clamping
|
|
2753
|
+
plugin.settings.maxTabs = 3;
|
|
2754
|
+
plugin.createConversation = jest.fn().mockResolvedValue({ id: 'fork-conv', providerId: 'claude' });
|
|
2755
|
+
plugin.updateConversation = jest.fn().mockResolvedValue(undefined);
|
|
2756
|
+
|
|
2757
|
+
let tabCounter = 0;
|
|
2758
|
+
mockCreateTab.mockImplementation(() => {
|
|
2759
|
+
tabCounter++;
|
|
2760
|
+
return createMockTabData({ id: `tab-${tabCounter}` });
|
|
2761
|
+
});
|
|
2762
|
+
|
|
2763
|
+
const manager = new TabManager(
|
|
2764
|
+
plugin,
|
|
2765
|
+
createMockMcpManager(),
|
|
2766
|
+
createMockEl(),
|
|
2767
|
+
createMockView()
|
|
2768
|
+
);
|
|
2769
|
+
|
|
2770
|
+
await manager.createTab();
|
|
2771
|
+
await manager.createTab();
|
|
2772
|
+
await manager.createTab();
|
|
2773
|
+
expect(manager.getTabCount()).toBe(3);
|
|
2774
|
+
|
|
2775
|
+
const result = await manager.forkToNewTab({
|
|
2776
|
+
messages: [],
|
|
2777
|
+
sourceSessionId: 'session-1',
|
|
2778
|
+
resumeAt: 'asst-uuid',
|
|
2779
|
+
});
|
|
2780
|
+
|
|
2781
|
+
expect(result).toBeNull();
|
|
2782
|
+
});
|
|
2783
|
+
});
|
|
2784
|
+
|
|
2785
|
+
describe('TabManager - createForkConversation', () => {
|
|
2786
|
+
it('should set forkSource with sessionId and resumeAt', async () => {
|
|
2787
|
+
const mockCreateConversation = jest.fn().mockResolvedValue({ id: 'fork-conv-1', providerId: 'claude' });
|
|
2788
|
+
const mockUpdateConversation = jest.fn().mockResolvedValue(undefined);
|
|
2789
|
+
|
|
2790
|
+
const plugin = createMockPlugin({
|
|
2791
|
+
createConversation: mockCreateConversation,
|
|
2792
|
+
updateConversation: mockUpdateConversation,
|
|
2793
|
+
});
|
|
2794
|
+
|
|
2795
|
+
const manager = createManager({ plugin });
|
|
2796
|
+
await manager.createTab();
|
|
2797
|
+
|
|
2798
|
+
await manager.forkToNewTab({
|
|
2799
|
+
messages: [],
|
|
2800
|
+
sourceSessionId: 'session-abc',
|
|
2801
|
+
resumeAt: 'asst-uuid-xyz',
|
|
2802
|
+
});
|
|
2803
|
+
|
|
2804
|
+
expect(mockUpdateConversation).toHaveBeenCalledWith('fork-conv-1', expect.objectContaining({
|
|
2805
|
+
providerState: { forkSource: { sessionId: 'session-abc', resumeAt: 'asst-uuid-xyz' } },
|
|
2806
|
+
}));
|
|
2807
|
+
});
|
|
2808
|
+
|
|
2809
|
+
it('should create the fork conversation with the source provider', async () => {
|
|
2810
|
+
const mockCreateConversation = jest.fn().mockResolvedValue({ id: 'fork-conv-codex', providerId: 'codex' });
|
|
2811
|
+
const mockUpdateConversation = jest.fn().mockResolvedValue(undefined);
|
|
2812
|
+
|
|
2813
|
+
const plugin = createMockPlugin({
|
|
2814
|
+
createConversation: mockCreateConversation,
|
|
2815
|
+
updateConversation: mockUpdateConversation,
|
|
2816
|
+
});
|
|
2817
|
+
|
|
2818
|
+
const manager = createManager({ plugin });
|
|
2819
|
+
await manager.createTab();
|
|
2820
|
+
|
|
2821
|
+
await manager.forkToNewTab({
|
|
2822
|
+
messages: [],
|
|
2823
|
+
providerId: 'codex',
|
|
2824
|
+
sourceSessionId: 'session-codex',
|
|
2825
|
+
resumeAt: 'asst-codex',
|
|
2826
|
+
});
|
|
2827
|
+
|
|
2828
|
+
expect(mockCreateConversation).toHaveBeenCalledWith({ providerId: 'codex' });
|
|
2829
|
+
});
|
|
2830
|
+
|
|
2831
|
+
it('should not set title when sourceTitle is undefined', async () => {
|
|
2832
|
+
const mockCreateConversation = jest.fn().mockResolvedValue({ id: 'fork-conv-1', providerId: 'claude' });
|
|
2833
|
+
const mockUpdateConversation = jest.fn().mockResolvedValue(undefined);
|
|
2834
|
+
|
|
2835
|
+
const plugin = createMockPlugin({
|
|
2836
|
+
createConversation: mockCreateConversation,
|
|
2837
|
+
updateConversation: mockUpdateConversation,
|
|
2838
|
+
});
|
|
2839
|
+
|
|
2840
|
+
const manager = createManager({ plugin });
|
|
2841
|
+
await manager.createTab();
|
|
2842
|
+
|
|
2843
|
+
await manager.forkToNewTab({
|
|
2844
|
+
messages: [],
|
|
2845
|
+
sourceSessionId: 'session-1',
|
|
2846
|
+
resumeAt: 'asst-uuid-1',
|
|
2847
|
+
// no sourceTitle
|
|
2848
|
+
});
|
|
2849
|
+
|
|
2850
|
+
const updateCall = mockUpdateConversation.mock.calls[0][1];
|
|
2851
|
+
expect(updateCall.title).toBeUndefined();
|
|
2852
|
+
});
|
|
2853
|
+
});
|
|
2854
|
+
|
|
2855
|
+
describe('TabManager - buildForkTitle', () => {
|
|
2856
|
+
function setupTitleTest(existingTitles: string[] = []) {
|
|
2857
|
+
const mockCreateConversation = jest.fn().mockResolvedValue({ id: 'fork-conv', providerId: 'claude' });
|
|
2858
|
+
const mockUpdateConversation = jest.fn().mockResolvedValue(undefined);
|
|
2859
|
+
|
|
2860
|
+
const plugin = createMockPlugin({
|
|
2861
|
+
createConversation: mockCreateConversation,
|
|
2862
|
+
updateConversation: mockUpdateConversation,
|
|
2863
|
+
getConversationList: jest.fn().mockReturnValue(
|
|
2864
|
+
existingTitles.map((t, i) => ({ id: `conv-${i}`, title: t }))
|
|
2865
|
+
),
|
|
2866
|
+
});
|
|
2867
|
+
|
|
2868
|
+
return { plugin, mockUpdateConversation };
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2871
|
+
it('should format title as "Fork: {source} (#{num})"', async () => {
|
|
2872
|
+
const { plugin, mockUpdateConversation } = setupTitleTest();
|
|
2873
|
+
const manager = createManager({ plugin });
|
|
2874
|
+
await manager.createTab();
|
|
2875
|
+
|
|
2876
|
+
await manager.forkToNewTab({
|
|
2877
|
+
messages: [],
|
|
2878
|
+
sourceSessionId: 'session-1',
|
|
2879
|
+
resumeAt: 'asst-uuid-1',
|
|
2880
|
+
sourceTitle: 'My Chat',
|
|
2881
|
+
forkAtUserMessage: 3,
|
|
2882
|
+
});
|
|
2883
|
+
|
|
2884
|
+
const updateCall = mockUpdateConversation.mock.calls[0][1];
|
|
2885
|
+
expect(updateCall.title).toBe('Fork: My Chat (#3)');
|
|
2886
|
+
});
|
|
2887
|
+
|
|
2888
|
+
it('should format title without message number when not provided', async () => {
|
|
2889
|
+
const { plugin, mockUpdateConversation } = setupTitleTest();
|
|
2890
|
+
const manager = createManager({ plugin });
|
|
2891
|
+
await manager.createTab();
|
|
2892
|
+
|
|
2893
|
+
await manager.forkToNewTab({
|
|
2894
|
+
messages: [],
|
|
2895
|
+
sourceSessionId: 'session-1',
|
|
2896
|
+
resumeAt: 'asst-uuid-1',
|
|
2897
|
+
sourceTitle: 'My Chat',
|
|
2898
|
+
});
|
|
2899
|
+
|
|
2900
|
+
const updateCall = mockUpdateConversation.mock.calls[0][1];
|
|
2901
|
+
expect(updateCall.title).toBe('Fork: My Chat');
|
|
2902
|
+
});
|
|
2903
|
+
|
|
2904
|
+
it('should truncate long source titles', async () => {
|
|
2905
|
+
const { plugin, mockUpdateConversation } = setupTitleTest();
|
|
2906
|
+
const manager = createManager({ plugin });
|
|
2907
|
+
await manager.createTab();
|
|
2908
|
+
|
|
2909
|
+
const longTitle = 'A'.repeat(100);
|
|
2910
|
+
await manager.forkToNewTab({
|
|
2911
|
+
messages: [],
|
|
2912
|
+
sourceSessionId: 'session-1',
|
|
2913
|
+
resumeAt: 'asst-uuid-1',
|
|
2914
|
+
sourceTitle: longTitle,
|
|
2915
|
+
forkAtUserMessage: 1,
|
|
2916
|
+
});
|
|
2917
|
+
|
|
2918
|
+
const updateCall = mockUpdateConversation.mock.calls[0][1];
|
|
2919
|
+
expect(updateCall.title.length).toBeLessThanOrEqual(50);
|
|
2920
|
+
expect(updateCall.title).toContain('…');
|
|
2921
|
+
expect(updateCall.title).toContain('Fork: ');
|
|
2922
|
+
expect(updateCall.title).toContain('(#1)');
|
|
2923
|
+
});
|
|
2924
|
+
|
|
2925
|
+
it('should deduplicate title when same fork title exists', async () => {
|
|
2926
|
+
const { plugin, mockUpdateConversation } = setupTitleTest(['Fork: My Chat (#1)']);
|
|
2927
|
+
const manager = createManager({ plugin });
|
|
2928
|
+
await manager.createTab();
|
|
2929
|
+
|
|
2930
|
+
await manager.forkToNewTab({
|
|
2931
|
+
messages: [],
|
|
2932
|
+
sourceSessionId: 'session-1',
|
|
2933
|
+
resumeAt: 'asst-uuid-1',
|
|
2934
|
+
sourceTitle: 'My Chat',
|
|
2935
|
+
forkAtUserMessage: 1,
|
|
2936
|
+
});
|
|
2937
|
+
|
|
2938
|
+
const updateCall = mockUpdateConversation.mock.calls[0][1];
|
|
2939
|
+
expect(updateCall.title).toBe('Fork: My Chat (#1) 2');
|
|
2940
|
+
});
|
|
2941
|
+
|
|
2942
|
+
it('should find next available dedup number', async () => {
|
|
2943
|
+
const { plugin, mockUpdateConversation } = setupTitleTest([
|
|
2944
|
+
'Fork: My Chat (#1)',
|
|
2945
|
+
'Fork: My Chat (#1) 2',
|
|
2946
|
+
'Fork: My Chat (#1) 3',
|
|
2947
|
+
]);
|
|
2948
|
+
const manager = createManager({ plugin });
|
|
2949
|
+
await manager.createTab();
|
|
2950
|
+
|
|
2951
|
+
await manager.forkToNewTab({
|
|
2952
|
+
messages: [],
|
|
2953
|
+
sourceSessionId: 'session-1',
|
|
2954
|
+
resumeAt: 'asst-uuid-1',
|
|
2955
|
+
sourceTitle: 'My Chat',
|
|
2956
|
+
forkAtUserMessage: 1,
|
|
2957
|
+
});
|
|
2958
|
+
|
|
2959
|
+
const updateCall = mockUpdateConversation.mock.calls[0][1];
|
|
2960
|
+
expect(updateCall.title).toBe('Fork: My Chat (#1) 4');
|
|
2961
|
+
});
|
|
2962
|
+
});
|