diff --git a/.changeset/jolly-nebula.md b/.changeset/jolly-nebula.md new file mode 100644 index 00000000000..2dda00499c2 --- /dev/null +++ b/.changeset/jolly-nebula.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Open completed plan file links from JetBrains session transcripts. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt index eb1dcd02a75..82fe6605966 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt @@ -15,6 +15,7 @@ import ai.kilocode.backend.workspace.ModelInfo import ai.kilocode.backend.workspace.ProviderData import ai.kilocode.backend.workspace.ProviderInfo import ai.kilocode.backend.workspace.SkillInfo +import ai.kilocode.log.KiloLog import ai.kilocode.rpc.KiloWorkspaceRpcApi import ai.kilocode.rpc.dto.AgentDto import ai.kilocode.rpc.dto.AgentsDto @@ -28,14 +29,28 @@ import ai.kilocode.rpc.dto.ModelLimitDto import ai.kilocode.rpc.dto.ProviderDto import ai.kilocode.rpc.dto.ProvidersDto import ai.kilocode.rpc.dto.SkillDto +import ai.kilocode.rpc.dto.WorkspaceFileDto +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.fileEditor.OpenFileDescriptor +import com.intellij.openapi.project.Project import com.intellij.openapi.components.service import com.intellij.openapi.project.ProjectManager +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.LocalFileSystem import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import java.net.URI +import java.net.URLDecoder +import java.nio.charset.StandardCharsets +import java.nio.file.InvalidPathException +import java.nio.file.Path +import kotlin.coroutines.resume /** * Backend implementation of [KiloWorkspaceRpcApi]. @@ -45,6 +60,9 @@ import kotlinx.coroutines.flow.map * directory (including worktrees) can get a workspace. */ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { + companion object { + private val LOG = KiloLog.create(KiloWorkspaceRpcApiImpl::class.java) + } private val app: KiloBackendAppService get() = service() @@ -83,6 +101,72 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { manager.get(directory).reload() } + override suspend fun files(directory: String, path: String): List { + val item = clean(path) ?: return emptyList() + val file = file(item) ?: return emptyList() + val bases = listOf(directory) + ProjectManager.getInstance().openProjects + .asSequence() + .filter { !it.isDefault } + .mapNotNull { it.basePath } + .filter { it != directory } + .toList() + val paths = if (file.isAbsolute) listOf(file) else bases.mapNotNull { base -> + file(base)?.resolve(file)?.normalize() + } + val found = linkedMapOf() + for (target in paths) { + val vf = LocalFileSystem.getInstance().refreshAndFindFileByPath(target.toString()) ?: continue + found[vf.path] = WorkspaceFileDto(vf.path, vf.name, vf.isDirectory) + } + return found.values.toList() + } + + override suspend fun openFile(path: String): Boolean { + val item = clean(path) ?: return false + val target = file(item)?.takeIf { it.isAbsolute } ?: return false + val vf = LocalFileSystem.getInstance().refreshAndFindFileByPath(target.toString()) ?: return false + val project = project(target) ?: run { + LOG.warn("No project available to open file: $path") + return false + } + navigate(project, vf) + return true + } + + private fun clean(path: String): String? { + val raw = path.trim().takeIf { it.isNotBlank() } ?: return null + return try { + val cut = raw.substringBefore('#').substringBefore('?') + val decoded = if (cut.startsWith("file:")) URI(cut).path else URLDecoder.decode(cut, StandardCharsets.UTF_8) + Path.of(decoded.replace('\\', '/')).normalize().toString() + } catch (e: Exception) { + LOG.debug { "Failed to normalize workspace file path: $path (${e.message})" } + null + } + } + + private fun file(path: String): Path? = try { + Path.of(path).normalize() + } catch (e: InvalidPathException) { + LOG.debug { "Invalid workspace file path: $path (${e.message})" } + null + } + + private suspend fun navigate(project: Project, file: VirtualFile) = suspendCancellableCoroutine { cont -> + ApplicationManager.getApplication().invokeLater({ + OpenFileDescriptor(project, file).navigate(true) + if (cont.isActive) cont.resume(Unit) + }, ModalityState.any()) + } + + private fun project(path: Path): Project? { + val projects = ProjectManager.getInstance().openProjects.filter { !it.isDefault } + return projects.firstOrNull { item -> + val base = item.basePath?.let(::file) ?: return@firstOrNull false + path.startsWith(base) + } ?: projects.firstOrNull() + } + // ------ mapping: domain model → DTO ------ private fun dto(state: KiloWorkspaceState): KiloWorkspaceStateDto = diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt index b63964f745d..ab818b48eaa 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt @@ -5,6 +5,7 @@ package ai.kilocode.client.app import ai.kilocode.rpc.KiloWorkspaceRpcApi import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.WorkspaceFileDto import com.intellij.openapi.components.Service import ai.kilocode.log.KiloLog import fleet.rpc.client.durable @@ -98,4 +99,23 @@ class KiloWorkspaceService internal constructor( } } } + + suspend fun files(directory: String, path: String): List { + return try { + call { files(directory, path) } + } catch (e: Exception) { + LOG.warn("workspace file lookup failed for directory=$directory path=$path", e) + emptyList() + } + } + + suspend fun openPath(directory: String, path: String): Boolean { + val match = files(directory, path).firstOrNull() ?: return false + return try { + call { openFile(match.path) } + } catch (e: Exception) { + LOG.warn("workspace file open failed for path=${match.path}", e) + false + } + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 84804330f8d..28f7762a8ec 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.session import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService +import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState @@ -35,6 +36,7 @@ import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.editor.colors.EditorColorsListener import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.openapi.Disposable +import com.intellij.openapi.components.service import com.intellij.openapi.options.Configurable import com.intellij.openapi.options.ConfigurableWithId import com.intellij.openapi.options.ShowSettingsUtil @@ -64,6 +66,7 @@ class SessionUi( ref: SessionRef? = null, displayMs: Long = SessionController.DISPLAY_DELAY_MS, private val manager: SessionManager? = null, + private val workspaces: KiloWorkspaceService = service(), ) : JPanel(BorderLayout()), Disposable, SessionEditorStyleTarget { companion object { @@ -71,6 +74,7 @@ class SessionUi( } private val project = project + private val workspace = workspace private val app = app private var opening = ref != null private var pending = false @@ -185,7 +189,7 @@ class SessionUi( reply = { id, dto -> controller.replyPermission(id, dto) }, ) login = LoginRequiredView(openProfile = { controller.openProfile() }, dismiss = { controller.dismissLoginRequired() }) - messageBody = SessionMessageListPanel(controller.model, this, question, permission, login) + messageBody = SessionMessageListPanel(controller.model, this, question, permission, login, ::openFile) header = SessionHeaderPanel(controller, this) scroll = SessionScroll(root, sessionContent, messageBody, blankBody) @@ -354,6 +358,12 @@ class SessionUi( prompt.clear() } + private fun openFile(path: String) { + cs.launch { + workspaces.openPath(workspace.directory, path) + } + } + private fun onStateChanged(state: SessionState) { prompt.setBusy(state.isBusy()) refresh() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index 4c5e49f4ae1..cad61f48fef 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -46,6 +46,7 @@ class SessionMessageListPanel( private val question: QuestionView? = null, private val permission: PermissionView? = null, private val login: LoginRequiredView? = null, + private val openFile: (String) -> Unit, ) : SessionLayoutPanel( JBUI.scale(SessionUiStyle.SessionLayout.GAP), JBUI.insets( @@ -173,7 +174,7 @@ class SessionMessageListPanel( // ------ private event handlers ------ private fun onTurnAdded(turn: ai.kilocode.client.session.model.Turn) { - val tv = TurnView(turn.id, style) + val tv = TurnView(turn.id, openFile, style) turnViews[turn.id] = tv for (msgId in turn.messageIds) { val msg = model.message(msgId) ?: continue @@ -224,7 +225,7 @@ class SessionMessageListPanel( removeAll() for (turn in model.turns()) { - val tv = TurnView(turn.id, style) + val tv = TurnView(turn.id, openFile, style) turnViews[turn.id] = tv for (msgId in turn.messageIds) { val msg = model.message(msgId) ?: continue diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt index e2db58c0c7e..287f23833fd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt @@ -27,12 +27,13 @@ import com.intellij.util.ui.JBUI */ class MessageView( val msg: Message, + private val openFile: (String) -> Unit, private var style: SessionEditorStyle = SessionEditorStyle.current(), ) : ai.kilocode.client.session.ui.SessionLayoutPanel( JBUI.scale(SessionUiStyle.SessionLayout.GAP), ), SessionEditorStyleTarget, SessionView { - constructor(msg: Message) : this(msg, SessionEditorStyle.current()) + constructor(msg: Message, openFile: (String) -> Unit) : this(msg, openFile, SessionEditorStyle.current()) val role: String get() = msg.info.role @@ -54,7 +55,7 @@ class MessageView( for ((_, content) in msg.parts) { if (content is StepFinish) continue if (isHidden(content)) continue - val view = ViewFactory.create(content) + val view = ViewFactory.create(content, openFile) view.applyStyle(style) parts[content.id] = view add(view) @@ -94,7 +95,7 @@ class MessageView( refresh() return } - val view = ViewFactory.create(content) + val view = ViewFactory.create(content, openFile) view.applyStyle(style) parts[content.id] = view add(view) @@ -106,7 +107,7 @@ class MessageView( val at = components.indexOfFirst { it === existing }.takeIf { it >= 0 } ?: componentCount parts.remove(content.id) remove(existing) - val view = ViewFactory.create(content) + val view = ViewFactory.create(content, openFile) view.applyStyle(style) parts[content.id] = view add(view, at) @@ -144,7 +145,7 @@ class MessageView( for ((_, content) in msg.parts) { if (content is StepFinish) continue if (isHidden(content)) continue - val view = ViewFactory.create(content) + val view = ViewFactory.create(content, openFile) view.applyStyle(style) parts[content.id] = view add(view) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt index 6c5c31f1cf1..aefb8d5fcb7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt @@ -9,7 +9,7 @@ import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.ui.md.MdView import java.awt.BorderLayout -class PlanExitView(tool: Tool) : PartView() { +class PlanExitView(tool: Tool, openFile: (String) -> Unit) : PartView() { companion object { fun canRender(tool: Tool): Boolean = tool.name == "plan_exit" && tool.state == ToolExecState.COMPLETED } @@ -22,6 +22,7 @@ class PlanExitView(tool: Tool) : PartView() { init { layout = BorderLayout() isOpaque = false + md.addLinkListener { openFile(it.href) } add(md.component, BorderLayout.CENTER) applyStyle(SessionEditorStyle.current()) sync() @@ -43,6 +44,8 @@ class PlanExitView(tool: Tool) : PartView() { fun markdown(): String = md.markdown() + internal fun simulateLink(href: String) = md.simulateLink(href) + private fun sync() { val plan = plan(item) val text = listOf(KiloBundle.message("session.part.plan.ready"), link(plan)) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt index 57538a8f19d..2bea4ff421e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt @@ -18,10 +18,11 @@ import com.intellij.util.ui.JBUI */ class TurnView( val id: String, + private val openFile: (String) -> Unit, private var style: SessionEditorStyle = SessionEditorStyle.current(), ) : SessionLayoutPanel(JBUI.scale(SessionUiStyle.SessionLayout.GAP)), SessionEditorStyleTarget { - constructor(id: String) : this(id, SessionEditorStyle.current()) + constructor(id: String, openFile: (String) -> Unit) : this(id, openFile, SessionEditorStyle.current()) private val messages = LinkedHashMap() @@ -31,7 +32,7 @@ class TurnView( /** Add a new [MessageView] for [msg] at the end of this turn. */ fun addMessage(msg: Message): MessageView { - val view = MessageView(msg, style) + val view = MessageView(msg, openFile, style) messages[msg.info.id] = view add(view) revalidate() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt index 9036d8928d3..8d861e02c02 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt @@ -20,11 +20,11 @@ import ai.kilocode.client.session.model.Tool * 3. Add a branch here — the exhaustive `when` will surface the gap as a compile error. */ object ViewFactory { - fun create(content: Content): PartView = when (content) { + fun create(content: Content, openFile: (String) -> Unit): PartView = when (content) { is Text -> TextView(content) is Reasoning -> ReasoningView(content) is Tool -> when { - PlanExitView.canRender(content) -> PlanExitView(content) + PlanExitView.canRender(content) -> PlanExitView(content, openFile) QuestionResultView.canRender(content) -> QuestionResultView(content) else -> ToolView(content) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt new file mode 100644 index 00000000000..b996f0c8153 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt @@ -0,0 +1,70 @@ +package ai.kilocode.client.app + +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.rpc.dto.WorkspaceFileDto +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext + +@Suppress("UnstableApiUsage") +class KiloWorkspaceServiceTest : BasePlatformTestCase() { + private lateinit var scope: CoroutineScope + private lateinit var rpc: FakeWorkspaceRpcApi + private lateinit var service: KiloWorkspaceService + + override fun setUp() { + super.setUp() + scope = CoroutineScope(SupervisorJob()) + rpc = FakeWorkspaceRpcApi() + service = KiloWorkspaceService(scope, rpc) + } + + override fun tearDown() { + try { + scope.cancel() + } finally { + super.tearDown() + } + } + + fun `test openPath opens first file match`() = runBlocking { + rpc.fileMatches = listOf( + WorkspaceFileDto("/test/.kilo/plans/a.md", "a.md"), + WorkspaceFileDto("/other/.kilo/plans/a.md", "a.md"), + ) + + val ok = withContext(Dispatchers.Default) { + service.openPath("/test", ".kilo/plans/a.md") + } + + assertTrue(ok) + assertEquals(listOf("/test" to ".kilo/plans/a.md"), rpc.fileCalls) + assertEquals(listOf("/test/.kilo/plans/a.md"), rpc.opened) + } + + fun `test openPath returns false when no match exists`() = runBlocking { + val ok = withContext(Dispatchers.Default) { + service.openPath("/test", ".kilo/plans/missing.md") + } + + assertFalse(ok) + assertEquals(listOf("/test" to ".kilo/plans/missing.md"), rpc.fileCalls) + assertTrue(rpc.opened.isEmpty()) + } + + fun `test openPath returns false when backend open fails`() = runBlocking { + rpc.fileMatches = listOf(WorkspaceFileDto("/test/.kilo/plans/a.md", "a.md")) + rpc.openResult = false + + val ok = withContext(Dispatchers.Default) { + service.openPath("/test", ".kilo/plans/a.md") + } + + assertFalse(ok) + assertEquals(listOf("/test/.kilo/plans/a.md"), rpc.opened) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt index 74272ac98ca..4d7abbc2326 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt @@ -382,7 +382,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { } created.add(workspace.directory to id) refs.add(ref) - SessionUi(project, workspace, sessions, app, scope, ref = ref, manager = owner).also { + SessionUi(project, workspace, sessions, app, scope, ref = ref, manager = owner, workspaces = workspaces).also { ui.add(it) Disposer.register(it) { ui.remove(it) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt index 75b3c17d393..9fe2e4ef305 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.cancel class SessionUiFactoryTest : BasePlatformTestCase() { private lateinit var scope: CoroutineScope private lateinit var workspace: Workspace + private lateinit var workspaces: KiloWorkspaceService private lateinit var sessions: KiloSessionService private lateinit var app: KiloAppService @@ -33,7 +34,7 @@ class SessionUiFactoryTest : BasePlatformTestCase() { app = KiloAppService(scope, FakeAppRpcApi().also { it.state.value = KiloAppStateDto(KiloAppStatusDto.READY) }) - val workspaces = KiloWorkspaceService(scope, FakeWorkspaceRpcApi().also { + workspaces = KiloWorkspaceService(scope, FakeWorkspaceRpcApi().also { it.state.value = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY) }) workspace = workspaces.workspace("/test") @@ -56,7 +57,7 @@ class SessionUiFactoryTest : BasePlatformTestCase() { fun `test factory wires open callback`() { val manager = FakeManager() val rpc = session("ses_1") - val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager) + val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager, workspaces = workspaces) val controller = controller(ui) com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { @@ -69,7 +70,7 @@ class SessionUiFactoryTest : BasePlatformTestCase() { fun `test empty panel opens through SessionRef via controller`() { val manager = FakeManager() val rpc = session("ses_1") - val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager) + val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager, workspaces = workspaces) val controller = controller(ui) val panel = ai.kilocode.client.session.ui.EmptySessionPanel(testRootDisposable, controller, listOf(rpc)) @@ -81,7 +82,7 @@ class SessionUiFactoryTest : BasePlatformTestCase() { fun `test empty panel show history routes through manager`() { val manager = FakeManager() - val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager) + val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager, workspaces = workspaces) val controller = controller(ui) val panel = ai.kilocode.client.session.ui.EmptySessionPanel( testRootDisposable, diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt index 4b101d140c2..951f15fd5b4 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -280,7 +280,12 @@ class SessionUiLayoutTest : SessionUiTestBase() { fun `test existing session history shows header above scroll pane`() { rpc.history.add(MessageWithPartsDto(message("msg1"), emptyList())) - ui = SessionUi(project, workspace, sessions, app, scope, ref = SessionRef.Local("ses_test"), displayMs = 0).apply { + ui = SessionUi( + project, workspace, sessions, app, scope, + ref = SessionRef.Local("ses_test"), + displayMs = 0, + workspaces = workspaces, + ).apply { setSize(800, 600) } settle() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt index 289094a1fe2..63b4fa7000f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt @@ -88,7 +88,13 @@ abstract class SessionUiTestBase : BasePlatformTestCase() { override fun openSession(ref: SessionRef) = fn(ref) } } - return SessionUi(project, workspace, sessions, app, scope, ref = SessionRef.from(id), displayMs = displayMs, manager = manager).apply { + return SessionUi( + project, workspace, sessions, app, scope, + ref = SessionRef.from(id), + displayMs = displayMs, + manager = manager, + workspaces = workspaces, + ).apply { setSize(800, 600) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index fb6333cde47..f58c869fb16 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -10,6 +10,7 @@ import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.model.ToolCallRef import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.views.LoginRequiredView +import ai.kilocode.client.session.views.PlanExitView import ai.kilocode.client.session.views.permission.PermissionView import ai.kilocode.client.session.views.question.QuestionResultView import ai.kilocode.client.session.views.question.QuestionView @@ -37,12 +38,13 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { private lateinit var model: SessionModel private lateinit var parent: Disposable private lateinit var panel: SessionMessageListPanel + private val openFile: (String) -> Unit = {} override fun setUp() { super.setUp() parent = Disposer.newDisposable("test") model = SessionModel() - panel = SessionMessageListPanel(model, parent) + panel = SessionMessageListPanel(model, parent, openFile = openFile) } override fun tearDown() { @@ -449,6 +451,29 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { assertEquals(listOf("tp1"), mv.partIds()) } + fun `test completed plan update replaces tool view and keeps open file action`() { + val opened = mutableListOf() + val item = SessionMessageListPanel(model, parent, openFile = { opened.add(it) }) + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", toolPart("tp1", "a1", "plan_exit", "call1", state = "running")) + + val mv = item.findMessage("a1")!! + assertTrue(mv.part("tp1") is ToolView) + + model.updateContent( + "a1", + toolPart( + "tp1", "a1", "plan_exit", "call1", state = "completed", + metadata = mapOf("plan" to ".kilo/plans/x.md"), + ), + ) + + val view = mv.part("tp1") as PlanExitView + view.simulateLink(".kilo/plans/x.md") + + assertEquals(listOf(".kilo/plans/x.md"), opened) + } + // ------ helpers ------ private fun panelWithPrompts(): SessionMessageListPanel { @@ -461,7 +486,7 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { reply = { _, _ -> }, ) val l = LoginRequiredView(openProfile = {}, dismiss = {}) - return SessionMessageListPanel(model, parent, q, p, l) + return SessionMessageListPanel(model, parent, q, p, l, openFile) } private inline fun find(root: Container): T? = findCls(root, T::class.java) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt index 161fa553118..e030a761883 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt @@ -28,7 +28,7 @@ class SessionUiUpdateTest : BasePlatformTestCase() { super.setUp() parent = Disposer.newDisposable("test") model = SessionModel() - panel = SessionMessageListPanel(model, parent) + panel = SessionMessageListPanel(model, parent, openFile = {}) } override fun tearDown() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt index c6b104c442b..69e69bdf979 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt @@ -12,14 +12,14 @@ class PlanExitViewTest : BasePlatformTestCase() { metadata = mapOf("plan" to ".kilo/plans/x.md") } - val view = PlanExitView(tool) + val view = PlanExitView(tool) {} assertEquals("Plan is ready [.kilo/plans/x.md](.kilo/plans/x.md)", view.markdown()) } fun `test view factory replaces running tool with plan exit view when completed`() { val running = tool(ToolExecState.RUNNING) - val existing = ViewFactory.create(running) + val existing = ViewFactory.create(running) {} assertTrue(existing is ToolView) val done = tool(ToolExecState.COMPLETED).apply { @@ -27,7 +27,19 @@ class PlanExitViewTest : BasePlatformTestCase() { } assertTrue(ViewFactory.shouldReplace(existing, done)) - assertTrue(ViewFactory.create(done) is PlanExitView) + assertTrue(ViewFactory.create(done) {} is PlanExitView) + } + + fun `test clicking plan link opens href`() { + val opened = mutableListOf() + val tool = tool(ToolExecState.COMPLETED).apply { + metadata = mapOf("plan" to ".kilo/plans/my%20plan.md") + } + + val view = PlanExitView(tool) { opened.add(it) } + view.simulateLink(".kilo/plans/my%20plan.md") + + assertEquals(listOf(".kilo/plans/my%20plan.md"), opened) } private fun tool(state: ToolExecState) = Tool("prt_plan", "plan_exit", toolKind("plan_exit")).apply { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt index 8d03559f920..97182fe0fca 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt @@ -129,7 +129,7 @@ class QuestionResultViewTest : BasePlatformTestCase() { input = mapOf("questions" to """[{"question":"Q1"}]"""), metadata = mapOf("answers" to """[["A1"]]"""), ) - val view = ViewFactory.create(tool) + val view = ViewFactory.create(tool) {} assertTrue(view is QuestionResultView) } @@ -139,14 +139,14 @@ class QuestionResultViewTest : BasePlatformTestCase() { input = emptyMap(), metadata = emptyMap(), ) - val view = ViewFactory.create(tool) + val view = ViewFactory.create(tool) {} assertTrue(view is ToolView) } fun `test view factory falls back to tool view for running question`() { val tool = runningTool("question") - val view = ViewFactory.create(tool) + val view = ViewFactory.create(tool) {} assertTrue(view is ToolView) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt index 8427cce067e..729f876e1f0 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt @@ -17,23 +17,24 @@ import com.intellij.util.ui.JBUI */ @Suppress("UnstableApiUsage") class TurnViewTest : BasePlatformTestCase() { + private val openFile: (String) -> Unit = {} // ------ TurnView ------ fun `test new TurnView is empty`() { - val tv = TurnView("t1") + val tv = TurnView("t1", openFile) assertTrue(tv.messageIds().isEmpty()) } fun `test addMessage appends and returns view`() { - val tv = TurnView("t1") + val tv = TurnView("t1", openFile) val mv = tv.addMessage(msg("u1", "user")) assertEquals("u1", mv.msg.info.id) assertEquals(listOf("u1"), tv.messageIds()) } fun `test addMessage preserves insertion order`() { - val tv = TurnView("t1") + val tv = TurnView("t1", openFile) tv.addMessage(msg("u1", "user")) tv.addMessage(msg("a1", "assistant")) tv.addMessage(msg("a2", "assistant")) @@ -41,7 +42,7 @@ class TurnViewTest : BasePlatformTestCase() { } fun `test messageView returns the view for a given id`() { - val tv = TurnView("t1") + val tv = TurnView("t1", openFile) tv.addMessage(msg("u1", "user")) val mv = tv.messageView("u1") assertNotNull(mv) @@ -49,12 +50,12 @@ class TurnViewTest : BasePlatformTestCase() { } fun `test messageView returns null for unknown id`() { - val tv = TurnView("t1") + val tv = TurnView("t1", openFile) assertNull(tv.messageView("missing")) } fun `test removeMessage removes the view`() { - val tv = TurnView("t1") + val tv = TurnView("t1", openFile) tv.addMessage(msg("u1", "user")) tv.addMessage(msg("a1", "assistant")) @@ -65,14 +66,14 @@ class TurnViewTest : BasePlatformTestCase() { } fun `test removeMessage unknown id is noop`() { - val tv = TurnView("t1") + val tv = TurnView("t1", openFile) tv.addMessage(msg("u1", "user")) tv.removeMessage("nope") assertEquals(listOf("u1"), tv.messageIds()) } fun `test dump produces correct format`() { - val tv = TurnView("u1") + val tv = TurnView("u1", openFile) tv.addMessage(msg("u1", "user")) tv.addMessage(msg("a1", "assistant")) assertEquals("user#u1, assistant#a1", tv.dump()) @@ -81,22 +82,22 @@ class TurnViewTest : BasePlatformTestCase() { // ------ MessageView ------ fun `test new MessageView is empty`() { - val mv = MessageView(msg("u1", "user")) + val mv = MessageView(msg("u1", "user"), openFile) assertTrue(mv.partIds().isEmpty()) } fun `test MessageView for user message has user role`() { - val mv = MessageView(msg("u1", "user")) + val mv = MessageView(msg("u1", "user"), openFile) assertEquals("user", mv.role) } fun `test MessageView for assistant message has assistant role`() { - val mv = MessageView(msg("a1", "assistant")) + val mv = MessageView(msg("a1", "assistant"), openFile) assertEquals("assistant", mv.role) } fun `test upsertPart adds a new TextView for Text content`() { - val mv = MessageView(msg("a1", "assistant")) + val mv = MessageView(msg("a1", "assistant"), openFile) val text = ai.kilocode.client.session.model.Text("p1") text.content.append("hello") mv.upsertPart(text) @@ -106,7 +107,7 @@ class TurnViewTest : BasePlatformTestCase() { } fun `test upsertPart updates existing part rather than adding duplicate`() { - val mv = MessageView(msg("a1", "assistant")) + val mv = MessageView(msg("a1", "assistant"), openFile) val t1 = ai.kilocode.client.session.model.Text("p1").also { it.content.append("v1") } mv.upsertPart(t1) @@ -119,7 +120,7 @@ class TurnViewTest : BasePlatformTestCase() { } fun `test removePart removes the renderer`() { - val mv = MessageView(msg("a1", "assistant")) + val mv = MessageView(msg("a1", "assistant"), openFile) mv.upsertPart(ai.kilocode.client.session.model.Text("p1").also { it.content.append("x") }) mv.removePart("p1") @@ -128,13 +129,13 @@ class TurnViewTest : BasePlatformTestCase() { } fun `test removePart unknown id is noop`() { - val mv = MessageView(msg("a1", "assistant")) + val mv = MessageView(msg("a1", "assistant"), openFile) mv.removePart("none") assertTrue(mv.partIds().isEmpty()) } fun `test appendDelta reaches TextView`() { - val mv = MessageView(msg("a1", "assistant")) + val mv = MessageView(msg("a1", "assistant"), openFile) mv.upsertPart(ai.kilocode.client.session.model.Text("p1").also { it.content.append("hello ") }) mv.appendDelta("p1", "world") @@ -144,7 +145,7 @@ class TurnViewTest : BasePlatformTestCase() { } fun `test appendDelta for unknown part id is noop`() { - val mv = MessageView(msg("a1", "assistant")) + val mv = MessageView(msg("a1", "assistant"), openFile) // Must not throw mv.appendDelta("unknown", "delta") } @@ -154,7 +155,7 @@ class TurnViewTest : BasePlatformTestCase() { val text = ai.kilocode.client.session.model.Text("p1").also { it.content.append("preloaded") } message.parts["p1"] = text - val mv = MessageView(message) + val mv = MessageView(message, openFile) assertEquals(listOf("p1"), mv.partIds()) assertTrue(mv.part("p1") is TextView) @@ -166,7 +167,7 @@ class TurnViewTest : BasePlatformTestCase() { val tool = Tool("t1", "read", toolKind("read")).also { it.state = ToolExecState.COMPLETED } message.parts["r1"] = reasoning message.parts["t1"] = tool - val mv = MessageView(message) + val mv = MessageView(message, openFile) mv.setSize(400, 200) mv.doLayout() @@ -178,7 +179,7 @@ class TurnViewTest : BasePlatformTestCase() { } fun `test consecutive messages use shared compact gap`() { - val tv = TurnView("u1") + val tv = TurnView("u1", openFile) tv.addMessage(msg("u1", "user").also { msg -> msg.parts["t1"] = Tool("t1", "read", toolKind("read")).also { it.state = ToolExecState.COMPLETED } }) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt index c218008a5b1..5b9c6a8c20f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.testing import ai.kilocode.rpc.KiloWorkspaceRpcApi import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.WorkspaceFileDto import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -20,6 +21,10 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { val state = MutableStateFlow(KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING)) var reloads = 0 private set + var fileMatches = emptyList() + var openResult = true + val fileCalls = mutableListOf>() + val opened = mutableListOf() override suspend fun resolveProjectDirectory(hint: String): String { assertNotEdt("resolveProjectDirectory") @@ -35,4 +40,16 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { assertNotEdt("reload") reloads += 1 } + + override suspend fun files(directory: String, path: String): List { + assertNotEdt("files") + fileCalls.add(directory to path) + return fileMatches + } + + override suspend fun openFile(path: String): Boolean { + assertNotEdt("openFile") + opened.add(path) + return openResult + } } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt index f0f84295067..f5c476e5ae9 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt @@ -1,6 +1,7 @@ package ai.kilocode.rpc import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.WorkspaceFileDto import com.intellij.platform.rpc.RemoteApiProviderService import fleet.rpc.RemoteApi import fleet.rpc.Rpc @@ -36,4 +37,10 @@ interface KiloWorkspaceRpcApi : RemoteApi { /** Trigger a full reload of workspace data. */ suspend fun reload(directory: String) + + /** Resolve [path] to matching files, scoped primarily to [directory]. */ + suspend fun files(directory: String, path: String): List + + /** Open an absolute backend file path in the IDE. */ + suspend fun openFile(path: String): Boolean } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorkspaceFileDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorkspaceFileDto.kt new file mode 100644 index 00000000000..1f3a1ac5b7a --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorkspaceFileDto.kt @@ -0,0 +1,10 @@ +package ai.kilocode.rpc.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class WorkspaceFileDto( + val path: String, + val name: String, + val directory: Boolean = false, +)