Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import cc.unitmesh.devins.idea.toolwindow.codereview.IdeaCodeReviewViewModel
import cc.unitmesh.devins.idea.toolwindow.header.IdeaAgentTabsHeader
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.getEffectiveProjectId
import cc.unitmesh.devins.idea.toolwindow.status.IdeaToolLoadingStatusBar
import cc.unitmesh.devins.idea.toolwindow.timeline.IdeaEmptyStateMessage
import cc.unitmesh.devins.idea.toolwindow.timeline.IdeaTimelineContent
Expand Down Expand Up @@ -77,6 +80,13 @@ fun IdeaAgentApp(
// Knowledge ViewModel (created lazily when needed)
var knowledgeViewModel by remember { mutableStateOf<IdeaKnowledgeViewModel?>(null) }

// Remote Agent ViewModel (created lazily when needed)
var remoteAgentViewModel by remember { mutableStateOf<IdeaRemoteAgentViewModel?>(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
LaunchedEffect(timeline.size, streamingOutput) {
if (timeline.isNotEmpty() || streamingOutput.isNotEmpty()) {
Expand All @@ -87,14 +97,21 @@ fun IdeaAgentApp(
}
}

// Create CodeReviewViewModel when switching to CODE_REVIEW tab
// Create ViewModels when switching tabs
LaunchedEffect(currentAgentType) {
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"
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The server URL is hardcoded to "http://localhost:8080". This should be configurable, either through user preferences, environment variables, or a configuration file. Consider loading this from ConfigManager or providing a UI setting to allow users to change the server URL without modifying code.

Copilot uses AI. Check for mistakes.
)
}
}

// Dispose ViewModels when leaving their tabs
Expand All @@ -108,6 +125,10 @@ fun IdeaAgentApp(
knowledgeViewModel?.dispose()
knowledgeViewModel = null
}
if (currentAgentType != AgentType.REMOTE) {
remoteAgentViewModel?.dispose()
remoteAgentViewModel = null
}
}
}

