package cc.unitmesh.devins.idea.toolwindow import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import cc.unitmesh.agent.AgentType import cc.unitmesh.agent.plan.AgentPlan import cc.unitmesh.agent.render.TimelineItem import cc.unitmesh.devins.idea.compose.IdeaLaunchedEffect import cc.unitmesh.devins.idea.editor.IdeaModelConfigDialogWrapper import cc.unitmesh.devins.idea.toolwindow.codereview.IdeaCodeReviewContent import cc.unitmesh.devins.idea.toolwindow.codereview.IdeaCodeReviewViewModel import cc.unitmesh.devins.idea.components.header.IdeaAgentTabsHeader import cc.unitmesh.devins.idea.components.IdeaVerticalResizableSplitPane import cc.unitmesh.config.AcpAgentConfig import cc.unitmesh.devins.idea.toolwindow.acp.IdeaAcpAgentContent import cc.unitmesh.devins.idea.toolwindow.acp.IdeaAcpAgentViewModel import cc.unitmesh.devins.idea.toolwindow.acp.IdeaAcpConfigDialogWrapper import cc.unitmesh.devins.idea.toolwindow.knowledge.IdeaKnowledgeContent import cc.unitmesh.devins.idea.toolwindow.knowledge.IdeaKnowledgeViewModel import cc.unitmesh.devins.idea.toolwindow.remote.IdeaRemoteAgentContent import cc.unitmesh.devins.idea.toolwindow.remote.IdeaRemoteAgentViewModel import cc.unitmesh.devins.idea.toolwindow.remote.IdeaRemoteModeSelector import cc.unitmesh.devins.idea.toolwindow.remote.RemoteAgentMode import cc.unitmesh.devins.idea.toolwindow.remote.getEffectiveProjectId import cc.unitmesh.devins.idea.toolwindow.webedit.IdeaWebEditContent import cc.unitmesh.devins.idea.toolwindow.webedit.IdeaWebEditViewModel import cc.unitmesh.devins.idea.components.status.IdeaToolLoadingStatusBar import cc.unitmesh.devins.idea.components.timeline.IdeaEmptyStateMessage import cc.unitmesh.devins.idea.components.timeline.IdeaTimelineContent import cc.unitmesh.config.AutoDevConfigWrapper import cc.unitmesh.config.ConfigManager import cc.unitmesh.devti.llm2.GithubCopilotDetector import cc.unitmesh.llm.ModelConfig import cc.unitmesh.llm.NamedModelConfig import com.intellij.openapi.project.Project import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.Orientation import org.jetbrains.jewel.ui.component.Divider /** * Main Compose application for Agent ToolWindow. * * Features: * - Tab-based agent type switching (Agentic, Review, Knowledge, Remote) * - Timeline-based chat interface with tool calls * - LLM configuration support via mpp-ui's ConfigManager * - Real agent execution using mpp-core's CodingAgent * - Tool loading status bar * * Aligned with AgentChatInterface from mpp-ui for feature parity. * * Note: Uses LaunchedEffect-based manual collection instead of collectAsState() * to avoid ClassLoader conflicts between plugin's coroutines and IntelliJ's * bundled Compose runtime. The StateFlow from kotlinx-coroutines in mpp-core * is loaded by a different ClassLoader than the one expected by Compose's * collectAsState() extension function. */ @Composable fun IdeaAgentApp( viewModel: IdeaAgentViewModel, project: Project, coroutineScope: CoroutineScope ) { // Use mutableStateOf with LaunchedEffect for manual collection // to avoid ClassLoader conflicts with collectAsState() var currentAgentType by remember { mutableStateOf(AgentType.CODING) } var timeline by remember { mutableStateOf>(emptyList()) } var streamingOutput by remember { mutableStateOf("") } var isExecuting by remember { mutableStateOf(false) } var currentPlan by remember { mutableStateOf(null) } var showConfigDialog by remember { mutableStateOf(false) } var isNewConfig by remember { mutableStateOf(false) } var mcpPreloadingMessage by remember { mutableStateOf("") } var configWrapper by remember { mutableStateOf(null) } var currentModelConfig by remember { mutableStateOf(null) } var isRefreshingCopilot by remember { mutableStateOf(false) } val listState = rememberLazyListState() // Check if GitHub Copilot is available val isCopilotAvailable = remember { GithubCopilotDetector.isGithubCopilotConfigured() } // Engine state (AutoDev vs ACP) var currentEngine by remember { mutableStateOf(IdeaEngine.AUTODEV) } var acpAgents by remember { mutableStateOf>(emptyMap()) } var currentAcpAgentKey by remember { mutableStateOf(null) } var showAcpConfigDialog by remember { mutableStateOf(false) } var acpIsConnected by remember { mutableStateOf(false) } var acpIsExecuting by remember { mutableStateOf(false) } // Collect engine state IdeaLaunchedEffect(viewModel, project = project) { viewModel.currentEngine.collect { println("IdeaAgentApp: currentEngine changed to $it") currentEngine = it } } IdeaLaunchedEffect(viewModel, project = project) { viewModel.acpAgents.collect { println("IdeaAgentApp: acpAgents changed, size=${it.size}") it.forEach { (key, config) -> println(" - ACP Agent: $key -> ${config.name}") } acpAgents = it } } IdeaLaunchedEffect(viewModel, project = project) { viewModel.currentAcpAgentKey.collect { currentAcpAgentKey = it } } IdeaLaunchedEffect(viewModel, project = project) { viewModel.showAcpConfigDialog.collect { showAcpConfigDialog = it } } IdeaLaunchedEffect(viewModel.acpViewModel, project = project) { viewModel.acpViewModel.isConnected.collect { acpIsConnected = it } } IdeaLaunchedEffect(viewModel.acpViewModel, project = project) { viewModel.acpViewModel.isExecuting.collect { acpIsExecuting = it } } // Effective isProcessing: check both engines val effectiveIsProcessing = if (currentEngine == IdeaEngine.ACP) acpIsExecuting else isExecuting // Collect StateFlows manually using IdeaLaunchedEffect to avoid ClassLoader conflicts IdeaLaunchedEffect(viewModel, project = project) { viewModel.currentAgentType.collect { currentAgentType = it } } IdeaLaunchedEffect(viewModel.renderer, project = project) { viewModel.renderer.timeline.collect { timeline = it } } IdeaLaunchedEffect(viewModel.renderer, project = project) { viewModel.renderer.currentStreamingOutput.collect { streamingOutput = it } } IdeaLaunchedEffect(viewModel, project = project) { viewModel.isExecuting.collect { isExecuting = it } } IdeaLaunchedEffect(viewModel.renderer, project = project) { viewModel.renderer.currentPlan.collect { currentPlan = it } } IdeaLaunchedEffect(viewModel, project = project) { viewModel.showConfigDialog.collect { showConfigDialog = it } } IdeaLaunchedEffect(viewModel, project = project) { viewModel.isNewConfig.collect { isNewConfig = it } } IdeaLaunchedEffect(viewModel, project = project) { viewModel.mcpPreloadingMessage.collect { mcpPreloadingMessage = it } } IdeaLaunchedEffect(viewModel, project = project) { viewModel.configWrapper.collect { configWrapper = it } } IdeaLaunchedEffect(viewModel, project = project) { viewModel.currentModelConfig.collect { currentModelConfig = it } } // Get available configs and current config name val availableConfigs = remember(configWrapper) { configWrapper?.getAllConfigs() ?: emptyList() } val currentConfigName = remember(configWrapper) { configWrapper?.getActiveName() } // Code Review ViewModel (created lazily when needed) var codeReviewViewModel by remember { mutableStateOf(null) } // Knowledge ViewModel (created lazily when needed) var knowledgeViewModel by remember { mutableStateOf(null) } // Remote Agent ViewModel (created lazily when needed) var remoteAgentViewModel by remember { mutableStateOf(null) } // ACP Agent ViewModel (created lazily when needed) var acpAgentViewModel by remember { mutableStateOf(null) } // WebEdit ViewModel (created lazily when needed) var webEditViewModel by remember { mutableStateOf(null) } // Remote agent state for input handling var remoteProjectId by remember { mutableStateOf("") } var remoteGitUrl by remember { mutableStateOf("") } // Auto-scroll to bottom when new items arrive // Note: Using scrollToItem instead of animateScrollToItem because IdeaLaunchedEffect // uses a custom coroutine scope that doesn't have MonotonicFrameClock required for animations IdeaLaunchedEffect(timeline.size, streamingOutput, project = project) { if (timeline.isNotEmpty() || streamingOutput.isNotEmpty()) { val targetIndex = if (streamingOutput.isNotEmpty()) timeline.size else timeline.lastIndex.coerceAtLeast(0) if (targetIndex >= 0) { listState.scrollToItem(targetIndex) } } } // Create ViewModels when switching tabs IdeaLaunchedEffect(currentAgentType, project = project) { if (currentAgentType == AgentType.CODE_REVIEW && codeReviewViewModel == null) { codeReviewViewModel = IdeaCodeReviewViewModel(project, coroutineScope) } if (currentAgentType == AgentType.KNOWLEDGE && knowledgeViewModel == null) { knowledgeViewModel = IdeaKnowledgeViewModel(project, coroutineScope) } if (currentAgentType == AgentType.REMOTE && remoteAgentViewModel == null) { remoteAgentViewModel = IdeaRemoteAgentViewModel( project = project, coroutineScope = coroutineScope, serverUrl = "http://localhost:8080" ) } if (currentAgentType == AgentType.REMOTE && acpAgentViewModel == null) { acpAgentViewModel = IdeaAcpAgentViewModel(project, coroutineScope) } if (currentAgentType == AgentType.WEB_EDIT && webEditViewModel == null) { webEditViewModel = IdeaWebEditViewModel(project, coroutineScope) } } // Dispose ViewModels when the tool window is disposed. // Keeping them alive across tab switches avoids reconnect churn and prevents leaks. DisposableEffect(Unit) { onDispose { codeReviewViewModel?.dispose() knowledgeViewModel?.dispose() remoteAgentViewModel?.dispose() acpAgentViewModel?.dispose() webEditViewModel?.dispose() codeReviewViewModel = null knowledgeViewModel = null remoteAgentViewModel = null acpAgentViewModel = null webEditViewModel = null } } Column( modifier = Modifier .fillMaxSize() .background(JewelTheme.globalColors.panelBackground) ) { // Agent Type Tabs Header IdeaAgentTabsHeader( currentAgentType = currentAgentType, onAgentTypeChange = { viewModel.onAgentTypeChange(it) }, onNewChat = { viewModel.clearHistory() }, onSettings = { viewModel.setShowConfigDialog(true) } ) Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) // Main content area with resizable split pane for chat-based modes when (currentAgentType) { AgentType.CODING, AgentType.LOCAL_CHAT, AgentType.ARTIFACT -> { IdeaVerticalResizableSplitPane( modifier = Modifier.fillMaxWidth().weight(1f), initialSplitRatio = 0.75f, minRatio = 0.3f, maxRatio = 0.9f, top = { IdeaTimelineContent( timeline = timeline, streamingOutput = streamingOutput, listState = listState, project = project, onProcessCancel = { cancelEvent -> viewModel.handleProcessCancel(cancelEvent) } ) }, bottom = { IdeaDevInInputArea( project = project, parentDisposable = viewModel, isProcessing = effectiveIsProcessing, onSend = { viewModel.sendMessage(it) }, onAbort = { viewModel.cancelTask() }, workspacePath = project.basePath, totalTokens = null, onAtClick = {}, availableConfigs = availableConfigs, currentConfigName = currentConfigName, onConfigSelect = { config -> viewModel.setActiveConfig(config.name) }, currentPlan = currentPlan, onConfigureClick = { viewModel.showEditConfigDialog() }, onAddNewConfig = { viewModel.showAddNewConfigDialog() }, onRefreshCopilot = if (isCopilotAvailable) { { coroutineScope.launch { isRefreshingCopilot = true try { val models = withContext(Dispatchers.IO) { GithubCopilotDetector.getSupportedModels(forceRefresh = true) } if (models != null && models.data.isNotEmpty()) { val existingNames = availableConfigs.map { it.name }.toMutableSet() var savedCount = 0 for (model in models.data.filter { !it.isEmbedding }) { val configName = "copilot-${model.id}" if (configName in existingNames) continue val namedConfig = NamedModelConfig( name = configName, provider = "github-copilot", apiKey = "", model = model.id, baseUrl = "", temperature = 0.0, maxTokens = model.capabilities.limits?.maxOutputTokens ?: 4096 ) try { ConfigManager.saveConfig(namedConfig, setActive = false) existingNames.add(configName) savedCount++ } catch (e: Exception) { println("Failed to save config $configName: ${e.message}") } } // Reload configs viewModel.reloadConfigs() println("Added $savedCount GitHub Copilot configurations") } } catch (e: Exception) { println("Failed to refresh GitHub Copilot: ${e.message}") } finally { isRefreshingCopilot = false } } } } else null, isRefreshingCopilot = isRefreshingCopilot, // Multimodal analysis callbacks - stream to timeline via renderer onMultimodalAnalysisStart = { imageCount, prompt -> viewModel.renderer.startMultimodalAnalysis(imageCount, prompt) }, onMultimodalAnalysisChunk = { chunk -> viewModel.renderer.updateMultimodalAnalysisChunk(chunk) }, onMultimodalAnalysisComplete = { result, error -> viewModel.renderer.completeMultimodalAnalysis(result, error) }, // ACP engine integration currentEngine = currentEngine, acpAgents = acpAgents, currentAcpAgentKey = if (currentEngine == IdeaEngine.ACP) currentAcpAgentKey else null, onAcpAgentSelect = { agentKey -> viewModel.switchToAcpAgent(agentKey) }, onConfigureAcp = { viewModel.setShowAcpConfigDialog(true) }, onSwitchToAutodev = { viewModel.switchToAutodev() }, onSwitchToAcp = { // When user clicks ACP in engine selector but hasn't selected agent yet if (acpAgents.isNotEmpty()) { // Switch to first agent if available val firstAgent = acpAgents.keys.first() viewModel.switchToAcpAgent(firstAgent) } else { // Open config dialog if no agents viewModel.setShowAcpConfigDialog(true) } } ) } ) } AgentType.REMOTE -> { val remoteVm = remoteAgentViewModel val acpVm = acpAgentViewModel if (remoteVm != null && acpVm != null) { var remoteMode by remember { mutableStateOf(RemoteAgentMode.SERVER) } var remoteIsExecuting by remember { mutableStateOf(false) } var acpIsExecuting by remember { mutableStateOf(false) } IdeaLaunchedEffect(remoteVm, project = project) { remoteVm.isExecuting.collect { remoteIsExecuting = it } } IdeaLaunchedEffect(acpVm, project = project) { acpVm.isExecuting.collect { acpIsExecuting = it } } val isRemoteProcessing = remoteMode == RemoteAgentMode.SERVER && remoteIsExecuting val isAcpProcessing = remoteMode == RemoteAgentMode.ACP && acpIsExecuting IdeaVerticalResizableSplitPane( modifier = Modifier.fillMaxWidth().weight(1f), initialSplitRatio = 0.75f, minRatio = 0.3f, maxRatio = 0.9f, top = { Column(modifier = Modifier.fillMaxSize()) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 8.dp), horizontalArrangement = Arrangement.Start ) { IdeaRemoteModeSelector( mode = remoteMode, onModeChange = { remoteMode = it } ) } Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) when (remoteMode) { RemoteAgentMode.SERVER -> { IdeaRemoteAgentContent( viewModel = remoteVm, listState = listState, onProjectIdChange = { remoteProjectId = it }, onGitUrlChange = { remoteGitUrl = it }, modifier = Modifier.fillMaxSize() ) } RemoteAgentMode.ACP -> { IdeaAcpAgentContent( viewModel = acpVm, listState = listState, modifier = Modifier.fillMaxSize() ) } } } }, bottom = { IdeaDevInInputArea( project = project, parentDisposable = viewModel, isProcessing = isRemoteProcessing || isAcpProcessing, onSend = { task -> when (remoteMode) { RemoteAgentMode.SERVER -> { val effectiveProjectId = getEffectiveProjectId(remoteProjectId, remoteGitUrl) if (effectiveProjectId.isNotBlank()) { remoteVm.executeTask(effectiveProjectId, task, remoteGitUrl) } else { remoteVm.renderer.renderError("Please provide a project or Git URL") } } RemoteAgentMode.ACP -> { acpVm.sendMessage(task) } } }, onAbort = { when (remoteMode) { RemoteAgentMode.SERVER -> remoteVm.cancelTask() RemoteAgentMode.ACP -> acpVm.cancelTask() } }, workspacePath = project.basePath, totalTokens = null, onAtClick = {}, availableConfigs = availableConfigs, currentConfigName = currentConfigName, onConfigSelect = { config -> viewModel.setActiveConfig(config.name) }, onConfigureClick = { viewModel.showEditConfigDialog() }, onAddNewConfig = { viewModel.showAddNewConfigDialog() }, isRefreshingCopilot = isRefreshingCopilot ) } ) } else { IdeaEmptyStateMessage("Loading Remote Agent...") } } AgentType.CODE_REVIEW -> { Box(modifier = Modifier.fillMaxWidth().weight(1f)) { codeReviewViewModel?.let { vm -> IdeaCodeReviewContent( viewModel = vm, parentDisposable = viewModel ) } ?: IdeaEmptyStateMessage("Loading Code Review...") } } AgentType.KNOWLEDGE -> { Box(modifier = Modifier.fillMaxWidth().weight(1f)) { knowledgeViewModel?.let { vm -> IdeaKnowledgeContent(viewModel = vm) } ?: IdeaEmptyStateMessage("Loading Knowledge Agent...") } } AgentType.WEB_EDIT -> { // WebEdit mode - Browse web pages and select DOM elements Box(modifier = Modifier.fillMaxWidth().weight(1f)) { webEditViewModel?.let { vm -> IdeaWebEditContent(viewModel = vm) } ?: IdeaEmptyStateMessage("Loading WebEdit Agent...") } } AgentType.CHAT_DB -> { Box(modifier = Modifier.fillMaxWidth().weight(1f)) { IdeaEmptyStateMessage("ChatDB Agent coming soon...") } } } // Tool loading status bar IdeaToolLoadingStatusBar( viewModel = viewModel, project = project, modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp) ) } // Model Configuration Dialog using DialogWrapper for proper z-index handling // Use DisposableEffect instead of IdeaLaunchedEffect to avoid coroutine context issues // DialogWrapper must be created on EDT, so we use invokeLater DisposableEffect(showConfigDialog) { if (showConfigDialog) { val dialogConfig = if (isNewConfig) ModelConfig() else (currentModelConfig ?: ModelConfig()) val dialogConfigName = if (isNewConfig) null else currentConfigName com.intellij.openapi.application.ApplicationManager.getApplication().invokeLater { IdeaModelConfigDialogWrapper.show( project = project, currentConfig = dialogConfig, currentConfigName = dialogConfigName, onSave = { configName, newModelConfig -> // If creating a new config (not editing current), ensure unique name val existingNames = availableConfigs.map { it.name } val finalConfigName = if (currentConfigName != configName && configName in existingNames) { // Auto-increment: my-glm -> my-glm-1 -> my-glm-2, etc. ConfigManager.generateUniqueConfigName(configName, existingNames) } else { configName } // Convert ModelConfig to NamedModelConfig val namedConfig = NamedModelConfig.fromModelConfig( name = finalConfigName, config = newModelConfig ) // Save to file viewModel.saveModelConfig(namedConfig, setActive = true) if (finalConfigName != configName) { println("Config name already exists, auto-renamed to: $finalConfigName") } } ) viewModel.setShowConfigDialog(false) } } onDispose { } } // ACP Agent Configuration Dialog DisposableEffect(showAcpConfigDialog) { if (showAcpConfigDialog) { com.intellij.openapi.application.ApplicationManager.getApplication().invokeLater { IdeaAcpConfigDialogWrapper.show( project = project, agents = acpAgents, activeKey = currentAcpAgentKey, onSave = { newAgents, newActiveKey -> // Reload ACP agents and update selection viewModel.reloadAcpAgents() if (newActiveKey != null) { viewModel.switchToAcpAgent(newActiveKey) } } ) viewModel.setShowAcpConfigDialog(false) } } onDispose { } } }