mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-21 05:52:35 +08:00
fix(jetbrains): open completed plan links
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Open completed plan file links from JetBrains session transcripts.
|
||||
+84
@@ -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<WorkspaceFileDto> {
|
||||
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<String, WorkspaceFileDto>()
|
||||
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 =
|
||||
|
||||
+20
@@ -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<WorkspaceFileDto> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-1
@@ -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()
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+6
-5
@@ -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)
|
||||
|
||||
+4
-1
@@ -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))
|
||||
|
||||
+3
-2
@@ -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<String, MessageView>()
|
||||
|
||||
@@ -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()
|
||||
|
||||
+2
-2
@@ -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)
|
||||
}
|
||||
|
||||
+70
@@ -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)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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) }
|
||||
}
|
||||
|
||||
+5
-4
@@ -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,
|
||||
|
||||
+6
-1
@@ -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()
|
||||
|
||||
+7
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+27
-2
@@ -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<String>()
|
||||
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 <reified T> find(root: Container): T? = findCls(root, T::class.java)
|
||||
|
||||
+1
-1
@@ -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() {
|
||||
|
||||
+15
-3
@@ -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<String>()
|
||||
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 {
|
||||
|
||||
+3
-3
@@ -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)
|
||||
}
|
||||
|
||||
+21
-20
@@ -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 }
|
||||
})
|
||||
|
||||
+17
@@ -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<WorkspaceFileDto>()
|
||||
var openResult = true
|
||||
val fileCalls = mutableListOf<Pair<String, String>>()
|
||||
val opened = mutableListOf<String>()
|
||||
|
||||
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<WorkspaceFileDto> {
|
||||
assertNotEdt("files")
|
||||
fileCalls.add(directory to path)
|
||||
return fileMatches
|
||||
}
|
||||
|
||||
override suspend fun openFile(path: String): Boolean {
|
||||
assertNotEdt("openFile")
|
||||
opened.add(path)
|
||||
return openResult
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Unit> {
|
||||
|
||||
/** 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<WorkspaceFileDto>
|
||||
|
||||
/** Open an absolute backend file path in the IDE. */
|
||||
suspend fun openFile(path: String): Boolean
|
||||
}
|
||||
|
||||
+10
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user