Expand All @@ -133,13 +154,23 @@ fun IdeaAgentApp(
.weight(1f)
) {
when (currentAgentType) {
AgentType.CODING, AgentType.REMOTE, AgentType.LOCAL_CHAT -> {
AgentType.CODING, AgentType.LOCAL_CHAT -> {
IdeaTimelineContent(
timeline = timeline,
streamingOutput = streamingOutput,
listState = listState
)
}
AgentType.REMOTE -> {
remoteAgentViewModel?.let { vm ->
IdeaRemoteAgentContent(
viewModel = vm,
listState = listState,
onProjectIdChange = { remoteProjectId = it },
onGitUrlChange = { remoteGitUrl = it }
)
} ?: IdeaEmptyStateMessage("Loading Remote Agent...")
}
AgentType.CODE_REVIEW -> {
codeReviewViewModel?.let { vm ->
IdeaCodeReviewContent(
Expand All @@ -159,7 +190,7 @@ fun IdeaAgentApp(
Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp))

// Input area (only for chat-based modes)
if (currentAgentType == AgentType.CODING || currentAgentType == AgentType.REMOTE || currentAgentType == AgentType.LOCAL_CHAT) {
if (currentAgentType == AgentType.CODING || currentAgentType == AgentType.LOCAL_CHAT) {
IdeaDevInInputArea(
project = project,
parentDisposable = viewModel,
Expand All @@ -181,6 +212,39 @@ fun IdeaAgentApp(
)
}

// Remote agent input area
if (currentAgentType == AgentType.REMOTE) {
remoteAgentViewModel?.let { remoteVm ->
val remoteIsExecuting by remoteVm.isExecuting.collectAsState()
val remoteIsConnected by remoteVm.isConnected.collectAsState()

IdeaDevInInputArea(
project = project,
parentDisposable = viewModel,
isProcessing = remoteIsExecuting,
onSend = { task ->
val effectiveProjectId = getEffectiveProjectId(remoteProjectId, remoteGitUrl)
if (effectiveProjectId.isNotBlank()) {
remoteVm.executeTask(effectiveProjectId, task, remoteGitUrl)
} else {
remoteVm.renderer.renderError("Please provide a project or Git URL")
}
Comment on lines +225 to +231
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation logic here only checks if effectiveProjectId is not blank, but doesn't validate whether it's a valid project ID or Git URL format. Additionally, the getEffectiveProjectId function can throw an exception (as noted in another comment). Consider adding more robust validation before calling executeTask, such as checking if the project exists in the available projects list, or validating the Git URL format with a regex pattern.

Copilot uses AI. Check for mistakes.
},
onAbort = { remoteVm.cancelTask() },
workspacePath = project.basePath,
totalTokens = null,
onSettingsClick = { viewModel.setShowConfigDialog(true) },
onAtClick = {},
availableConfigs = availableConfigs,
currentConfigName = currentConfigName,
onConfigSelect = { config ->
viewModel.setActiveConfig(config.name)
},
onConfigureClick = { viewModel.setShowConfigDialog(true) }
)
}
}

// Tool loading status bar
IdeaToolLoadingStatusBar(
viewModel = viewModel,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package cc.unitmesh.devins.idea.toolwindow.remote

import cc.unitmesh.agent.RemoteAgentEvent
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.sse.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlin.time.Duration.Companion.seconds

/**
* Remote Agent Client for IntelliJ IDEA plugin.
*
* Connects to mpp-server and streams agent execution events via SSE.
* This is adapted from mpp-ui's RemoteAgentClient for use in the IDE plugin.
*/
class IdeaRemoteAgentClient(
private val baseUrl: String = "http://localhost:8080"
) {
private val httpClient: HttpClient = HttpClient(CIO) {
install(SSE) {
reconnectionTime = 30.seconds
maxReconnectionAttempts = 3
}

// We handle HTTP errors manually to provide better error messages
// SSE connections need explicit status checking
expectSuccess = false
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The expectSuccess = false setting in the HTTP client configuration is concerning. This means that HTTP error responses (4xx, 5xx) won't throw exceptions automatically, which could lead to silent failures. Consider removing this line to enable proper error handling, or add explicit error checking in the response handling code. If there's a specific reason to keep this, it should be documented with a comment explaining why.

Suggested change
expectSuccess = false

Copilot uses AI. Check for mistakes.

engine {
maxConnectionsCount = 1000
endpoint {
maxConnectionsPerRoute = 100
pipelineMaxSize = 20
keepAliveTime = 5000
connectTimeout = 5000
connectAttempts = 5
}
}
}

private val json = Json {
ignoreUnknownKeys = true
isLenient = true
}

/**
* Health check to verify server is running
*/
suspend fun healthCheck(): HealthResponse {
val response = httpClient.get("$baseUrl/health")
if (!response.status.isSuccess()) {
throw RemoteAgentException("Health check failed: ${response.status}")
}
return json.decodeFromString(response.bodyAsText())
}

/**
* Get list of available projects from server
*/
suspend fun getProjects(): ProjectListResponse {
val response = httpClient.get("$baseUrl/api/projects")
if (!response.status.isSuccess()) {
throw RemoteAgentException("Failed to fetch projects: ${response.status}")
}
return json.decodeFromString(response.bodyAsText())
}

/**
* Execute agent task with SSE streaming
* Returns a Flow of RemoteAgentEvent for reactive processing
*/
fun executeStream(request: RemoteAgentRequest): Flow<RemoteAgentEvent> = flow {
try {
httpClient.sse(
urlString = "$baseUrl/api/agent/stream",
request = {
method = HttpMethod.Post
contentType(ContentType.Application.Json)
setBody(json.encodeToString(RemoteAgentRequest.serializer(), request))
}
) {
// Check HTTP status before processing SSE events
if (!call.response.status.isSuccess()) {
throw RemoteAgentException("Stream connection failed: ${call.response.status}")
}

incoming
.mapNotNull { event ->
event.data?.takeIf { data ->
!data.trim().equals("[DONE]", ignoreCase = true)
}?.let { data ->
val eventType = event.event ?: "message"
RemoteAgentEvent.from(eventType, data)
}
}
.collect { parsedEvent ->
emit(parsedEvent)
}
}
} catch (e: Exception) {
e.printStackTrace()
Copy link

Copilot AI Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The printStackTrace() call should be replaced with proper logging using IntelliJ's logging framework. In IntelliJ plugins, use com.intellij.openapi.diagnostic.Logger for consistent error tracking. Example: private val LOG = Logger.getInstance(IdeaRemoteAgentClient::class.java) and then LOG.error("Stream connection failed", e).

Copilot uses AI. Check for mistakes.
throw RemoteAgentException("Stream connection failed: ${e.message}", e)
}
}

fun close() {
httpClient.close()
}
}

/**
* Request/Response Data Classes
*/
@Serializable
data class RemoteAgentRequest(
val projectId: String,
val task: String,
val llmConfig: LLMConfig? = null,
val gitUrl: String? = null,
val branch: String? = null,
val username: String? = null,
val password: String? = null
)

@Serializable
data class LLMConfig(
val provider: String,
val modelName: String,
val apiKey: String,
val baseUrl: String? = null
)

@Serializable
data class HealthResponse(
val status: String
)

@Serializable
data class ProjectInfo(
val id: String,
val name: String,
val path: String,
val description: String
)

@Serializable
data class ProjectListResponse(
val projects: List<ProjectInfo>
)

/**
* Exception for remote agent errors
*/
class RemoteAgentException(message: String, cause: Throwable? = null) : Exception(message, cause)

Loading
Loading