From da39b5becd86699bca0b47936367b29380e299ee Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Thu, 4 Dec 2025 07:44:36 +0800 Subject: [PATCH 1/5] feat(mpp-idea): add file change tracking components Introduce new classes for tracking, summarizing, and displaying file changes in the IDEA toolwindow, and update input area to integrate with these features. --- .../idea/toolwindow/IdeaDevInInputArea.kt | 7 + .../changes/IdeaFileChangeDiffDialog.kt | 335 ++++++++++++++++++ .../toolwindow/changes/IdeaFileChangeItem.kt | 171 +++++++++ .../changes/IdeaFileChangeSummary.kt | 258 ++++++++++++++ .../changes/IdeaFileChangeTracker.kt | 212 +++++++++++ 5 files changed, 983 insertions(+) create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/changes/IdeaFileChangeDiffDialog.kt create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/changes/IdeaFileChangeItem.kt create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/changes/IdeaFileChangeSummary.kt create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/changes/IdeaFileChangeTracker.kt diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt index 26ba409d7f..b59278f760 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaDevInInputArea.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.unit.dp import cc.unitmesh.agent.plan.AgentPlan import cc.unitmesh.devins.idea.editor.* import cc.unitmesh.devins.idea.toolwindow.plan.IdeaPlanSummaryBar +import cc.unitmesh.devins.idea.toolwindow.changes.IdeaFileChangeSummary import cc.unitmesh.llm.NamedModelConfig import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager @@ -111,6 +112,12 @@ fun IdeaDevInInputArea( modifier = Modifier.fillMaxWidth() ) + // File change summary - shown when there are file changes + IdeaFileChangeSummary( + project = project, + modifier = Modifier.fillMaxWidth() + ) + // Top toolbar with file selection (no individual border) IdeaTopToolbar( project = project, diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/changes/IdeaFileChangeDiffDialog.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/changes/IdeaFileChangeDiffDialog.kt new file mode 100644 index 0000000000..13195f8882 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/changes/IdeaFileChangeDiffDialog.kt @@ -0,0 +1,335 @@ +package cc.unitmesh.devins.idea.toolwindow.changes + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.unitmesh.agent.diff.ChangeType +import cc.unitmesh.agent.diff.DiffUtils +import cc.unitmesh.agent.diff.FileChange +import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.util.ui.JBUI +import org.jetbrains.jewel.bridge.compose +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.DefaultButton +import org.jetbrains.jewel.ui.component.Icon +import org.jetbrains.jewel.ui.component.OutlinedButton +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.icons.AllIconsKeys +import java.awt.Dimension +import javax.swing.JComponent + +/** + * Dialog for displaying file change diff using IntelliJ's DialogWrapper. + * Uses Jewel Compose for the content rendering. + */ +@Composable +fun IdeaFileChangeDiffDialog( + project: Project, + change: FileChange, + onDismiss: () -> Unit, + onUndo: () -> Unit, + onKeep: () -> Unit +) { + // Show the dialog using DialogWrapper + IdeaFileChangeDiffDialogWrapper.show( + project = project, + change = change, + onUndo = onUndo, + onKeep = onKeep, + onDismiss = onDismiss + ) +} + +/** + * DialogWrapper implementation for file change diff dialog. + */ +class IdeaFileChangeDiffDialogWrapper( + private val project: Project, + private val change: FileChange, + private val onUndoCallback: () -> Unit, + private val onKeepCallback: () -> Unit, + private val onDismissCallback: () -> Unit +) : DialogWrapper(project) { + + init { + title = "Diff: ${change.getFileName()}" + init() + contentPanel.border = JBUI.Borders.empty() + rootPane.border = JBUI.Borders.empty() + } + + override fun createSouthPanel(): JComponent? = null + + override fun createCenterPanel(): JComponent { + val dialogPanel = compose { + DiffDialogContent( + change = change, + onDismiss = { + onDismissCallback() + close(CANCEL_EXIT_CODE) + }, + onUndo = { + onUndoCallback() + close(OK_EXIT_CODE) + }, + onKeep = { + onKeepCallback() + close(OK_EXIT_CODE) + } + ) + } + dialogPanel.preferredSize = Dimension(800, 600) + return dialogPanel + } + + override fun doCancelAction() { + onDismissCallback() + super.doCancelAction() + } + + companion object { + fun show( + project: Project, + change: FileChange, + onUndo: () -> Unit, + onKeep: () -> Unit, + onDismiss: () -> Unit + ): Boolean { + val dialog = IdeaFileChangeDiffDialogWrapper( + project = project, + change = change, + onUndoCallback = onUndo, + onKeepCallback = onKeep, + onDismissCallback = onDismiss + ) + return dialog.showAndGet() + } + } +} + +@Composable +private fun DiffDialogContent( + change: FileChange, + onDismiss: () -> Unit, + onUndo: () -> Unit, + onKeep: () -> Unit +) { + val scrollState = rememberScrollState() + val diffContent = DiffUtils.generateUnifiedDiff( + oldContent = change.originalContent ?: "", + newContent = change.newContent ?: "", + filePath = change.filePath + ) + + Column( + modifier = Modifier + .fillMaxSize() + .background(JewelTheme.globalColors.panelBackground) + .padding(16.dp) + ) { + // Header + DiffDialogHeader(change = change) + + Spacer(modifier = Modifier.height(12.dp)) + + // Diff content + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .clip(RoundedCornerShape(4.dp)) + .background(AutoDevColors.Neutral.c900) + .padding(8.dp) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + ) { + diffContent.lines().forEach { line -> + DiffLine(line = line) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Action buttons + DiffDialogActions( + onDismiss = onDismiss, + onUndo = onUndo, + onKeep = onKeep + ) + } +} + +@Composable +private fun DiffDialogHeader(change: FileChange) { + val diffStats = change.getDiffStats() + val iconKey = when (change.changeType) { + ChangeType.CREATE -> AllIconsKeys.General.Add + ChangeType.EDIT -> AllIconsKeys.Actions.Edit + ChangeType.DELETE -> AllIconsKeys.General.Remove + ChangeType.RENAME -> AllIconsKeys.Actions.Edit // Use Edit as fallback for Rename + } + val iconColor = when (change.changeType) { + ChangeType.CREATE -> AutoDevColors.Green.c400 + ChangeType.EDIT -> AutoDevColors.Blue.c400 + ChangeType.DELETE -> AutoDevColors.Red.c400 + ChangeType.RENAME -> AutoDevColors.Indigo.c400 // Use Indigo instead of Purple + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + key = iconKey, + contentDescription = change.changeType.name, + modifier = Modifier.size(20.dp), + tint = iconColor + ) + Column { + Text( + text = change.getFileName(), + style = JewelTheme.defaultTextStyle.copy( + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ) + ) + Text( + text = change.filePath, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = AutoDevColors.Neutral.c400 + ) + ) + } + } + + // Diff stats + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "+${diffStats.addedLines}", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + color = AutoDevColors.Green.c400, + fontWeight = FontWeight.Medium + ) + ) + Text( + text = "-${diffStats.deletedLines}", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + color = AutoDevColors.Red.c400, + fontWeight = FontWeight.Medium + ) + ) + } + } +} + +@Composable +private fun DiffLine(line: String) { + val backgroundColor: Color + val textColor: Color + + when { + line.startsWith("+") && !line.startsWith("+++") -> { + backgroundColor = AutoDevColors.Green.c900.copy(alpha = 0.3f) + textColor = AutoDevColors.Green.c300 + } + line.startsWith("-") && !line.startsWith("---") -> { + backgroundColor = AutoDevColors.Red.c900.copy(alpha = 0.3f) + textColor = AutoDevColors.Red.c300 + } + line.startsWith("@@") -> { + backgroundColor = AutoDevColors.Blue.c900.copy(alpha = 0.3f) + textColor = AutoDevColors.Blue.c300 + } + else -> { + backgroundColor = Color.Transparent + textColor = AutoDevColors.Neutral.c300 + } + } + + Text( + text = line, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + color = textColor + ), + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor) + .padding(horizontal = 4.dp, vertical = 1.dp) + ) +} + +@Composable +private fun DiffDialogActions( + onDismiss: () -> Unit, + onUndo: () -> Unit, + onKeep: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedButton( + onClick = onDismiss, + modifier = Modifier.padding(end = 8.dp) + ) { + Text("Close") + } + + OutlinedButton( + onClick = onUndo, + modifier = Modifier.padding(end = 8.dp) + ) { + Icon( + key = AllIconsKeys.Actions.Rollback, + contentDescription = "Undo", + modifier = Modifier.size(14.dp), + tint = AutoDevColors.Red.c400 + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Undo", color = AutoDevColors.Red.c400) + } + + DefaultButton( + onClick = onKeep + ) { + Icon( + key = AllIconsKeys.Actions.Checked, + contentDescription = "Keep", + modifier = Modifier.size(14.dp), + tint = AutoDevColors.Green.c400 + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Keep") + } + } +} diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/changes/IdeaFileChangeItem.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/changes/IdeaFileChangeItem.kt new file mode 100644 index 0000000000..aa14d682ce --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/changes/IdeaFileChangeItem.kt @@ -0,0 +1,171 @@ +package cc.unitmesh.devins.idea.toolwindow.changes + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.unitmesh.agent.diff.ChangeType +import cc.unitmesh.agent.diff.FileChange +import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Icon +import org.jetbrains.jewel.ui.component.IconButton +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.icons.AllIconsKeys + +/** + * Individual file change item for IntelliJ IDEA using Jewel components. + * + * Displays file name, path, change type icon, diff stats, and action buttons. + */ +@Composable +fun IdeaFileChangeItem( + change: FileChange, + onClick: () -> Unit, + onUndo: () -> Unit, + onKeep: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(4.dp)) + .background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f)) + .clickable(onClick = onClick) + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // File info + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + // Change type icon + val iconKey = when (change.changeType) { + ChangeType.CREATE -> AllIconsKeys.General.Add + ChangeType.EDIT -> AllIconsKeys.Actions.Edit + ChangeType.DELETE -> AllIconsKeys.General.Remove + ChangeType.RENAME -> AllIconsKeys.Actions.Edit // Use Edit as fallback for Rename + } + val iconColor = when (change.changeType) { + ChangeType.CREATE -> AutoDevColors.Green.c400 + ChangeType.EDIT -> AutoDevColors.Blue.c400 + ChangeType.DELETE -> AutoDevColors.Red.c400 + ChangeType.RENAME -> AutoDevColors.Indigo.c400 // Use Indigo instead of Purple + } + + Icon( + key = iconKey, + contentDescription = change.changeType.name, + modifier = Modifier.size(14.dp), + tint = iconColor + ) + + // File name + Text( + text = change.getFileName(), + style = JewelTheme.defaultTextStyle.copy(fontSize = 11.sp), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + + // Path separator + Text( + text = "\u00B7", // Middle dot + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = AutoDevColors.Neutral.c500 + ) + ) + + // Parent path + val parentPath = change.filePath.substringBeforeLast('/') + if (parentPath.isNotEmpty()) { + Text( + text = parentPath.substringAfterLast('/'), + style = JewelTheme.defaultTextStyle.copy( + fontSize = 10.sp, + color = AutoDevColors.Neutral.c500 + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + } + + // Diff stats + val diffStats = change.getDiffStats() + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + if (diffStats.addedLines > 0) { + Text( + text = "+${diffStats.addedLines}", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 10.sp, + color = AutoDevColors.Green.c400 + ), + modifier = Modifier + .clip(RoundedCornerShape(3.dp)) + .background(AutoDevColors.Green.c900.copy(alpha = 0.3f)) + .padding(horizontal = 4.dp, vertical = 1.dp) + ) + } + if (diffStats.deletedLines > 0) { + Text( + text = "-${diffStats.deletedLines}", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 10.sp, + color = AutoDevColors.Red.c400 + ), + modifier = Modifier + .clip(RoundedCornerShape(3.dp)) + .background(AutoDevColors.Red.c900.copy(alpha = 0.3f)) + .padding(horizontal = 4.dp, vertical = 1.dp) + ) + } + } + } + + // Action buttons + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + // Keep button + IconButton( + onClick = onKeep, + modifier = Modifier.size(22.dp) + ) { + Icon( + key = AllIconsKeys.Actions.Checked, + contentDescription = "Keep", + modifier = Modifier.size(12.dp), + tint = AutoDevColors.Green.c400 + ) + } + + // Undo button + IconButton( + onClick = onUndo, + modifier = Modifier.size(22.dp) + ) { + Icon( + key = AllIconsKeys.Actions.Rollback, + contentDescription = "Undo", + modifier = Modifier.size(12.dp), + tint = AutoDevColors.Red.c400 + ) + } + } + } +} diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/changes/IdeaFileChangeSummary.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/changes/IdeaFileChangeSummary.kt new file mode 100644 index 0000000000..11ab22d377 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/changes/IdeaFileChangeSummary.kt @@ -0,0 +1,258 @@ +package cc.unitmesh.devins.idea.toolwindow.changes + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.unitmesh.agent.diff.ChangeType +import cc.unitmesh.agent.diff.FileChange +import cc.unitmesh.agent.diff.FileChangeTracker +import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.runWriteAction +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.LocalFileSystem +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Icon +import org.jetbrains.jewel.ui.component.IconButton +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.icons.AllIconsKeys + +/** + * File Change Summary Component for IntelliJ IDEA using Jewel components. + * + * Displays a collapsible summary of all file changes made by the AI Agent. + * Uses Jewel theming and components for native IntelliJ look and feel. + */ +@Composable +fun IdeaFileChangeSummary( + project: Project, + modifier: Modifier = Modifier +) { + val changes by FileChangeTracker.changes.collectAsState() + var isExpanded by remember { mutableStateOf(false) } + var selectedChange by remember { mutableStateOf(null) } + + // Only show if there are changes + if (changes.isEmpty()) { + return + } + + // Show diff dialog if a file is selected + selectedChange?.let { change -> + IdeaFileChangeDiffDialog( + project = project, + change = change, + onDismiss = { selectedChange = null }, + onUndo = { + undoChange(project, change) + FileChangeTracker.removeChange(change) + selectedChange = null + }, + onKeep = { + FileChangeTracker.removeChange(change) + selectedChange = null + } + ) + } + + Column( + modifier = modifier + .fillMaxWidth() + .background( + JewelTheme.globalColors.panelBackground, + RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp) + ) + .clip(RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp)) + ) { + // Collapsed header + IdeaChangeSummaryHeader( + changeCount = changes.size, + isExpanded = isExpanded, + onExpandToggle = { isExpanded = !isExpanded }, + onUndoAll = { + changes.forEach { change -> + undoChange(project, change) + } + FileChangeTracker.clearChanges() + }, + onKeepAll = { + FileChangeTracker.clearChanges() + } + ) + + // Expanded content + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically(), + exit = shrinkVertically() + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(JewelTheme.globalColors.borders.normal) + ) + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 200.dp) + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(changes, key = { "${it.filePath}_${it.timestamp}" }) { change -> + IdeaFileChangeItem( + change = change, + onClick = { selectedChange = change }, + onUndo = { + undoChange(project, change) + FileChangeTracker.removeChange(change) + }, + onKeep = { + FileChangeTracker.removeChange(change) + } + ) + } + } + } + } + } +} + +@Composable +private fun IdeaChangeSummaryHeader( + changeCount: Int, + isExpanded: Boolean, + onExpandToggle: () -> Unit, + onUndoAll: () -> Unit, + onKeepAll: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onExpandToggle() } + .padding(horizontal = 8.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // Left side: expand arrow and title + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Expand arrow + Icon( + key = if (isExpanded) AllIconsKeys.General.ArrowDown else AllIconsKeys.General.ArrowRight, + contentDescription = if (isExpanded) "Collapse" else "Expand", + modifier = Modifier.size(12.dp), + tint = AutoDevColors.Neutral.c400 + ) + + // Title + Text( + text = "$changeCount file${if (changeCount > 1) "s" else ""} changed", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + fontWeight = FontWeight.Medium + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + // Right side: action buttons + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Undo All button + IconButton( + onClick = onUndoAll, + modifier = Modifier.size(24.dp) + ) { + Icon( + key = AllIconsKeys.Actions.Rollback, + contentDescription = "Undo All", + modifier = Modifier.size(14.dp), + tint = AutoDevColors.Red.c400 + ) + } + + // Keep All button + IconButton( + onClick = onKeepAll, + modifier = Modifier.size(24.dp) + ) { + Icon( + key = AllIconsKeys.Actions.Checked, + contentDescription = "Keep All", + modifier = Modifier.size(14.dp), + tint = AutoDevColors.Green.c400 + ) + } + } + } +} + +/** + * Undo a file change by restoring the original content + */ +private fun undoChange(project: Project, change: FileChange) { + ApplicationManager.getApplication().invokeLater { + runWriteAction { + try { + when (change.changeType) { + ChangeType.CREATE -> { + // For created files, delete or clear the content + val virtualFile = LocalFileSystem.getInstance().findFileByPath(change.filePath) + virtualFile?.let { vf -> + val document = FileDocumentManager.getInstance().getDocument(vf) + document?.setText("") + } + } + ChangeType.EDIT, ChangeType.RENAME -> { + // Restore original content + change.originalContent?.let { original -> + val virtualFile = LocalFileSystem.getInstance().findFileByPath(change.filePath) + virtualFile?.let { vf -> + val document = FileDocumentManager.getInstance().getDocument(vf) + document?.setText(original) + } + } + } + ChangeType.DELETE -> { + // For deleted files, we would need to recreate them + change.originalContent?.let { original -> + val parentPath = change.filePath.substringBeforeLast('/') + val fileName = change.filePath.substringAfterLast('/') + val parentDir = LocalFileSystem.getInstance().findFileByPath(parentPath) + parentDir?.let { dir -> + val newFile = dir.createChildData(project, fileName) + val document = FileDocumentManager.getInstance().getDocument(newFile) + document?.setText(original) + } + } + } + } + } catch (e: Exception) { + println("Failed to undo change for ${change.filePath}: ${e.message}") + } + } + } +} diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/changes/IdeaFileChangeTracker.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/changes/IdeaFileChangeTracker.kt new file mode 100644 index 0000000000..28972d1605 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/changes/IdeaFileChangeTracker.kt @@ -0,0 +1,212 @@ +package cc.unitmesh.devins.idea.toolwindow.changes + +import cc.unitmesh.agent.diff.ChangeType +import cc.unitmesh.agent.diff.FileChange +import cc.unitmesh.agent.diff.FileChangeTracker +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.vcs.changes.Change +import com.intellij.openapi.vcs.changes.ChangeListListener +import com.intellij.openapi.vcs.changes.ChangeListManager +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.VirtualFileManager +import com.intellij.openapi.vfs.newvfs.BulkFileListener +import com.intellij.openapi.vfs.newvfs.events.VFileContentChangeEvent +import com.intellij.openapi.vfs.newvfs.events.VFileCreateEvent +import com.intellij.openapi.vfs.newvfs.events.VFileDeleteEvent +import com.intellij.openapi.vfs.newvfs.events.VFileEvent + +/** + * Bridge between IntelliJ VCS/VFS and the cross-platform FileChangeTracker. + * + * This service monitors file changes in the project and syncs them to the + * FileChangeTracker for display in the IdeaFileChangeSummary component. + * + * It can track changes from: + * 1. VCS ChangeListManager (for VCS-tracked changes) + * 2. VFS BulkFileListener (for real-time file modifications) + */ +@Service(Service.Level.PROJECT) +class IdeaFileChangeTracker(private val project: Project) : Disposable { + + private val changeListManager = ChangeListManager.getInstance(project) + private var isTracking = false + + // Cache of original file contents before changes + private val originalContents = mutableMapOf() + + init { + // Subscribe to VFS events for real-time file change tracking + project.messageBus.connect(this).subscribe( + VirtualFileManager.VFS_CHANGES, + object : BulkFileListener { + override fun before(events: List) { + if (!isTracking) return + + events.forEach { event -> + when (event) { + is VFileContentChangeEvent -> { + // Cache original content before change + val file = event.file + if (isProjectFile(file)) { + val document = FileDocumentManager.getInstance().getDocument(file) + originalContents[file.path] = document?.text + } + } + is VFileDeleteEvent -> { + // Cache content before deletion + val file = event.file + if (isProjectFile(file)) { + val document = FileDocumentManager.getInstance().getDocument(file) + originalContents[file.path] = document?.text + } + } + } + } + } + + override fun after(events: List) { + if (!isTracking) return + + events.forEach { event -> + when (event) { + is VFileContentChangeEvent -> { + val file = event.file + if (isProjectFile(file)) { + val document = FileDocumentManager.getInstance().getDocument(file) + val newContent = document?.text + val originalContent = originalContents.remove(file.path) + + if (originalContent != newContent) { + recordChange( + filePath = file.path, + changeType = ChangeType.EDIT, + originalContent = originalContent, + newContent = newContent + ) + } + } + } + is VFileCreateEvent -> { + val file = event.file + if (file != null && isProjectFile(file)) { + val document = FileDocumentManager.getInstance().getDocument(file) + recordChange( + filePath = file.path, + changeType = ChangeType.CREATE, + originalContent = null, + newContent = document?.text + ) + } + } + is VFileDeleteEvent -> { + val file = event.file + if (isProjectFile(file)) { + val originalContent = originalContents.remove(file.path) + recordChange( + filePath = file.path, + changeType = ChangeType.DELETE, + originalContent = originalContent, + newContent = null + ) + } + } + } + } + } + } + ) + } + + /** + * Start tracking file changes + */ + fun startTracking() { + isTracking = true + } + + /** + * Stop tracking file changes + */ + fun stopTracking() { + isTracking = false + originalContents.clear() + } + + /** + * Record a file change to the FileChangeTracker + */ + fun recordChange( + filePath: String, + changeType: ChangeType, + originalContent: String?, + newContent: String? + ) { + val change = FileChange( + filePath = filePath, + changeType = changeType, + originalContent = originalContent, + newContent = newContent + ) + FileChangeTracker.recordChange(change) + } + + /** + * Sync VCS changes to FileChangeTracker + */ + fun syncVcsChanges() { + val changes = changeListManager.defaultChangeList.changes + changes.forEach { change -> + val fileChange = convertVcsChange(change) + if (fileChange != null) { + FileChangeTracker.recordChange(fileChange) + } + } + } + + /** + * Convert IntelliJ VCS Change to FileChange + */ + private fun convertVcsChange(change: Change): FileChange? { + val virtualFile = change.virtualFile ?: return null + val filePath = virtualFile.path + + val changeType = when (change.type) { + Change.Type.NEW -> ChangeType.CREATE + Change.Type.DELETED -> ChangeType.DELETE + Change.Type.MOVED -> ChangeType.RENAME + else -> ChangeType.EDIT + } + + val originalContent = change.beforeRevision?.content + val newContent = change.afterRevision?.content + + return FileChange( + filePath = filePath, + changeType = changeType, + originalContent = originalContent, + newContent = newContent + ) + } + + /** + * Check if a file is within the project + */ + private fun isProjectFile(file: VirtualFile): Boolean { + val projectBasePath = project.basePath ?: return false + return file.path.startsWith(projectBasePath) + } + + override fun dispose() { + stopTracking() + } + + companion object { + fun getInstance(project: Project): IdeaFileChangeTracker { + return project.getService(IdeaFileChangeTracker::class.java) + } + } +} From 6648ccf3f0d19a75ae78497bf688ac25c912404e Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Thu, 4 Dec 2025 08:16:30 +0800 Subject: [PATCH 2/5] feat(plan): sync plan state from agent to renderer Integrate PlanStateService observation to update renderer with current plan. Add logging for plan updates and summary rendering. --- .../kotlin/cc/unitmesh/agent/CodingAgent.kt | 8 +++++ .../devins/idea/renderer/JewelRenderer.kt | 22 ++++++++++++- .../renderer/sketch/IdeaTerminalRenderer.kt | 7 ++-- .../idea/toolwindow/IdeaAgentViewModel.kt | 22 +++++++++++++ .../changes/IdeaFileChangeSummary.kt | 33 ++++++++++--------- .../toolwindow/plan/IdeaPlanSummaryBar.kt | 6 ++++ 6 files changed, 78 insertions(+), 20 deletions(-) diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/CodingAgent.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/CodingAgent.kt index fc796c37ea..0a3f63373c 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/CodingAgent.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/CodingAgent.kt @@ -85,6 +85,14 @@ class CodingAgent( private val policyEngine = DefaultPolicyEngine() private val toolOrchestrator = ToolOrchestrator(toolRegistry, policyEngine, renderer, mcpConfigService = mcpToolConfigService) + /** + * Get the PlanStateService for observing plan state changes. + * Returns null if no plan tool is registered. + */ + fun getPlanStateService(): cc.unitmesh.agent.plan.PlanStateService? { + return toolOrchestrator.getPlanStateService() + } + private val errorRecoveryAgent = ErrorRecoveryAgent(projectPath, llmService) private val analysisAgent = AnalysisAgent(llmService, contentThreshold = 15000) private val mcpToolsInitializer = McpToolsInitializer() diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt index 36440de2af..b5399e11c8 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt @@ -14,11 +14,14 @@ import cc.unitmesh.agent.render.ToolCallInfo import cc.unitmesh.agent.tool.ToolType import cc.unitmesh.agent.tool.toToolType import cc.unitmesh.llm.compression.TokenInfo +import com.intellij.openapi.diagnostic.Logger import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +private val jewelRendererLogger = Logger.getInstance("JewelRenderer") + /** * Jewel-compatible Renderer for IntelliJ IDEA plugin. * @@ -84,6 +87,15 @@ class JewelRenderer : BaseRenderer() { private val _currentPlan = MutableStateFlow(null) val currentPlan: StateFlow = _currentPlan.asStateFlow() + /** + * Set the current plan directly. + * Used to sync with PlanStateService from CodingAgent. + */ + fun setPlan(plan: AgentPlan?) { + jewelRendererLogger.info("setPlan: plan=${plan != null}, tasks=${plan?.tasks?.size ?: 0}") + _currentPlan.value = plan + } + // BaseRenderer implementation override fun renderIterationHeader(current: Int, max: Int) { @@ -137,10 +149,14 @@ class JewelRenderer : BaseRenderer() { } override fun renderToolCall(toolName: String, paramsStr: String) { + jewelRendererLogger.info("renderToolCall: toolName=$toolName, paramsStr length=${paramsStr.length}") + val toolInfo = formatToolCallDisplay(toolName, paramsStr) val params = parseParamsString(paramsStr) val toolType = toolName.toToolType() + jewelRendererLogger.info("renderToolCall: parsed params keys=${params.keys}") + // Handle task-boundary tool - update task list if (toolName == "task-boundary") { updateTaskFromToolCall(params) @@ -148,6 +164,7 @@ class JewelRenderer : BaseRenderer() { // Handle plan management tool - update plan state if (toolName == "plan") { + jewelRendererLogger.info("renderToolCall: detected plan tool, calling updatePlanFromToolCall") updatePlanFromToolCall(params) // Skip rendering plan tool to timeline - it's shown in PlanSummaryBar return @@ -302,10 +319,13 @@ class JewelRenderer : BaseRenderer() { * Internal method to update plan state */ private fun updatePlanState(action: String, planMarkdown: String, taskIndex: Int?, stepIndex: Int?) { + jewelRendererLogger.info("updatePlanState: action=$action, planMarkdown length=${planMarkdown.length}") when (action) { "CREATE", "UPDATE" -> { if (planMarkdown.isNotBlank()) { - _currentPlan.value = MarkdownPlanParser.parseToPlan(planMarkdown) + val plan = MarkdownPlanParser.parseToPlan(planMarkdown) + jewelRendererLogger.info("Parsed plan: ${plan.tasks.size} tasks") + _currentPlan.value = plan } } "COMPLETE_STEP" -> { diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaTerminalRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaTerminalRenderer.kt index 0204b690bc..6b2a61276a 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaTerminalRenderer.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaTerminalRenderer.kt @@ -76,11 +76,10 @@ fun IdeaTerminalRenderer( // Command display CommandDisplay(command = command, isDangerous = isDangerous, dangerReason = dangerReason) - // Output (if available) - if (showOutput && executionResult != null) { - OutputDisplay(result = executionResult!!) - } +// if (showOutput && executionResult != null) { +// OutputDisplay(result = executionResult!!) +// } } } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt index 1fbd874f5e..35584f4e9a 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt @@ -262,10 +262,32 @@ class IdeaAgentViewModel( enableLLMStreaming = true ) agentInitialized = true + + // Start observing PlanStateService and sync to renderer + startPlanStateObserver(codingAgent!!) } return codingAgent!! } + // Job for observing PlanStateService + private var planStateObserverJob: Job? = null + + /** + * Start observing PlanStateService and sync plan state to renderer. + */ + private fun startPlanStateObserver(agent: CodingAgent) { + // Cancel any existing observer + planStateObserverJob?.cancel() + + val planStateService = agent.getPlanStateService() ?: return + + planStateObserverJob = coroutineScope.launch { + planStateService.currentPlan.collect { plan -> + renderer.setPlan(plan) + } + } + } + /** * Check if LLM service is configured. */ diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/changes/IdeaFileChangeSummary.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/changes/IdeaFileChangeSummary.kt index 11ab22d377..fd19ccdbb9 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/changes/IdeaFileChangeSummary.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/changes/IdeaFileChangeSummary.kt @@ -52,22 +52,25 @@ fun IdeaFileChangeSummary( return } - // Show diff dialog if a file is selected - selectedChange?.let { change -> - IdeaFileChangeDiffDialog( - project = project, - change = change, - onDismiss = { selectedChange = null }, - onUndo = { - undoChange(project, change) - FileChangeTracker.removeChange(change) - selectedChange = null - }, - onKeep = { - FileChangeTracker.removeChange(change) - selectedChange = null + // Handle diff dialog display when a file is selected + LaunchedEffect(selectedChange) { + selectedChange?.let { change -> + ApplicationManager.getApplication().invokeLater { + IdeaFileChangeDiffDialogWrapper.show( + project = project, + change = change, + onUndo = { + undoChange(project, change) + FileChangeTracker.removeChange(change) + }, + onKeep = { + FileChangeTracker.removeChange(change) + }, + onDismiss = {} + ) } - ) + selectedChange = null + } } Column( diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/plan/IdeaPlanSummaryBar.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/plan/IdeaPlanSummaryBar.kt index f897ed9b09..b04f027164 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/plan/IdeaPlanSummaryBar.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/plan/IdeaPlanSummaryBar.kt @@ -43,6 +43,8 @@ import org.jetbrains.jewel.ui.icons.AllIconsKeys * Displays a collapsible summary of the current plan above the input box. * Uses Jewel theming and components for native IntelliJ look and feel. */ +private val planSummaryLogger = com.intellij.openapi.diagnostic.Logger.getInstance("IdeaPlanSummaryBar") + @Composable fun IdeaPlanSummaryBar( plan: AgentPlan?, @@ -50,8 +52,12 @@ fun IdeaPlanSummaryBar( onViewDetails: (() -> Unit)? = null, onDismiss: (() -> Unit)? = null ) { + // Log for debugging + planSummaryLogger.info("IdeaPlanSummaryBar called: plan=${plan != null}, tasks=${plan?.tasks?.size ?: 0}") + // Don't render if no plan if (plan == null || plan.tasks.isEmpty()) { + planSummaryLogger.info("IdeaPlanSummaryBar: not rendering (plan is null or empty)") return } From 60017ee0ad96a46ff6b7a92bd48ddf5563ef9601 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Thu, 4 Dec 2025 08:26:43 +0800 Subject: [PATCH 3/5] fix(plan): trigger StateFlow update after plan changes Update the plan instance with a new timestamp to ensure StateFlow emits changes when task or step status is updated. --- .../kotlin/cc/unitmesh/agent/plan/PlanStateService.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/PlanStateService.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/PlanStateService.kt index 7c38668346..5807f8cb75 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/PlanStateService.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/PlanStateService.kt @@ -94,6 +94,8 @@ class PlanStateService { val plan = _currentPlan.value ?: return val task = plan.getTask(taskId) ?: return task.updateStatus(status) + // Trigger StateFlow update by creating a new plan instance with updated timestamp + _currentPlan.value = plan.copy(updatedAt = kotlinx.datetime.Clock.System.now().toEpochMilliseconds()) notifyTaskUpdated(task) } @@ -103,6 +105,8 @@ class PlanStateService { fun completeStep(taskId: String, stepId: String) { val plan = _currentPlan.value ?: return plan.completeStep(taskId, stepId) + // Trigger StateFlow update by creating a new plan instance with updated timestamp + _currentPlan.value = plan.copy(updatedAt = kotlinx.datetime.Clock.System.now().toEpochMilliseconds()) notifyStepCompleted(taskId, stepId) } @@ -113,6 +117,8 @@ class PlanStateService { val plan = _currentPlan.value ?: return val task = plan.getTask(taskId) ?: return task.updateStepStatus(stepId, status) + // Trigger StateFlow update by creating a new plan instance with updated timestamp + _currentPlan.value = plan.copy(updatedAt = kotlinx.datetime.Clock.System.now().toEpochMilliseconds()) notifyTaskUpdated(task) } From 2a5de6657720143334e82ddcacf449944a59d016 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Thu, 4 Dec 2025 08:57:48 +0800 Subject: [PATCH 4/5] feat(vscode): implement plan functionality for mpp-vscode - Add observePlanState method to JsCodingAgent for observing PlanStateService state changes - Extend JsPlanSummaryData with JsTaskSummary and JsStepSummary for complete task/step info - Add plan state observer in ChatViewProvider to sync plan updates to webview - Add planUpdate/planCleared message handling in App.tsx - Pass currentPlan prop to ChatInput component for PlanSummaryBar display - Add ws as external dependency in esbuild config --- .../cc/unitmesh/agent/CodingAgentExports.kt | 29 +++++++ .../cc/unitmesh/agent/RendererExports.kt | 54 ++++++++++++- mpp-vscode/package.json | 2 +- mpp-vscode/src/providers/chat-view.ts | 75 +++++++++++++++++++ mpp-vscode/webview/src/App.tsx | 19 +++++ mpp-vscode/webview/src/hooks/useVSCode.ts | 3 + 6 files changed, 179 insertions(+), 3 deletions(-) diff --git a/mpp-core/src/jsMain/kotlin/cc/unitmesh/agent/CodingAgentExports.kt b/mpp-core/src/jsMain/kotlin/cc/unitmesh/agent/CodingAgentExports.kt index 613cf70af9..932b71fe9f 100644 --- a/mpp-core/src/jsMain/kotlin/cc/unitmesh/agent/CodingAgentExports.kt +++ b/mpp-core/src/jsMain/kotlin/cc/unitmesh/agent/CodingAgentExports.kt @@ -4,6 +4,7 @@ import cc.unitmesh.agent.config.JsToolConfigFile import cc.unitmesh.agent.render.DefaultCodingAgentRenderer import cc.unitmesh.llm.JsMessage import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch import kotlinx.coroutines.promise import kotlin.js.Promise @@ -182,6 +183,34 @@ class JsCodingAgent( JsMessage(msg.role.name.lowercase(), msg.content) }.toTypedArray() } + + /** + * Observe plan state changes and call the callback with plan summary data. + * Returns a function to stop observing. + */ + @JsName("observePlanState") + fun observePlanState(callback: (JsPlanSummaryData?) -> Unit): () -> Unit { + val planStateService = agent.getPlanStateService() + if (planStateService == null) { + return { } + } + + var job: kotlinx.coroutines.Job? = null + job = GlobalScope.launch { + planStateService.currentPlan.collect { plan -> + if (plan != null) { + val summary = cc.unitmesh.agent.plan.PlanSummaryData.from(plan) + callback(JsPlanSummaryData.from(summary)) + } else { + callback(null) + } + } + } + + return { + job.cancel() + } + } } diff --git a/mpp-core/src/jsMain/kotlin/cc/unitmesh/agent/RendererExports.kt b/mpp-core/src/jsMain/kotlin/cc/unitmesh/agent/RendererExports.kt index 3003b813a5..e4ee398821 100644 --- a/mpp-core/src/jsMain/kotlin/cc/unitmesh/agent/RendererExports.kt +++ b/mpp-core/src/jsMain/kotlin/cc/unitmesh/agent/RendererExports.kt @@ -1,9 +1,57 @@ package cc.unitmesh.agent import cc.unitmesh.agent.plan.PlanSummaryData +import cc.unitmesh.agent.plan.StepSummary +import cc.unitmesh.agent.plan.TaskSummary import cc.unitmesh.agent.render.CodingAgentRenderer import kotlin.js.JsExport +/** + * JS-friendly step summary data + */ +@JsExport +data class JsStepSummary( + val id: String, + val description: String, + val status: String +) { + companion object { + fun from(step: StepSummary): JsStepSummary { + return JsStepSummary( + id = step.id, + description = step.description, + status = step.status.name + ) + } + } +} + +/** + * JS-friendly task summary data + */ +@JsExport +data class JsTaskSummary( + val id: String, + val title: String, + val status: String, + val completedSteps: Int, + val totalSteps: Int, + val steps: Array +) { + companion object { + fun from(task: TaskSummary): JsTaskSummary { + return JsTaskSummary( + id = task.id, + title = task.title, + status = task.status.name, + completedSteps = task.completedSteps, + totalSteps = task.totalSteps, + steps = task.steps.map { JsStepSummary.from(it) }.toTypedArray() + ) + } + } +} + /** * JS-friendly plan summary data */ @@ -16,7 +64,8 @@ data class JsPlanSummaryData( val failedSteps: Int, val progressPercent: Int, val status: String, - val currentStepDescription: String? + val currentStepDescription: String?, + val tasks: Array ) { companion object { fun from(summary: PlanSummaryData): JsPlanSummaryData { @@ -28,7 +77,8 @@ data class JsPlanSummaryData( failedSteps = summary.failedSteps, progressPercent = summary.progressPercent, status = summary.status.name, - currentStepDescription = summary.currentStepDescription + currentStepDescription = summary.currentStepDescription, + tasks = summary.tasks.map { JsTaskSummary.from(it) }.toTypedArray() ) } } diff --git a/mpp-vscode/package.json b/mpp-vscode/package.json index b44643e994..86f202abfb 100644 --- a/mpp-vscode/package.json +++ b/mpp-vscode/package.json @@ -138,7 +138,7 @@ "scripts": { "vscode:prepublish": "npm run build", "build": "npm run build:extension && npm run build:webview", - "build:extension": "esbuild ./src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node", + "build:extension": "esbuild ./src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --external:ws --format=cjs --platform=node", "build:webview": "cd webview && npm install && npm run build", "watch": "npm run build:extension -- --watch", "package": "vsce package", diff --git a/mpp-vscode/src/providers/chat-view.ts b/mpp-vscode/src/providers/chat-view.ts index 0ed099ccc2..41b236471b 100644 --- a/mpp-vscode/src/providers/chat-view.ts +++ b/mpp-vscode/src/providers/chat-view.ts @@ -30,6 +30,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { private isExecuting = false; private messages: Array<{ role: string; content: string }> = []; private editorChangeDisposable: vscode.Disposable | undefined; + private planStateUnsubscribe: (() => void) | null = null; constructor( private readonly context: vscode.ExtensionContext, @@ -329,9 +330,46 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { ); this.log(`CodingAgent initialized for workspace: ${workspacePath}`); + + // Start observing plan state changes + this.startPlanStateObserver(); + return this.codingAgent; } + /** + * Start observing plan state changes from CodingAgent + * Mirrors IdeaAgentViewModel.startPlanStateObserver() + */ + private startPlanStateObserver(): void { + // Cancel any existing observer + if (this.planStateUnsubscribe) { + this.planStateUnsubscribe(); + this.planStateUnsubscribe = null; + } + + if (!this.codingAgent) { + return; + } + + try { + // Use the observePlanState method from JsCodingAgent + this.planStateUnsubscribe = this.codingAgent.observePlanState((planSummary: any) => { + if (planSummary) { + this.postMessage({ + type: 'planUpdate', + data: this.convertPlanSummary(planSummary) + }); + } else { + this.postMessage({ type: 'planCleared' }); + } + }); + this.log('Plan state observer started'); + } catch (error) { + this.log(`Failed to start plan state observer: ${error}`); + } + } + /** * Create renderer that forwards events to webview * Mirrors TuiRenderer from mpp-ui @@ -392,10 +430,47 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { addLiveTerminal: () => {}, forceStop: () => { self.postMessage({ type: 'taskComplete', data: { success: false, message: 'Stopped' } }); + }, + // Plan summary rendering - sends plan data to webview + renderPlanSummary: (summary: any) => { + self.postMessage({ + type: 'planUpdate', + data: self.convertPlanSummary(summary) + }); } }; } + /** + * Convert JsPlanSummaryData to webview-compatible format + */ + private convertPlanSummary(summary: any): any { + if (!summary) return null; + + return { + planId: summary.planId, + title: summary.title, + totalSteps: summary.totalSteps, + completedSteps: summary.completedSteps, + failedSteps: summary.failedSteps, + progressPercent: summary.progressPercent, + status: summary.status, + currentStepDescription: summary.currentStepDescription, + tasks: Array.from(summary.tasks || []).map((task: any) => ({ + id: task.id, + title: task.title, + status: task.status, + completedSteps: task.completedSteps, + totalSteps: task.totalSteps, + steps: Array.from(task.steps || []).map((step: any) => ({ + id: step.id, + description: step.description, + status: step.status + })) + })) + }; + } + /** * Handle user message * Mirrors IdeaAgentViewModel.executeTask() diff --git a/mpp-vscode/webview/src/App.tsx b/mpp-vscode/webview/src/App.tsx index be8cf68a3d..e071b9e315 100644 --- a/mpp-vscode/webview/src/App.tsx +++ b/mpp-vscode/webview/src/App.tsx @@ -11,6 +11,7 @@ import { ChatInput } from './components/ChatInput'; import { ModelConfig } from './components/ModelSelector'; import { SelectedFile } from './components/FileChip'; import { CompletionItem } from './components/CompletionPopup'; +import { PlanData } from './components/plan'; import { useVSCode, ExtensionMessage } from './hooks/useVSCode'; import type { AgentState, ToolCallInfo, TerminalOutput, ToolCallTimelineItem } from './types/timeline'; import './App.css'; @@ -53,6 +54,9 @@ const App: React.FC = () => { const [completionItems, setCompletionItems] = useState([]); const [completionResult, setCompletionResult] = useState(null); + // Plan state - mirrors mpp-idea's IdeaPlanSummaryBar + const [currentPlan, setCurrentPlan] = useState(null); + const { postMessage, onMessage, isVSCode } = useVSCode(); // Handle messages from extension @@ -253,6 +257,20 @@ const App: React.FC = () => { }); } break; + + // Plan update from mpp-core PlanStateService + case 'planUpdate': + if (msg.data) { + setCurrentPlan(msg.data as unknown as PlanData); + } else { + setCurrentPlan(null); + } + break; + + // Plan cleared + case 'planCleared': + setCurrentPlan(null); + break; } }, []); @@ -434,6 +452,7 @@ const App: React.FC = () => { currentConfigName={configState.currentConfigName} totalTokens={totalTokens} activeFile={activeFile} + currentPlan={currentPlan} /> ); diff --git a/mpp-vscode/webview/src/hooks/useVSCode.ts b/mpp-vscode/webview/src/hooks/useVSCode.ts index 779e5df12f..fa163adfca 100644 --- a/mpp-vscode/webview/src/hooks/useVSCode.ts +++ b/mpp-vscode/webview/src/hooks/useVSCode.ts @@ -48,6 +48,9 @@ export interface ExtensionMessage { // Completion events | 'completionsResult' | 'completionApplied' + // Plan events + | 'planUpdate' + | 'planCleared' // Error and control | 'error' | 'historyCleared'; From bce4dfc1f7fc2565cb3aa2699d4e94f6e51e060c Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Thu, 4 Dec 2025 09:16:02 +0800 Subject: [PATCH 5/5] fix: address PR #40 review comments - Add error handling for plan markdown parsing in JewelRenderer - Restore OutputDisplay in IdeaTerminalRenderer (was commented out) - Fix root-level files edge case in IdeaFileChangeItem - Cancel planStateObserverJob in IdeaAgentViewModel.dispose() - Add dispose() method to ChatViewProvider for cleanup - Add type guard validation for PlanData in App.tsx - Replace println with Logger in IdeaFileChangeSummary - Simplify StateFlow update in PlanStateService (use direct reassignment) --- .../unitmesh/agent/plan/PlanStateService.kt | 16 +++++++-------- .../devins/idea/renderer/JewelRenderer.kt | 11 +++++++--- .../renderer/sketch/IdeaTerminalRenderer.kt | 6 +++--- .../idea/toolwindow/IdeaAgentViewModel.kt | 1 + .../toolwindow/changes/IdeaFileChangeItem.kt | 4 ++-- .../changes/IdeaFileChangeSummary.kt | 5 ++++- mpp-vscode/src/providers/chat-view.ts | 13 +++++++++++- mpp-vscode/webview/src/App.tsx | 20 +++++++++++++++++-- 8 files changed, 56 insertions(+), 20 deletions(-) diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/PlanStateService.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/PlanStateService.kt index 5807f8cb75..1baa8de80a 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/PlanStateService.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/plan/PlanStateService.kt @@ -94,22 +94,22 @@ class PlanStateService { val plan = _currentPlan.value ?: return val task = plan.getTask(taskId) ?: return task.updateStatus(status) - // Trigger StateFlow update by creating a new plan instance with updated timestamp - _currentPlan.value = plan.copy(updatedAt = kotlinx.datetime.Clock.System.now().toEpochMilliseconds()) + // Trigger StateFlow update by reassigning the plan object + _currentPlan.value = plan notifyTaskUpdated(task) } - + /** * Complete a step within a task. */ fun completeStep(taskId: String, stepId: String) { val plan = _currentPlan.value ?: return plan.completeStep(taskId, stepId) - // Trigger StateFlow update by creating a new plan instance with updated timestamp - _currentPlan.value = plan.copy(updatedAt = kotlinx.datetime.Clock.System.now().toEpochMilliseconds()) + // Trigger StateFlow update by reassigning the plan object + _currentPlan.value = plan notifyStepCompleted(taskId, stepId) } - + /** * Update a step's status. */ @@ -117,8 +117,8 @@ class PlanStateService { val plan = _currentPlan.value ?: return val task = plan.getTask(taskId) ?: return task.updateStepStatus(stepId, status) - // Trigger StateFlow update by creating a new plan instance with updated timestamp - _currentPlan.value = plan.copy(updatedAt = kotlinx.datetime.Clock.System.now().toEpochMilliseconds()) + // Trigger StateFlow update by reassigning the plan object + _currentPlan.value = plan notifyTaskUpdated(task) } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt index b5399e11c8..e171325739 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt @@ -323,9 +323,14 @@ class JewelRenderer : BaseRenderer() { when (action) { "CREATE", "UPDATE" -> { if (planMarkdown.isNotBlank()) { - val plan = MarkdownPlanParser.parseToPlan(planMarkdown) - jewelRendererLogger.info("Parsed plan: ${plan.tasks.size} tasks") - _currentPlan.value = plan + try { + val plan = MarkdownPlanParser.parseToPlan(planMarkdown) + jewelRendererLogger.info("Parsed plan: ${plan.tasks.size} tasks") + _currentPlan.value = plan + } catch (e: Exception) { + jewelRendererLogger.warn("Failed to parse plan markdown", e) + // Keep previous valid plan on parse failure + } } } "COMPLETE_STEP" -> { diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaTerminalRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaTerminalRenderer.kt index 6b2a61276a..5b4485dc9f 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaTerminalRenderer.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaTerminalRenderer.kt @@ -77,9 +77,9 @@ fun IdeaTerminalRenderer( // Command display CommandDisplay(command = command, isDangerous = isDangerous, dangerReason = dangerReason) // Output (if available) -// if (showOutput && executionResult != null) { -// OutputDisplay(result = executionResult!!) -// } + if (showOutput && executionResult != null) { + OutputDisplay(result = executionResult!!) + } } } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt index 35584f4e9a..4ff5d18950 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentViewModel.kt @@ -576,6 +576,7 @@ class IdeaAgentViewModel( override fun dispose() { currentJob?.cancel() + planStateObserverJob?.cancel() coroutineScope.cancel() } } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/changes/IdeaFileChangeItem.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/changes/IdeaFileChangeItem.kt index aa14d682ce..88f92d7b1d 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/changes/IdeaFileChangeItem.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/changes/IdeaFileChangeItem.kt @@ -89,8 +89,8 @@ fun IdeaFileChangeItem( ) // Parent path - val parentPath = change.filePath.substringBeforeLast('/') - if (parentPath.isNotEmpty()) { + val parentPath = change.filePath.substringBeforeLast('/', "") + if (parentPath.isNotEmpty() && parentPath != change.filePath) { Text( text = parentPath.substringAfterLast('/'), style = JewelTheme.defaultTextStyle.copy( diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/changes/IdeaFileChangeSummary.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/changes/IdeaFileChangeSummary.kt index fd19ccdbb9..b98f48abbc 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/changes/IdeaFileChangeSummary.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/changes/IdeaFileChangeSummary.kt @@ -23,6 +23,7 @@ import cc.unitmesh.agent.diff.FileChangeTracker import cc.unitmesh.devins.ui.compose.theme.AutoDevColors import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.runWriteAction +import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.LocalFileSystem @@ -32,6 +33,8 @@ import org.jetbrains.jewel.ui.component.IconButton import org.jetbrains.jewel.ui.component.Text import org.jetbrains.jewel.ui.icons.AllIconsKeys +private val logger = Logger.getInstance("IdeaFileChangeSummary") + /** * File Change Summary Component for IntelliJ IDEA using Jewel components. * @@ -254,7 +257,7 @@ private fun undoChange(project: Project, change: FileChange) { } } } catch (e: Exception) { - println("Failed to undo change for ${change.filePath}: ${e.message}") + logger.error("Failed to undo change for ${change.filePath}", e) } } } diff --git a/mpp-vscode/src/providers/chat-view.ts b/mpp-vscode/src/providers/chat-view.ts index 41b236471b..6756e7ec02 100644 --- a/mpp-vscode/src/providers/chat-view.ts +++ b/mpp-vscode/src/providers/chat-view.ts @@ -21,7 +21,7 @@ const KotlinCC = MppCore.cc.unitmesh; /** * Chat View Provider for the sidebar webview */ -export class ChatViewProvider implements vscode.WebviewViewProvider { +export class ChatViewProvider implements vscode.WebviewViewProvider, vscode.Disposable { private webviewView: vscode.WebviewView | undefined; private codingAgent: any = null; private llmService: any = null; @@ -32,6 +32,17 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { private editorChangeDisposable: vscode.Disposable | undefined; private planStateUnsubscribe: (() => void) | null = null; + /** + * Dispose of resources when the provider is no longer needed. + */ + dispose(): void { + if (this.planStateUnsubscribe) { + this.planStateUnsubscribe(); + this.planStateUnsubscribe = null; + } + this.editorChangeDisposable?.dispose(); + } + constructor( private readonly context: vscode.ExtensionContext, private readonly log: (message: string) => void diff --git a/mpp-vscode/webview/src/App.tsx b/mpp-vscode/webview/src/App.tsx index e071b9e315..2783418eeb 100644 --- a/mpp-vscode/webview/src/App.tsx +++ b/mpp-vscode/webview/src/App.tsx @@ -16,6 +16,22 @@ import { useVSCode, ExtensionMessage } from './hooks/useVSCode'; import type { AgentState, ToolCallInfo, TerminalOutput, ToolCallTimelineItem } from './types/timeline'; import './App.css'; +/** + * Type guard to validate PlanData structure from extension messages. + */ +function isPlanData(data: unknown): data is PlanData { + if (!data || typeof data !== 'object') return false; + const plan = data as Record; + return ( + typeof plan.planId === 'string' && + typeof plan.title === 'string' && + typeof plan.totalSteps === 'number' && + typeof plan.completedSteps === 'number' && + typeof plan.progressPercent === 'number' && + Array.isArray(plan.tasks) + ); +} + interface ConfigState { availableConfigs: ModelConfig[]; currentConfigName: string | null; @@ -260,8 +276,8 @@ const App: React.FC = () => { // Plan update from mpp-core PlanStateService case 'planUpdate': - if (msg.data) { - setCurrentPlan(msg.data as unknown as PlanData); + if (msg.data && isPlanData(msg.data)) { + setCurrentPlan(msg.data); } else { setCurrentPlan(null); }