From cbd4b1cbf3ead7a5311bd673e8d03b18b7129392 Mon Sep 17 00:00:00 2001 From: kirillk Date: Wed, 19 Aug 2026 18:21:10 -0400 Subject: [PATCH 1/9] feat(jetbrains): open sub-agent sessions in editor tabs --- .changeset/subagent-session-tabs.md | 5 + .../kilocode/client/session/SessionManager.kt | 2 + .../ai/kilocode/client/session/SessionUi.kt | 126 +++++++++++------- .../subagent/SubagentSessionEditorHost.kt | 86 ++++++++++++ .../subagent/SubagentSessionEditorKind.kt | 91 +++++++++++++ .../session/subagent/SubagentTitleCache.kt | 22 +++ .../session/ui/SessionMessageListPanel.kt | 5 +- .../session/ui/header/SessionHeaderPanel.kt | 4 +- .../client/session/views/MessageView.kt | 5 +- .../kilocode/client/session/views/TurnView.kt | 3 +- .../client/session/views/ViewFactory.kt | 6 +- .../client/session/views/tool/TaskToolView.kt | 19 +++ .../client/vfs/KiloFileEditorProvider.kt | 2 + .../resources/messages/KiloBundle.properties | 3 + .../client/session/SessionUiLayoutTest.kt | 28 ++++ .../client/session/SessionUiTestBase.kt | 5 +- .../subagent/SubagentSessionEditorHostTest.kt | 93 +++++++++++++ .../subagent/SubagentSessionEditorKindTest.kt | 72 ++++++++++ .../client/session/views/TaskToolViewTest.kt | 31 ++++- 19 files changed, 549 insertions(+), 59 deletions(-) create mode 100644 .changeset/subagent-session-tabs.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/subagent/SubagentSessionEditorHost.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/subagent/SubagentSessionEditorKind.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/subagent/SubagentTitleCache.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/subagent/SubagentSessionEditorHostTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/subagent/SubagentSessionEditorKindTest.kt diff --git a/.changeset/subagent-session-tabs.md b/.changeset/subagent-session-tabs.md new file mode 100644 index 00000000000..9c595acb4f4 --- /dev/null +++ b/.changeset/subagent-session-tabs.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": minor +--- + +Open sub-agent task sessions in read-only editor tabs from JetBrains task cards. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt index 003d413afe0..32e788ce435 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt @@ -31,6 +31,8 @@ interface SessionManager { val hostedInEditorTab: Boolean get() = false + val readonly: Boolean get() = false + fun emptyPanel(parent: Disposable, controller: SessionController): EmptySessionPanel = EmptySessionPanel( parent, controller, 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 1d156f6e6ef..5a5a86976b8 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 @@ -17,6 +17,9 @@ import ai.kilocode.client.session.model.FileAttachment import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.scroll.SessionScroll +import ai.kilocode.client.session.subagent.SubagentSessionEditorKind +import ai.kilocode.client.session.subagent.SubagentTitleCache +import ai.kilocode.client.session.subagent.subagentSessionParams import ai.kilocode.client.session.ui.ConnectionPanel import ai.kilocode.client.session.ui.empty.EmptySessionPanel import ai.kilocode.client.session.ui.LoadingPanel @@ -205,6 +208,7 @@ class SessionUi( private var style = SessionEditorStyle.current() private val selection = SessionSelection() private val popup = HeaderPopupController(timers) + private val readonly: Boolean get() = manager?.readonly == true private val provider = object : TextCopyProvider() { override fun getActionUpdateThread() = ActionUpdateThread.EDT @@ -284,10 +288,11 @@ class SessionUi( val defaultFocusedComponent: JComponent get() { modalFocus?.invoke()?.let { return it } + if (readonly) return scroll.component return prompt.defaultFocusedComponent } - internal val promptFocusedComponent: JComponent get() = prompt.defaultFocusedComponent + internal val promptFocusedComponent: JComponent get() = if (readonly) scroll.component else prompt.defaultFocusedComponent /** * Sends [text] as the session's first message. Used by the New Worktree flow to auto-start a @@ -297,6 +302,7 @@ class SessionUi( */ @RequiresEdt internal fun submitPrompt(text: String, select: PromptSelection? = null) { + if (readonly) return if (text.isBlank()) return // Seed the session's agent/model/reasoning so the pickers and later turns reflect the pick, // then send the first turn carrying it too (so it applies before workspace-ready resolves). @@ -306,7 +312,7 @@ class SessionUi( @RequiresEdt internal fun focusPrompt() { - val target = prompt.defaultFocusedComponent + val target = promptFocusedComponent ApplicationManager.getApplication().invokeLater({ if (!disposed && !project.isDisposed) { IdeFocusManager.getInstance(project).requestFocusInProject(target, project) @@ -356,7 +362,7 @@ class SessionUi( load = LoadingPanel() progressBody = load val focus = { manager?.focusPrompt() ?: focusPrompt() } - question = QuestionView( + val questionView = if (readonly) null else QuestionView( project = project, reply = { id, dto, opts -> controller.replyQuestion(id, dto, opts) }, reject = { id -> controller.rejectQuestion(id) }, @@ -364,18 +370,18 @@ class SessionUi( scroll = { scroll.followBottom(it) }, selection = selection, focus = focus, - ) - permission = PermissionView( + ).also { question = it } + val permissionView = if (readonly) null else PermissionView( reply = { id, dto, rules -> controller.replyPermission(id, dto, rules) }, selection = selection, focus = focus, - ) - login = LoginRequiredView( + ).also { permission = it } + val loginView = if (readonly) null else LoginRequiredView( openProfile = { controller.openProfile() }, dismiss = { controller.dismissLoginRequired() }, selection = selection, focus = focus, - ) + ).also { login = it } outcome = SessionOutcomeView( selection = selection, focus = focus, @@ -383,25 +389,26 @@ class SessionUi( messageBody = SessionMessageListPanel( controller.model, this, - question, - permission, - login, + questionView, + permissionView, + loginView, fileLinks::open, ::openUrl, selection, ::openAttachment, repo = workspace.directory, resize = { anchor, fn -> scroll.preserve(anchor, fn) }, - revert = ::revert, - cancelRevert = ::cancelRevert, - deleteQueued = { id -> controller.deleteQueuedMessage(id) }, - banner = RevertBanner(controller.model, ::redo, controller::redoAll, ::cancelRevert, focus), + revert = if (readonly) null else ::revert, + cancelRevert = if (readonly) null else ::cancelRevert, + deleteQueued = if (readonly) null else { id -> controller.deleteQueuedMessage(id) }, + banner = if (readonly) null else RevertBanner(controller.model, ::redo, controller::redoAll, ::cancelRevert, focus), + onOpenSubagent = ::openSubagent, ).also { it.outcome = outcome it.setDiffOpener(::openInlineDiff, controller.id) it.onHover = { view, on -> if (on) popup.show(view) else popup.notifyExit(view) } } - header = SessionHeaderPanel(controller, this) { openBranchChanges() } + header = SessionHeaderPanel(controller, this, readonly) { openBranchChanges() } scroll = SessionScroll(root, sessionContent, messageBody, blankBody) overlay = SessionHoverCopyOverlay(root, scroll.component, this) @@ -435,6 +442,15 @@ class SessionUi( connection = ConnectionPanel(this, controller) root.addOverlay(connection) { pane, child -> val size = child.preferredSize + if (readonly) { + val gap = SessionUiStyle.View.contentGap() + return@addOverlay java.awt.Rectangle( + gap, + pane.height - size.height - gap, + (pane.width - gap * 2).coerceAtLeast(0), + size.height, + ) + } val point = SwingUtilities.convertPoint(prompt.parent ?: root.content, prompt.x, prompt.y, pane) val gap = SessionUiStyle.View.contentGap() java.awt.Rectangle( @@ -450,51 +466,58 @@ class SessionUi( java.awt.Rectangle(0, 0, pane.width, pane.height) } root.overlay.setComponentZOrder(drop, 0) - prompt.onFileDrag = ::syncDrop - prompt.installFileDrop(root, "session-root") + if (!readonly) { + prompt.onFileDrag = ::syncDrop + prompt.installFileDrop(root, "session-root") + } // The visual overlay returns contains(false) so normal UI remains clickable. // Registering it as a native DnD target makes IntelliJ resolve a null over-component. sessionContent.add(header, BorderLayout.NORTH) sessionContent.add(scroll.component, BorderLayout.CENTER) root.content.add(sessionContent, BorderLayout.CENTER) - root.content.add( - prompt.align( - HAlign.CENTER, - VAlign.FIT, - maxW = { SessionUiStyle.SessionLayout.readableWidth(prompt, style.transcriptFont) }, - ), - BorderLayout.SOUTH, - ) + if (!readonly) { + root.content.add( + prompt.align( + HAlign.CENTER, + VAlign.FIT, + maxW = { SessionUiStyle.SessionLayout.readableWidth(prompt, style.transcriptFont) }, + ), + BorderLayout.SOUTH, + ) + } add(root, BorderLayout.CENTER) } private fun bindUi() { - prompt.mode.onSelect = { item -> controller.selectAgent(item.id) } - prompt.model.onSelect = { item -> - prompt.setAttachmentEnabled(item.attachment) - controller.selectModel(item.provider, item.id) - } - prompt.reasoning.onSelect = { item -> controller.selectVariant(item.id) } - prompt.onReset = { controller.clearModelOverride() } - prompt.onChange = { scroll.refresh() } - prompt.onAutoApproveToggle = { value -> - controller.setAutoApprove(value) + if (!readonly) { + prompt.mode.onSelect = { item -> controller.selectAgent(item.id) } + prompt.model.onSelect = { item -> + prompt.setAttachmentEnabled(item.attachment) + controller.selectModel(item.provider, item.id) + } + prompt.reasoning.onSelect = { item -> controller.selectVariant(item.id) } + prompt.onReset = { controller.clearModelOverride() } + prompt.onChange = { scroll.refresh() } + prompt.onAutoApproveToggle = { value -> + controller.setAutoApprove(value) + prompt.setAutoApprove(controller.autoApprove) + } prompt.setAutoApprove(controller.autoApprove) - } - prompt.setAutoApprove(controller.autoApprove) - prompt.model.favorites = { app.favorites.value } - prompt.model.onFavoriteToggle = { item -> - Telemetry.send( - "Model Favorite Toggled", - mapOf("provider" to item.provider, "modelId" to item.id), - ) - app.toggleModelFavorite(item.provider, item.id) + prompt.model.favorites = { app.favorites.value } + prompt.model.onFavoriteToggle = { item -> + Telemetry.send( + "Model Favorite Toggled", + mapOf("provider" to item.provider, "modelId" to item.id), + ) + app.toggleModelFavorite(item.provider, item.id) + } } controller.addListener(this) { event -> when (event) { is SessionControllerEvent.WorkspaceReady -> { + if (readonly) return@addListener val m = controller.model prompt.mode.setItems(m.agents.map { ModePicker.Item( @@ -558,10 +581,12 @@ class SessionUi( } is SessionControllerEvent.AppChanged -> { + if (readonly) return@addListener prompt.setReady(controller.model.isReady()) } is SessionControllerEvent.WorkspaceChanged -> { + if (readonly) return@addListener prompt.setReady(controller.model.isReady()) } @@ -700,6 +725,7 @@ class SessionUi( } private fun sendPrompt(text: String, files: List, select: PromptSelection? = null) { + if (readonly) return if (text.isBlank() && files.isEmpty()) return prompt.clear() val follow = scroll.following() @@ -862,6 +888,16 @@ class SessionUi( } } + @RequiresEdt + private fun openSubagent(sessionId: String, title: String) { + service().put(sessionId, title) + project.service().open( + SubagentSessionEditorKind.ID, + subagentSessionParams(sessionId, workspace.directory), + ) + Telemetry.send("Subagent Session Opened", mapOf("sessionId" to sessionId)) + } + /** Badge-only refresh: fetches stats (no patch text) and updates the header count. */ private fun refreshBranchChanges() { if (!showBranchBadge()) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/subagent/SubagentSessionEditorHost.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/subagent/SubagentSessionEditorHost.kt new file mode 100644 index 00000000000..86ec8580fe5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/subagent/SubagentSessionEditorHost.kt @@ -0,0 +1,86 @@ +package ai.kilocode.client.session.subagent + +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.app.Workspace +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.SessionActivityKind +import ai.kilocode.client.session.SessionHost +import ai.kilocode.client.session.SessionManager +import ai.kilocode.client.session.SessionRef +import ai.kilocode.client.session.SessionUi +import ai.kilocode.client.session.SessionUiFactory +import ai.kilocode.client.session.controller.SessionController +import ai.kilocode.client.session.ui.empty.EmptySessionPanel +import ai.kilocode.client.util.UiTimerSource +import ai.kilocode.client.util.UiTimers +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.wm.IdeFocusManager +import com.intellij.util.concurrency.annotations.RequiresEdt +import java.awt.BorderLayout +import javax.swing.JComponent +import javax.swing.JPanel + +class SubagentSessionEditorHost( + parent: Disposable, + project: Project, + workspace: Workspace, + create: (Project, Workspace, SessionManager, SessionRef?, UiTimerSource) -> SessionUi = + { project, workspace, manager, ref, timers -> + service().create(project, workspace, manager, ref, timers) + }, + resolve: (String) -> Workspace = { dir -> service().workspace(dir) }, + status: () -> Map = { emptyMap() }, + timers: UiTimerSource = UiTimers, + request: (JComponent) -> Unit = { focus -> + ApplicationManager.getApplication().invokeLater({ + IdeFocusManager.getInstance(project).requestFocusInProject(focus, project) + }, ModalityState.defaultModalityState()) + }, +) : SessionHost(project, workspace, create, resolve, status, timers, request) { + override val readonly: Boolean get() = true + override val hostedInEditorTab: Boolean get() = true + override val showsBranchBadgeInHeader: Boolean get() = false + + val component = JPanel(BorderLayout()) + + init { + Disposer.register(parent, this) + } + + @RequiresEdt + fun open(sessionId: String) { + openSession(SessionRef.Local(sessionId), focus = true) + } + + @RequiresEdt + fun currentFocus(): JComponent? = currentUi()?.defaultFocusedComponent + + @RequiresEdt + override fun newSession() = Unit + + @RequiresEdt + override fun showHistory(back: (() -> Unit)?) = Unit + + @RequiresEdt + override fun emptyPanel(parent: Disposable, controller: SessionController): EmptySessionPanel = EmptySessionPanel( + parent, + controller, + recents = emptyList(), + history = {}, + timers = timers, + minimal = true, + ) + + @RequiresEdt + override fun present(ui: SessionUi?) { + component.removeAll() + if (ui != null) component.add(ui, BorderLayout.CENTER) + component.revalidate() + component.repaint() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/subagent/SubagentSessionEditorKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/subagent/SubagentSessionEditorKind.kt new file mode 100644 index 00000000000..fa32525f949 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/subagent/SubagentSessionEditorKind.kt @@ -0,0 +1,91 @@ +package ai.kilocode.client.session.subagent + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloSessionService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.SessionUi +import ai.kilocode.client.session.SessionUiFactory +import ai.kilocode.client.vfs.KiloEditorKind +import ai.kilocode.client.vfs.KiloEditorKindRegistry +import ai.kilocode.client.vfs.KiloVirtualFile +import com.intellij.icons.AllIcons +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.components.BorderLayoutPanel +import kotlinx.coroutines.cancel +import java.awt.BorderLayout +import javax.swing.Icon +import javax.swing.JComponent + +object SubagentSessionEditorKind : KiloEditorKind { + const val ID = "subagent-session" + + override val id: String = ID + + override fun title(params: Map): String { + val id = params[SESSION]?.takeIf { it.isNotBlank() } ?: return KiloBundle.message("session.subagent.title") + return service().title(id)?.takeIf { it.isNotBlank() } + ?: KiloBundle.message("session.subagent.title") + } + + override fun icon(params: Map): Icon = AllIcons.Nodes.Function + override fun presentablePath(params: Map): String = KiloBundle.message("session.subagent.path", params[SESSION].orEmpty()) + override fun isValid(params: Map): Boolean = !params[SESSION].isNullOrBlank() && !params[DIR].isNullOrBlank() + + @RequiresEdt + override fun preferredFocus(component: JComponent): JComponent? = (component as? SubagentSessionEditorPanel)?.host?.currentFocus() + + @RequiresEdt + override fun createContent(project: Project, file: KiloVirtualFile, parent: Disposable): JComponent { + val id = file.path.params[SESSION]?.takeIf { it.isNotBlank() } ?: return BorderLayoutPanel() + val dir = file.path.params[DIR]?.takeIf { it.isNotBlank() } ?: return BorderLayoutPanel() + val workspace = service().workspace(dir) + val cs = service().scope() + Disposer.register(parent) { cs.cancel() } + val host = SubagentSessionEditorHost( + parent = parent, + project = project, + workspace = workspace, + create = { p, w, manager, ref, timers -> + SessionUi( + project = p, + workspace = w, + sessions = p.service(), + app = service(), + cs = cs, + ref = ref, + manager = manager, + timers = timers, + ) + }, + ) + host.open(id) + return SubagentSessionEditorPanel(host) + } + + private const val SESSION = "sessionId" + private const val DIR = "directory" +} + +class SubagentSessionEditorPanel(val host: SubagentSessionEditorHost) : BorderLayoutPanel() { + init { + add(host.component, BorderLayout.CENTER) + } +} + +fun ensureSubagentSessionEditorKind() { + service().register(SubagentSessionEditorKind) +} + +internal fun unregisterSubagentSessionEditorKind() { + service().unregister(SubagentSessionEditorKind.ID) +} + +internal fun subagentSessionParams(sessionId: String, directory: String): Map = linkedMapOf( + "sessionId" to sessionId, + "directory" to directory, +) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/subagent/SubagentTitleCache.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/subagent/SubagentTitleCache.kt new file mode 100644 index 00000000000..169c0b422b5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/subagent/SubagentTitleCache.kt @@ -0,0 +1,22 @@ +package ai.kilocode.client.session.subagent + +import com.intellij.openapi.components.Service +import com.intellij.util.concurrency.annotations.RequiresEdt + +@Service(Service.Level.APP) +class SubagentTitleCache { + private val names = linkedMapOf() + + @RequiresEdt + fun put(sessionId: String, title: String) { + names[sessionId] = title + } + + @RequiresEdt + fun title(sessionId: String): String? = names[sessionId] + + @RequiresEdt + fun clear() { + names.clear() + } +} 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 d78155783d4..05bce2c5213 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 @@ -66,6 +66,7 @@ class SessionMessageListPanel( private val cancelRevert: (() -> Unit)? = null, private val deleteQueued: ((String) -> Unit)? = null, private val banner: RevertBanner? = null, + private val onOpenSubagent: ((String, String) -> Unit)? = null, ) : SessionLayoutPanel( SessionUiStyle.SessionLayout.GAP, Insets( @@ -297,7 +298,7 @@ class SessionMessageListPanel( // ------ private event handlers ------ private fun onTurnAdded(turn: ai.kilocode.client.session.model.Turn) { - val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover, revert, deleteQueued).also { + val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover, revert, deleteQueued, onOpenSubagent).also { it.setDiffOpener(openDiff, sessionId) } turnViews[turn.id] = tv @@ -366,7 +367,7 @@ class SessionMessageListPanel( removeAll() for (turn in model.turns()) { - val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover, revert, deleteQueued).also { + val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover, revert, deleteQueued, onOpenSubagent).also { it.setDiffOpener(openDiff, sessionId) } turnViews[turn.id] = tv diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt index 73c78212d64..aed22219a94 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt @@ -42,6 +42,7 @@ import javax.swing.SwingUtilities class SessionHeaderPanel( private val controller: SessionController, parent: Disposable, + private val readonly: Boolean = false, onOpenBranchDiff: (() -> Unit)? = null, ) : BorderLayoutPanel(), SessionEditorStyleTarget { @@ -296,7 +297,8 @@ class SessionHeaderPanel( setTokens(header.tokens) syncTodos(header.todos.items) - compact.isEnabled = header.canCompact + compact.isVisible = !readonly + compact.isEnabled = !readonly && header.canCompact val appended = timeline.setItems(header.timeline) sizeTimeline() if (viewport.isVisible != timeline.isVisible) viewport.isVisible = timeline.isVisible 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 1fd304138e4..b12e87d5bff 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 @@ -66,6 +66,7 @@ class MessageView( private val repo: String? = null, private val hover: ((PartView, Boolean) -> Unit)? = null, private val revert: ((String) -> Unit)? = null, + private val onOpenSubagent: ((String, String) -> Unit)? = null, ) : ai.kilocode.client.session.ui.SessionLayoutPanel( SessionUiStyle.SessionLayout.GAP, ), Disposable, SessionEditorStyleTarget, SessionView { @@ -358,9 +359,9 @@ class MessageView( } private fun view(content: Content) = if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) { - ViewFactory.createUser(content, openFile, openUrl, selection, repo, promptMentions(msg), { openAttachment(msg.info.id, it) }, openDiff, sessionId) + ViewFactory.createUser(content, openFile, openUrl, selection, repo, promptMentions(msg), { openAttachment(msg.info.id, it) }, openDiff, sessionId, onOpenSubagent) } else { - ViewFactory.create(content, openFile, openUrl, selection, repo, { openAttachment(msg.info.id, it) }, openDiff, sessionId) + ViewFactory.create(content, openFile, openUrl, selection, repo, { openAttachment(msg.info.id, it) }, openDiff, sessionId, onOpenSubagent) } private fun syncPromptMentions() { 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 ccc2ba53ad9..0d1b42d763c 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 @@ -40,6 +40,7 @@ class TurnView( private val hover: ((PartView, Boolean) -> Unit)? = null, private val revert: ((String) -> Unit)? = null, private val deleteQueued: ((String) -> Unit)? = null, + private val onOpenSubagent: ((String, String) -> Unit)? = null, ) : SessionLayoutPanel(SessionUiStyle.SessionLayout.GAP), Disposable, SessionEditorStyleTarget, SessionView { private val messages = LinkedHashMap() @@ -77,7 +78,7 @@ class TurnView( /** Add a new [MessageView] for [msg] at the end of this turn. */ fun addMessage(msg: Message): MessageView { - val view = MessageView(msg, openFile, style, openUrl, selection, openAttachment, resize, repo, hover, revert).also { + val view = MessageView(msg, openFile, style, openUrl, selection, openAttachment, resize, repo, hover, revert, onOpenSubagent).also { it.setDiffOpener(openDiff, sessionId) } messages[msg.info.id] = view 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 1d24afe0b09..3abf9e229f5 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 @@ -52,6 +52,7 @@ object ViewFactory { openAttachment: (FileAttachment) -> Unit = { AttachmentView.openDefault(it, openFile, openUrl) }, openDiff: SessionDiffOpener = { _, _, _ -> }, sessionId: String? = null, + onOpenSubagent: ((String, String) -> Unit)? = null, ): PartView = when (content) { is Text -> TextView(content, openFile = openFile, openUrl = openUrl, selection = selection) is Reasoning -> ReasoningView(content, openFile = openFile, openUrl = openUrl, selection = selection) @@ -65,7 +66,7 @@ object ViewFactory { SearchToolView.canRender(content) -> SearchToolView(content, selection = selection, repo = repo) ReadToolView.canRender(content) -> ReadToolView(content, openFile, selection = selection) EditToolView.canRender(content) -> EditToolView(content, openFile, selection, openDiff, sessionId) - TaskToolView.canRender(content) -> TaskToolView(content, selection = selection) + TaskToolView.canRender(content) -> TaskToolView(content, selection = selection, onOpenSubagent = onOpenSubagent) else -> ToolView(content, selection = selection) } is Compaction -> CompactionView(content) @@ -94,9 +95,10 @@ object ViewFactory { openAttachment: (FileAttachment) -> Unit = { AttachmentView.openDefault(it, openFile, openUrl) }, openDiff: SessionDiffOpener = { _, _, _ -> }, sessionId: String? = null, + onOpenSubagent: ((String, String) -> Unit)? = null, ): PartView = when (content) { is Text -> PromptView(content, openFile = openFile, openAttachment = openAttachment, openUrl = openUrl, selection = selection, mentions = mentions) - else -> create(content, openFile, openUrl, selection, repo, openAttachment, openDiff, sessionId) + else -> create(content, openFile, openUrl, selection, repo, openAttachment, openDiff, sessionId, onOpenSubagent) } /** diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt index 7d5b91a370b..8741118817f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt @@ -9,9 +9,12 @@ import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.base.AbstractSessionPartView +import ai.kilocode.client.session.views.base.PartHeader +import ai.kilocode.client.ui.HoverIcon import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack import ai.kilocode.client.ui.layout.StackAxis +import com.intellij.icons.AllIcons import com.intellij.openapi.actionSystem.DataSink import com.intellij.openapi.actionSystem.UiDataProvider import com.intellij.ui.components.JBLabel @@ -31,6 +34,7 @@ import kotlin.math.abs class TaskToolView( tool: Tool, private val selection: SessionSelection? = null, + private val onOpenSubagent: ((String, String) -> Unit)? = null, private val parts: ToolParts = toolParts(tool), ) : AbstractSessionPartView(parts.header, { TaskBody(parts.glyph).scroll }), UiDataProvider { @@ -41,8 +45,16 @@ class TaskToolView( private val rows = LinkedHashMap() private var following = false private var collapsed = false + private val open = HoverIcon().apply { + icon = AllIcons.Actions.Preview + cursor = java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR) + toolTipText = KiloBundle.message("session.part.tool.openSubagent") + accessibleContext.accessibleName = KiloBundle.message("session.part.tool.openSubagent") + addActionListener { openSubagent() } + } init { + parts.header.right(PartHeader.centered(open)) applyStyle(style) sync() if (item.childTools.isNotEmpty()) expand() @@ -120,9 +132,16 @@ class TaskToolView( changed = setForeground(parts.title, titleColor(item)) || changed changed = setText(parts.state, stateText(item)) || changed changed = setForeground(parts.state, color(item)) || changed + changed = setVisible(open, item.childSessionId != null && onOpenSubagent != null) || changed return changed } + private fun openSubagent() { + val id = item.childSessionId ?: return + val title = listOf(agentTitle(item), summary(item)).filter { it.isNotBlank() }.joinToString(" - ") + onOpenSubagent?.invoke(id, title) + } + private fun syncRows(): Boolean { if (!hasBody()) return false val body = taskBody() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileEditorProvider.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileEditorProvider.kt index abaec4bd37c..1fd68ea6d72 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileEditorProvider.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileEditorProvider.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.vfs import ai.kilocode.client.agentManager.worktree.ensureWorktreeSessionEditorKind import ai.kilocode.client.diff.ensureDiffEditorKind +import ai.kilocode.client.session.subagent.ensureSubagentSessionEditorKind import ai.kilocode.client.session.ui.attachment.ensureAttachmentEditorKind import com.intellij.openapi.components.service import com.intellij.openapi.fileEditor.FileEditor @@ -42,6 +43,7 @@ class KiloFileEditorProvider : FileEditorProvider, DumbAware { private fun ensureKinds() { ensureAttachmentEditorKind() ensureDiffEditorKind() + ensureSubagentSessionEditorKind() ensureWorktreeSessionEditorKind() } diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 7af9b0b901c..4847fc9a8fc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -155,6 +155,7 @@ diff.overflow.message=This diff is too large to preview here. diff.overflow.open=Open in a diff tab session.part.tool.copy=Copy session.part.tool.openDiff=Open in Diff Viewer +session.part.tool.openSubagent=Open sub-agent in editor session.part.tool.error=Error session.part.tool.agent={0} Agent session.part.tool.pending=Pending @@ -230,6 +231,8 @@ prompt.attachment.missing=Attachment no longer exists: {0} prompt.attachment.send.failed=Failed to send attachment: {0} session.attachment.title=Attachment session.attachment.path=Kilo / Attachments / {0} / {1} +session.subagent.title=Sub-agent Session +session.subagent.path=Kilo / Sub-agents / {0} session.attachment.loading=Loading attachment... session.attachment.missing=Attachment not found session.attachment.unsupported=Cannot preview {0} 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 5225a2998e1..b6485fa6c78 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 @@ -200,6 +200,25 @@ class SessionUiLayoutTest : SessionUiTestBase() { assertSame(messages, pv.parent) } + fun `test readonly session omits prompt and active reply views`() { + val owner = object : SessionManager { + override fun newSession() {} + override fun showHistory(back: (() -> Unit)?) {} + override fun openSession(ref: SessionRef) {} + override val readonly: Boolean get() = true + } + rpc.history.addAll(history(1)) + ui = newUi(id = "ses_test", manager = owner) + settle() + layoutReadonly() + + assertNull(find(ui, PromptPanel::class.java)) + assertNull(find(ui, QuestionView::class.java)) + assertNull(find(ui, PermissionView::class.java)) + assertSame(scrollComponent(), ui.defaultFocusedComponent) + assertTrue(find(ui).parent != null) + } + fun `test header is docked above shared scroll pane and hidden while empty`() { val root = find(ui) val header = find(ui) @@ -784,6 +803,15 @@ class SessionUiLayoutTest : SessionUiTestBase() { private fun promptPoint(root: SessionRootPanel, prompt: PromptPanel) = SwingUtilities.convertPoint(prompt.parent, prompt.x, prompt.y, root.overlay) + private fun layoutReadonly() { + ui.doLayout() + val root = find(ui) + root.doLayout() + root.content.doLayout() + scrollComponent().doLayout() + (scrollView() as? java.awt.Container)?.doLayout() + } + private class Row(override val sessionViewKind: SessionView.Kind) : JPanel(), SessionView { override fun getPreferredSize() = Dimension(100, 10) } 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 b2206018382..62375f8bc80 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 @@ -89,8 +89,9 @@ abstract class SessionUiTestBase : BasePlatformTestCase() { displayMs: Long = 0, open: ((SessionRef) -> Unit)? = null, migration: MigrationUiController = FakeMigrationUiController(), + manager: SessionManager? = null, ): SessionUi { - val manager = open?.let { fn -> + val owner = manager ?: open?.let { fn -> object : SessionManager { override fun newSession() {} override fun showHistory(back: (() -> Unit)?) {} @@ -101,7 +102,7 @@ abstract class SessionUiTestBase : BasePlatformTestCase() { project, workspace, sessions, app, scope, ref = SessionRef.from(id), displayMs = displayMs, - manager = manager, + manager = owner, workspaces = workspaces, migration = migration, ).apply { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/subagent/SubagentSessionEditorHostTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/subagent/SubagentSessionEditorHostTest.kt new file mode 100644 index 00000000000..4fcf03c82e7 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/subagent/SubagentSessionEditorHostTest.kt @@ -0,0 +1,93 @@ +package ai.kilocode.client.session.subagent + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloSessionService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.session.SessionActivityKind +import ai.kilocode.client.session.SessionUi +import ai.kilocode.client.testing.FakeAppRpcApi +import ai.kilocode.client.testing.FakeSessionRpcApi +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.client.testing.TestCoroutines +import ai.kilocode.client.util.UiTimers +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class SubagentSessionEditorHostTest : BasePlatformTestCase() { + private lateinit var coroutines: TestCoroutines + + override fun setUp() { + super.setUp() + coroutines = TestCoroutines() + } + + override fun tearDown() { + try { + coroutines.close() + } finally { + super.tearDown() + } + } + + fun testSubagentHostCapabilities() { + val host = host() + + assertTrue(host.readonly) + assertTrue(host.hostedInEditorTab) + assertFalse(host.showsBranchBadgeInHeader) + } + + fun testOpenPresentsSessionUi() { + val host = host() + + host.open("ses_child") + coroutines.drain() + + assertTrue(host.component.components.any { it is SessionUi }) + assertNotNull(host.currentFocus()) + } + + fun testNewSessionAndHistoryAreNoOps() { + val host = host() + + host.newSession() + host.showHistory() + + assertEquals(0, host.component.componentCount) + } + + private fun host(): SubagentSessionEditorHost { + val sessions = KiloSessionService(project, coroutines.scope, FakeSessionRpcApi()) + val app = KiloAppService(coroutines.scope, FakeAppRpcApi().also { + it.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + }) + val workspaces = KiloWorkspaceService(coroutines.scope, FakeWorkspaceRpcApi().also { + it.state.value = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY) + }) + val workspace = workspaces.workspace("/test") + return SubagentSessionEditorHost( + parent = testRootDisposable, + project = project, + workspace = workspace, + create = { project, workspace, manager, ref, timers -> + SessionUi( + project = project, + workspace = workspace, + sessions = sessions, + app = app, + cs = coroutines.scope, + ref = ref, + manager = manager, + workspaces = workspaces, + timers = timers, + ) + }, + status = { emptyMap() }, + timers = UiTimers, + request = {}, + ) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/subagent/SubagentSessionEditorKindTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/subagent/SubagentSessionEditorKindTest.kt new file mode 100644 index 00000000000..a499a161e78 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/subagent/SubagentSessionEditorKindTest.kt @@ -0,0 +1,72 @@ +package ai.kilocode.client.session.subagent + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.vfs.KiloEditorKindRegistry +import ai.kilocode.client.vfs.KiloPath +import ai.kilocode.client.vfs.KiloVirtualFileKindRegistry +import ai.kilocode.client.vfs.KiloVirtualFileSystem +import com.intellij.openapi.components.service +import com.intellij.openapi.fileTypes.FileTypes +import com.intellij.openapi.vfs.VirtualFilePathWrapper +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class SubagentSessionEditorKindTest : BasePlatformTestCase() { + override fun tearDown() { + try { + service().clear() + } finally { + super.tearDown() + } + } + + fun testSubagentSessionParamsUseStableIdentityFields() { + val params = subagentSessionParams("ses_child", "/repo") + val path = KiloPath(SubagentSessionEditorKind.ID, params).canonical() + val json = KiloVirtualFileSystem.getInstance().getPath(path) + val decoded = KiloVirtualFileSystem.decode(json) + + assertEquals(path, decoded) + assertEquals(SubagentSessionEditorKind.ID, path.kind) + assertEquals("ses_child", params["sessionId"]) + assertEquals("/repo", params["directory"]) + assertFalse(json.contains("title", ignoreCase = true)) + } + + fun testSubagentSessionKindCreatesVirtualFile() { + ensureSubagentSessionEditorKind() + val fs = KiloVirtualFileSystem.getInstance() + val path = KiloPath(SubagentSessionEditorKind.ID, subagentSessionParams("ses_child", "/repo")) + val file = fs.findOrCreateFile(path) + + assertNotNull(file) + assertSame(FileTypes.UNKNOWN, file!!.fileType) + assertNotNull(SubagentSessionEditorKind.icon(path.params)) + assertEquals(KiloBundle.message("session.subagent.title"), file.name) + assertEquals(KiloBundle.message("session.subagent.path", "ses_child"), (file as VirtualFilePathWrapper).presentablePath) + assertNotNull(service().get(SubagentSessionEditorKind.ID)) + assertNotNull(service().get(SubagentSessionEditorKind.ID)) + + unregisterSubagentSessionEditorKind() + fs.clear() + + assertNull(service().get(SubagentSessionEditorKind.ID)) + assertNull(service().get(SubagentSessionEditorKind.ID)) + assertNull(fs.findOrCreateFile(path)) + } + + fun testSubagentSessionTitleUsesCache() { + service().put("ses_child", "Explore Agent - Find files") + + assertEquals("Explore Agent - Find files", SubagentSessionEditorKind.title(subagentSessionParams("ses_child", "/repo"))) + } + + fun testSubagentSessionTitleFallsBack() { + assertEquals(KiloBundle.message("session.subagent.title"), SubagentSessionEditorKind.title(subagentSessionParams("ses_child", "/repo"))) + } + + fun testSubagentSessionKindRequiresSessionAndDirectory() { + assertFalse(SubagentSessionEditorKind.isValid(subagentSessionParams("", "/repo"))) + assertFalse(SubagentSessionEditorKind.isValid(subagentSessionParams("ses_child", ""))) + assertTrue(SubagentSessionEditorKind.isValid(subagentSessionParams("ses_child", "/repo"))) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewTest.kt index 01811084b00..c432d5f0668 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewTest.kt @@ -5,6 +5,7 @@ import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.toolKind import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.tool.TaskToolView +import ai.kilocode.client.ui.HoverIcon import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack import com.intellij.openapi.util.Disposer @@ -146,13 +147,33 @@ class TaskToolViewTest : BasePlatformTestCase() { assertEquals(0, scroll.verticalScrollBar.value) } - private fun view(tool: Tool): TaskToolView = TaskToolView(tool).also { views.add(it) } + fun `test open subagent icon appears only with child session and callback`() { + val closed = view(task(), onOpen = null) + val opened = view(task(), onOpen = { _, _ -> }) + val missing = view(task(sessionId = null), onOpen = { _, _ -> }) - private fun task(children: List = emptyList()) = Tool("part_task", "task", toolKind("task")).also { + assertFalse(openIcon(closed).isVisible) + assertTrue(openIcon(opened).isVisible) + assertFalse(openIcon(missing).isVisible) + } + + fun `test open subagent icon invokes callback without toggling body`() { + val calls = mutableListOf>() + val view = view(task(), onOpen = { id, title -> calls.add(id to title) }) + + openIcon(view).doClick() + + assertEquals(listOf("ses_child" to "Explore Agent - Find files"), calls) + assertFalse(view.isExpanded()) + } + + private fun view(tool: Tool, onOpen: ((String, String) -> Unit)? = null): TaskToolView = TaskToolView(tool, onOpenSubagent = onOpen).also { views.add(it) } + + private fun task(children: List = emptyList(), sessionId: String? = "ses_child") = Tool("part_task", "task", toolKind("task")).also { it.state = ToolExecState.COMPLETED it.input = mapOf("subagent_type" to "explore", "description" to "Find files") - it.metadata = mapOf("sessionId" to "ses_child") - it.childSessionId = "ses_child" + it.metadata = sessionId?.let { id -> mapOf("sessionId" to id) }.orEmpty() + it.childSessionId = sessionId it.childTools = children } @@ -172,6 +193,8 @@ class TaskToolViewTest : BasePlatformTestCase() { return stack.components.toList() } + private fun openIcon(view: TaskToolView) = descendants(view).filterIsInstance().single() + private fun rowText(view: TaskToolView) = rows(view).map { row -> descendants(row).filterIsInstance().mapNotNull { label -> label.text.takeIf { it.isNotBlank() } }.joinToString(" ") } From f6ccea49ba8720b754df7fb0fc0a0135be30a956 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 20 Aug 2026 12:19:50 -0400 Subject: [PATCH 2/9] fix(jetbrains): register subagent editor kind before VFS open Call ensureSubagentSessionEditorKind() before opening the sub-agent VFS file so the first click reliably creates the virtual file, matching the diff and attachment open sites. Also cap SubagentTitleCache with an access-order LRU so high-churn child session ids do not accumulate for the IDE lifetime. --- .../main/kotlin/ai/kilocode/client/session/SessionUi.kt | 2 ++ .../client/session/subagent/SubagentTitleCache.kt | 7 ++++++- .../session/subagent/SubagentSessionEditorKindTest.kt | 9 +++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) 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 5a5a86976b8..9ddee3bad42 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 @@ -19,6 +19,7 @@ import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.scroll.SessionScroll import ai.kilocode.client.session.subagent.SubagentSessionEditorKind import ai.kilocode.client.session.subagent.SubagentTitleCache +import ai.kilocode.client.session.subagent.ensureSubagentSessionEditorKind import ai.kilocode.client.session.subagent.subagentSessionParams import ai.kilocode.client.session.ui.ConnectionPanel import ai.kilocode.client.session.ui.empty.EmptySessionPanel @@ -891,6 +892,7 @@ class SessionUi( @RequiresEdt private fun openSubagent(sessionId: String, title: String) { service().put(sessionId, title) + ensureSubagentSessionEditorKind() project.service().open( SubagentSessionEditorKind.ID, subagentSessionParams(sessionId, workspace.directory), diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/subagent/SubagentTitleCache.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/subagent/SubagentTitleCache.kt index 169c0b422b5..7542ba643a2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/subagent/SubagentTitleCache.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/subagent/SubagentTitleCache.kt @@ -3,9 +3,14 @@ package ai.kilocode.client.session.subagent import com.intellij.openapi.components.Service import com.intellij.util.concurrency.annotations.RequiresEdt +private const val CAP = 128 + @Service(Service.Level.APP) class SubagentTitleCache { - private val names = linkedMapOf() + // Access-order LRU so high-churn sub-agent session ids evict oldest-used first. + private val names = object : LinkedHashMap(16, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry) = size > CAP + } @RequiresEdt fun put(sessionId: String, title: String) { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/subagent/SubagentSessionEditorKindTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/subagent/SubagentSessionEditorKindTest.kt index a499a161e78..35f4e7dd72a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/subagent/SubagentSessionEditorKindTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/subagent/SubagentSessionEditorKindTest.kt @@ -64,6 +64,15 @@ class SubagentSessionEditorKindTest : BasePlatformTestCase() { assertEquals(KiloBundle.message("session.subagent.title"), SubagentSessionEditorKind.title(subagentSessionParams("ses_child", "/repo"))) } + fun testSubagentTitleCacheEvictsLeastRecentlyUsed() { + val cache = service() + repeat(200) { cache.put("ses_$it", "Title $it") } + + // Oldest untouched entries are evicted; recent ones survive. + assertNull(cache.title("ses_0")) + assertEquals("Title 199", cache.title("ses_199")) + } + fun testSubagentSessionKindRequiresSessionAndDirectory() { assertFalse(SubagentSessionEditorKind.isValid(subagentSessionParams("", "/repo"))) assertFalse(SubagentSessionEditorKind.isValid(subagentSessionParams("ses_child", ""))) From b65d11a452772c29d26809a3d79f2935d7379e31 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 20 Aug 2026 14:48:04 -0400 Subject: [PATCH 3/9] fix(jetbrains): open sub-agent via shared header open action The sub-agent open control used a persistent header HoverIcon that never reliably opened the editor tab. Switch it to the same hover-overlay open-in-editor affordance the edit/patch and modified-files cards already use (SessionCopyTarget + open-diff icon), and extract that button+anchor wiring into a shared HeaderOpenAction so the three cards no longer duplicate it. --- .../client/session/ui/ModifiedFilesView.kt | 22 +++++------- .../session/views/base/HeaderOpenAction.kt | 25 +++++++++++++ .../client/session/views/tool/EditToolView.kt | 19 ++++------ .../client/session/views/tool/TaskToolView.kt | 35 +++++++++++-------- .../client/session/views/TaskToolViewTest.kt | 13 +++---- 5 files changed, 68 insertions(+), 46 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/HeaderOpenAction.kt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt index dcae262e864..ebbaa85bdda 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt @@ -8,11 +8,11 @@ import ai.kilocode.client.session.ui.popup.HeaderPopupBody import ai.kilocode.client.session.ui.popup.HeaderPopupRequest import ai.kilocode.client.session.ui.selection.SessionCopyTarget import ai.kilocode.client.session.ui.selection.SessionSelection -import ai.kilocode.client.session.ui.selection.hoverPlaceholder import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.SessionViewIcons import ai.kilocode.client.session.views.base.AbstractSessionPartView +import ai.kilocode.client.session.views.base.HeaderOpenAction import ai.kilocode.client.session.views.base.PartHeader import ai.kilocode.client.session.views.tool.EditFileChange import ai.kilocode.client.session.views.tool.POPUP_OPTS @@ -21,9 +21,7 @@ import ai.kilocode.client.session.views.tool.setFont import ai.kilocode.client.session.views.tool.setForeground import ai.kilocode.client.session.views.tool.setIcon import ai.kilocode.client.ui.DiffBars -import ai.kilocode.client.ui.ToolbarButtonAction import ai.kilocode.client.ui.UiStyle -import ai.kilocode.client.ui.toolbarButton import ai.kilocode.rpc.dto.DiffFileDto import com.intellij.openapi.util.Disposer import com.intellij.ui.components.JBLabel @@ -53,14 +51,14 @@ class ModifiedFilesView private constructor( init { body.parent = this body.overflow = ::openDiffViewer - parts.diff.addActionListener { openDiffViewer() } + parts.open.button.addActionListener { openDiffViewer() } isVisible = false applyStyle(style) } override val copyEligible: Boolean get() = diffs.isNotEmpty() - override val copyAnchor: JComponent get() = parts.anchor - override val copyToolbar: JComponent get() = parts.diff + override val copyAnchor: JComponent get() = parts.open.anchor + override val copyToolbar: JComponent get() = parts.open.button fun setDiffOpener(openDiff: SessionDiffOpener, sessionId: String?, turnId: String) { this.openDiff = openDiff @@ -75,7 +73,7 @@ class ModifiedFilesView private constructor( this.diffs = diffs if (files == next) { val visible = next.isNotEmpty() - parts.diff.isEnabled = visible + parts.open.enabled = visible if (isVisible == visible) return false isVisible = visible revalidate() @@ -89,7 +87,7 @@ class ModifiedFilesView private constructor( if (isVisible != visible) isVisible = visible if (!visible) collapse() parts.update(files.size, additions, deletions) - parts.diff.isEnabled = visible + parts.open.enabled = visible if (isExpanded()) body.updateFiles(files) revalidate() repaint() @@ -157,17 +155,15 @@ class ModifiedFilesView private constructor( val glyph = JBLabel() val title = JBLabel(KiloBundle.message("session.changes.modified")) val count = JBLabel() - val diff = toolbarButton( - ToolbarButtonAction(SessionViewIcons.openDiff, KiloBundle.message("session.part.tool.openDiff")) {}, - ).apply { isEnabled = false } - val anchor = hoverPlaceholder(diff) + val open = HeaderOpenAction(SessionViewIcons.openDiff, KiloBundle.message("session.part.tool.openDiff")) {} + .apply { enabled = false } val bars = DiffBars(0, 0) // Left-aligned header: icon, title, file count, sticks change badge, open-in-diff. val panel = PartHeader().apply { leading(glyph) left(title) titleGap() - left(count, PartHeader.centered(bars), anchor) + left(count, PartHeader.centered(bars), open.anchor) } @RequiresEdt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/HeaderOpenAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/HeaderOpenAction.kt new file mode 100644 index 00000000000..ae4999aef92 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/HeaderOpenAction.kt @@ -0,0 +1,25 @@ +package ai.kilocode.client.session.views.base + +import ai.kilocode.client.session.ui.selection.hoverPlaceholder +import ai.kilocode.client.ui.ToolbarButtonAction +import ai.kilocode.client.ui.toolbarButton +import javax.swing.Icon +import javax.swing.JComponent + +/** + * Trailing header "open in editor" affordance shared by the edit/patch, modified-files, and task + * cards. [button] is a hover toolbar button that [ai.kilocode.client.session.ui.selection.SessionHoverCopyOverlay] + * floats over the zero-height [anchor]; the anchor only reserves the button's width in the header + * row so header content never sits under it. Views expose [button] as `SessionCopyTarget.copyToolbar` + * and [anchor] as `copyAnchor`, giving every card an identical open action instead of a bespoke icon. + */ +internal class HeaderOpenAction(icon: Icon, tooltip: String, handler: () -> Unit) { + val button = toolbarButton(ToolbarButtonAction(icon, tooltip, handler)) + val anchor: JComponent = hoverPlaceholder(button) + + var enabled: Boolean + get() = button.isEnabled + set(value) { + button.isEnabled = value + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt index 6ef6e318682..efce59cf607 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt @@ -11,18 +11,16 @@ import ai.kilocode.client.session.ui.popup.HeaderPopupBody import ai.kilocode.client.session.ui.popup.HeaderPopupRequest import ai.kilocode.client.session.ui.selection.SessionCopyTarget import ai.kilocode.client.session.ui.selection.SessionSelection -import ai.kilocode.client.session.ui.selection.hoverPlaceholder import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.SessionViewIcons import ai.kilocode.client.session.views.base.PartHeader import ai.kilocode.client.session.views.base.AbstractSessionPartView +import ai.kilocode.client.session.views.base.HeaderOpenAction import ai.kilocode.client.ui.DiffStatBadge -import ai.kilocode.client.ui.ToolbarButtonAction import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.md.MdCodeBlockBorder import ai.kilocode.client.ui.md.MdCodeBlockOptions -import ai.kilocode.client.ui.toolbarButton import ai.kilocode.rpc.dto.DiffFileDto import com.intellij.openapi.actionSystem.DataSink import com.intellij.openapi.actionSystem.UiDataProvider @@ -57,10 +55,7 @@ class EditToolView( private var sessionId: String? = null private var canDiff = false private val badge = DiffStatBadge(0, 0) - private val diff = toolbarButton( - ToolbarButtonAction(SessionViewIcons.openDiff, KiloBundle.message("session.part.tool.openDiff"), ::openDiffViewer), - ) - private val diffAnchor = hoverPlaceholder(diff) + private val open = HeaderOpenAction(SessionViewIcons.openDiff, KiloBundle.message("session.part.tool.openDiff"), ::openDiffViewer) private val filesTag = JBLabel().apply { foreground = SessionUiStyle.Text.Secondary.foreground() font = JBFont.small() @@ -74,7 +69,7 @@ class EditToolView( parts.left.next(parts.link) parts.left.next(filesTag) parts.left.next(PartHeader.centered(badge)) - parts.left.next(diffAnchor) + parts.left.next(open.anchor) // The base binds click-to-toggle across the whole header subtree, skipping controls that own // a mouse listener. parts.link (FileLinkLabel) installs its own click handler that opens the // file, so it is skipped automatically and does not also toggle the card. @@ -83,8 +78,8 @@ class EditToolView( } override val copyEligible: Boolean get() = canDiff - override val copyAnchor: JComponent get() = diffAnchor - override val copyToolbar: JComponent get() = diff + override val copyAnchor: JComponent get() = open.anchor + override val copyToolbar: JComponent get() = open.button constructor( tool: Tool, @@ -239,9 +234,9 @@ class EditToolView( // Mirrors toDiffFiles(item).isNotEmpty() without re-parsing the metadata JSON or allocating a // DiffFileDto per file on every streaming delta: files present, else a single-file patch. val show = count > 0 || editDiff(item).isNotBlank() - if (canDiff == show && diff.isEnabled == show) return + if (canDiff == show && open.enabled == show) return canDiff = show - diff.isEnabled = show + open.enabled = show } private fun openDiffViewer() { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt index 8741118817f..1c2f8c8e41b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt @@ -5,16 +5,16 @@ import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.ui.SessionCodeScroll +import ai.kilocode.client.session.ui.selection.SessionCopyTarget import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.SessionViewIcons import ai.kilocode.client.session.views.base.AbstractSessionPartView -import ai.kilocode.client.session.views.base.PartHeader -import ai.kilocode.client.ui.HoverIcon +import ai.kilocode.client.session.views.base.HeaderOpenAction import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack import ai.kilocode.client.ui.layout.StackAxis -import com.intellij.icons.AllIcons import com.intellij.openapi.actionSystem.DataSink import com.intellij.openapi.actionSystem.UiDataProvider import com.intellij.ui.components.JBLabel @@ -25,6 +25,7 @@ import java.awt.BorderLayout import java.awt.Dimension import java.awt.Point import java.awt.Rectangle +import javax.swing.JComponent import javax.swing.JPanel import javax.swing.ScrollPaneConstants import javax.swing.Scrollable @@ -36,7 +37,7 @@ class TaskToolView( private val selection: SessionSelection? = null, private val onOpenSubagent: ((String, String) -> Unit)? = null, private val parts: ToolParts = toolParts(tool), -) : AbstractSessionPartView(parts.header, { TaskBody(parts.glyph).scroll }), UiDataProvider { +) : AbstractSessionPartView(parts.header, { TaskBody(parts.glyph).scroll }), UiDataProvider, SessionCopyTarget { override val contentId: String = tool.id @@ -45,23 +46,28 @@ class TaskToolView( private val rows = LinkedHashMap() private var following = false private var collapsed = false - private val open = HoverIcon().apply { - icon = AllIcons.Actions.Preview - cursor = java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR) - toolTipText = KiloBundle.message("session.part.tool.openSubagent") - accessibleContext.accessibleName = KiloBundle.message("session.part.tool.openSubagent") - addActionListener { openSubagent() } - } + // Same hover open-in-editor affordance as the edit/patch and modified-files cards. + private val open = HeaderOpenAction( + SessionViewIcons.openDiff, + KiloBundle.message("session.part.tool.openSubagent"), + ::openSubagent, + ) init { - parts.header.right(PartHeader.centered(open)) + parts.header.right(open.anchor) applyStyle(style) sync() if (item.childTools.isNotEmpty()) expand() } + override val copyEligible: Boolean get() = item.childSessionId != null && onOpenSubagent != null + override val copyAnchor: JComponent get() = open.anchor + override val copyToolbar: JComponent get() = open.button + + override fun copyText(): String? = null + override fun uiDataSnapshot(sink: DataSink) { - selection?.provideCopy(sink) { copyText() } + selection?.provideCopy(sink) { copyDump() } } @RequiresEdt @@ -132,7 +138,6 @@ class TaskToolView( changed = setForeground(parts.title, titleColor(item)) || changed changed = setText(parts.state, stateText(item)) || changed changed = setForeground(parts.state, color(item)) || changed - changed = setVisible(open, item.childSessionId != null && onOpenSubagent != null) || changed return changed } @@ -224,7 +229,7 @@ class TaskToolView( return maxOf(0, view.height - scroll.viewport.extentSize.height) } - private fun copyText(): String = buildString { + private fun copyDump(): String = buildString { append(agentTitle(item)) val desc = item.input["description"].orEmpty() if (desc.isNotBlank()) append(" - ").append(desc) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewTest.kt index c432d5f0668..a0332103603 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewTest.kt @@ -147,17 +147,17 @@ class TaskToolViewTest : BasePlatformTestCase() { assertEquals(0, scroll.verticalScrollBar.value) } - fun `test open subagent icon appears only with child session and callback`() { + fun `test open subagent action is eligible only with child session and callback`() { val closed = view(task(), onOpen = null) val opened = view(task(), onOpen = { _, _ -> }) val missing = view(task(sessionId = null), onOpen = { _, _ -> }) - assertFalse(openIcon(closed).isVisible) - assertTrue(openIcon(opened).isVisible) - assertFalse(openIcon(missing).isVisible) + assertFalse(closed.copyEligible) + assertTrue(opened.copyEligible) + assertFalse(missing.copyEligible) } - fun `test open subagent icon invokes callback without toggling body`() { + fun `test open subagent action invokes callback without toggling body`() { val calls = mutableListOf>() val view = view(task(), onOpen = { id, title -> calls.add(id to title) }) @@ -193,7 +193,8 @@ class TaskToolViewTest : BasePlatformTestCase() { return stack.components.toList() } - private fun openIcon(view: TaskToolView) = descendants(view).filterIsInstance().single() + // The open button is a hover overlay, not a header child, so read it from the copy toolbar. + private fun openIcon(view: TaskToolView) = view.copyToolbar as HoverIcon private fun rowText(view: TaskToolView) = rows(view).map { row -> descendants(row).filterIsInstance().mapNotNull { label -> label.text.takeIf { it.isNotBlank() } }.joinToString(" ") From 1f669a73b8d4b00814541e42b649cae7ca28e8f4 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 20 Aug 2026 14:56:40 -0400 Subject: [PATCH 4/9] fix(jetbrains): place sub-agent open action after summary text Append the open action to the flexible header slot so it sits right after the task summary like the edit/patch card, instead of pinned to the far-right header group. --- .../ai/kilocode/client/session/views/tool/TaskToolView.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt index 1c2f8c8e41b..28a1ba57d8a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt @@ -54,7 +54,9 @@ class TaskToolView( ) init { - parts.header.right(open.anchor) + // Place the open action right after the summary text (like the edit/patch card), not pinned + // to the far right: the summary lives in the flexible header slot, so append it there. + (parts.slot as Stack).next(open.anchor) applyStyle(style) sync() if (item.childTools.isNotEmpty()) expand() From ba87c7285766a66b3bb79d1ddaab71c8466704c8 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 20 Aug 2026 15:04:46 -0400 Subject: [PATCH 5/9] chore(jetbrains): sync bun.lock version to 7.4.22 --- bun.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index dd4ec72d327..8d3cd7c41cb 100644 --- a/bun.lock +++ b/bun.lock @@ -357,7 +357,7 @@ }, "packages/kilo-jetbrains": { "name": "@kilocode/kilo-jetbrains", - "version": "7.4.21", + "version": "7.4.22", }, "packages/kilo-memory": { "name": "@kilocode/kilo-memory", From 61f389297591ea06ca27723998ecc19377e3eadc Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 20 Aug 2026 15:15:05 -0400 Subject: [PATCH 6/9] feat(jetbrains): live sub-agent body in collapsed task popup Collapsed task cards now show the same hover popup as the edit/patch and changes cards. Instead of rebuilding a static snapshot, the popup reparents the live TaskBodyScroll the in-place expanded card uses, so streaming child tools keep updating inside the popup. The popup's disposable detaches the shared body on hide (unless the card reclaimed it by expanding) without disposing it, so it stays reusable across expand/collapse. --- .../client/session/views/tool/TaskToolView.kt | 38 ++++++++++++++ .../client/session/views/TaskToolViewTest.kt | 51 +++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt index 28a1ba57d8a..818c526b141 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt @@ -5,6 +5,8 @@ import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.ui.SessionCodeScroll +import ai.kilocode.client.session.ui.popup.HeaderPopupBody +import ai.kilocode.client.session.ui.popup.HeaderPopupRequest import ai.kilocode.client.session.ui.selection.SessionCopyTarget import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle @@ -15,8 +17,10 @@ import ai.kilocode.client.session.views.base.HeaderOpenAction import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack import ai.kilocode.client.ui.layout.StackAxis +import com.intellij.openapi.Disposable import com.intellij.openapi.actionSystem.DataSink import com.intellij.openapi.actionSystem.UiDataProvider +import com.intellij.openapi.util.Disposer import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBScrollPane import com.intellij.util.concurrency.annotations.RequiresEdt @@ -178,6 +182,40 @@ class TaskToolView( return changed } + /** + * Collapsed-card hover preview. Unlike the edit/patch popups, which build fresh snapshot content, + * this hosts the *live* [TaskBodyScroll] — the same instance the in-place expanded card uses — so + * streaming child tools keep updating inside the popup while the card stays collapsed. + */ + @RequiresEdt + override fun headerPopup(): HeaderPopupRequest? = + popup("tool", "task", item.childTools.isNotEmpty()) { taskPopupBody() } + + @RequiresEdt + private fun taskPopupBody(): HeaderPopupBody { + val scroll = taskBody() + syncRows() + val owner = Disposer.newDisposable("Task popup body") + // The live body is only reparented into the popup, never rebuilt. On hide, detach it so it + // returns to a reusable state — unless the card already reclaimed it by expanding — and never + // dispose the shared component itself. + Disposer.register(owner, Disposable { if (!isExpanded()) detachBody(scroll) }) + return HeaderPopupBody( + scroll, + owner, + SessionUiStyle.Colors.codeBlockBackground(), + SessionUiStyle.View.Popup.WIDE_MAX_WIDTH, + ) + } + + @RequiresEdt + private fun detachBody(scroll: TaskBodyScroll) { + val parent = scroll.parent ?: return + parent.remove(scroll) + parent.revalidate() + parent.repaint() + } + private fun taskBody() = bodyComponent() as TaskBodyScroll private fun taskBodyOrNull() = if (hasBody()) bodyComponent() as? TaskBodyScroll else null diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewTest.kt index a0332103603..687e16e55b7 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewTest.kt @@ -167,6 +167,54 @@ class TaskToolViewTest : BasePlatformTestCase() { assertFalse(view.isExpanded()) } + fun `test task popup only shows when collapsed with children`() { + val view = view(task(children = listOf(child("c1", "read")))) + assertTrue(view.isExpanded()) + assertNull(view.headerPopup()) + + view.collapse() + assertNotNull(view.headerPopup()) + + val empty = view(task(children = emptyList())) + assertNull(empty.headerPopup()) + } + + fun `test collapsed task popup hosts the live expanded body`() { + val view = view(task(children = listOf(child("c1", "read")))) + val live = scroll(view) + assertNotNull(live) + + view.collapse() + assertNull(scroll(view)) + + val popup = view.headerPopup()!!.build() + try { + // The popup hosts the same live scroll instance, not a rebuilt snapshot. + assertTrue(descendants(popup.component).any { it === live }) + } finally { + Disposer.dispose(popup.disposable) + } + + // Disposing the popup detaches the shared body without destroying it; re-expanding reuses it. + assertNull(live!!.parent) + view.expand() + assertSame(live, scroll(view)) + } + + fun `test collapsed task popup reflects streaming child updates`() { + val view = view(task(children = listOf(child("c1", "read")))) + view.collapse() + + val popup = view.headerPopup()!!.build() + try { + assertEquals(1, popupRows(popup.component).size) + view.update(task(children = listOf(child("c1", "read"), child("c2", "grep")))) + assertEquals(2, popupRows(popup.component).size) + } finally { + Disposer.dispose(popup.disposable) + } + } + private fun view(tool: Tool, onOpen: ((String, String) -> Unit)? = null): TaskToolView = TaskToolView(tool, onOpenSubagent = onOpen).also { views.add(it) } private fun task(children: List = emptyList(), sessionId: String? = "ses_child") = Tool("part_task", "task", toolKind("task")).also { @@ -196,6 +244,9 @@ class TaskToolViewTest : BasePlatformTestCase() { // The open button is a hover overlay, not a header child, so read it from the copy toolbar. private fun openIcon(view: TaskToolView) = view.copyToolbar as HoverIcon + private fun popupRows(component: Component): List = + descendants(component).filterIsInstance().single().components.toList() + private fun rowText(view: TaskToolView) = rows(view).map { row -> descendants(row).filterIsInstance().mapNotNull { label -> label.text.takeIf { it.isNotBlank() } }.joinToString(" ") } From aac28987eff422173a98d3c128e9b8cc2ccb6b5c Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 20 Aug 2026 15:52:55 -0400 Subject: [PATCH 7/9] fix(jetbrains): bound task popup instead of resizing the frame Streaming child tools no longer pack the whole IDE window. The task popup is now a fixed, bounded box: a 60-char floor width, the shared height cap, and both scrollbars, so live child updates scroll inside the balloon instead of resizing it. HeaderPopupBody gains opt-in minWidth/fixedHeight/horizontal params; snapshot popups keep their content-sized behavior. --- .../client/session/ui/popup/HeaderPopup.kt | 18 ++++++++--- .../client/session/views/tool/TaskToolView.kt | 32 +++++++++++++++++-- .../client/session/views/TaskToolViewTest.kt | 24 ++++++++++++++ 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopup.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopup.kt index 82702ded3cf..d827983edc3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopup.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopup.kt @@ -29,13 +29,21 @@ class HeaderPopupBody( val disposable: Disposable, val background: Color, maxWidth: Int = SessionUiStyle.View.Popup.MAX_WIDTH, + // Opt-in bounds for live bodies (e.g. the task card): a floor width in final device px, a fixed + // height pinned to the shared cap, and a horizontal scrollbar. Snapshot popups keep the defaults. + minWidth: Int = 0, + fixedHeight: Boolean = false, + horizontal: Boolean = false, ) { - val component: JComponent = HeaderPopupPanel(component, JBUI.scale(maxWidth)) + val component: JComponent = HeaderPopupPanel(component, JBUI.scale(maxWidth), minWidth, fixedHeight, horizontal) } private class HeaderPopupPanel( private val child: JComponent, private val maxWidth: Int, + private val minWidth: Int, + private val fixedHeight: Boolean, + horizontal: Boolean, ) : JPanel(BorderLayout()) { // One scroll pane wraps every popup body (single-file edit, multi-file patch, session changes), // so bodies taller than the max height scroll instead of clipping. Bodies that carry their own @@ -43,7 +51,7 @@ private class HeaderPopupPanel( private val scroll = JBScrollPane( child, ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, - ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, + if (horizontal) ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED else ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, ).apply { // Transparent so the balloon fill shows uniformly behind nested popup content. isOpaque = false @@ -57,9 +65,11 @@ private class HeaderPopupPanel( } override fun getPreferredSize(): Dimension { - val width = contentWidth(child).takeIf { it > 0 }?.coerceAtMost(maxWidth) ?: maxWidth + val measured = contentWidth(child).takeIf { it > 0 }?.coerceAtMost(maxWidth) ?: maxWidth + val width = measured.coerceAtLeast(minWidth).coerceAtMost(maxWidth) fit(child, width) - val height = child.preferredSize.height.coerceAtMost(JBUI.scale(SessionUiStyle.View.Popup.MAX_HEIGHT)) + val cap = JBUI.scale(SessionUiStyle.View.Popup.MAX_HEIGHT) + val height = if (fixedHeight) cap else child.preferredSize.height.coerceAtMost(cap) return Dimension(width, height) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt index 818c526b141..9dceab31e12 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt @@ -50,6 +50,7 @@ class TaskToolView( private val rows = LinkedHashMap() private var following = false private var collapsed = false + private var popup: HeaderPopupBody? = null // Same hover open-in-editor affordance as the edit/patch and modified-files cards. private val open = HeaderOpenAction( SessionViewIcons.openDiff, @@ -86,7 +87,10 @@ class TaskToolView( changed = syncRows() || changed if (content.childTools.isNotEmpty() && !collapsed) changed = expand() || changed followTail(follow || fresh) - if (changed) refresh() + if (changed) { + refresh() + refreshPopup() + } } @RequiresEdt @@ -178,6 +182,8 @@ class TaskToolView( if (changed) { body.rows.revalidate() body.rows.repaint() + body.revalidate() + body.repaint() } return changed } @@ -199,13 +205,32 @@ class TaskToolView( // The live body is only reparented into the popup, never rebuilt. On hide, detach it so it // returns to a reusable state — unless the card already reclaimed it by expanding — and never // dispose the shared component itself. - Disposer.register(owner, Disposable { if (!isExpanded()) detachBody(scroll) }) - return HeaderPopupBody( + val body = HeaderPopupBody( scroll, owner, SessionUiStyle.Colors.codeBlockBackground(), SessionUiStyle.View.Popup.WIDE_MAX_WIDTH, + // Fixed, bounded box so streaming child tools scroll instead of resizing the balloon: + // a 60-char floor width, the shared height cap, and both scrollbars. + minWidth = scroll.getFontMetrics(style.smallEditorFont).charWidth('m') * POPUP_MIN_CHARS, + fixedHeight = true, + horizontal = true, ) + popup = body + Disposer.register(owner, Disposable { + if (popup === body) popup = null + if (!isExpanded()) detachBody(scroll) + }) + return body + } + + // The popup hosts the live body, so parent-view updates just revalidate it for the scrollbars to + // track the new content height; the balloon keeps its bounded size instead of resizing. + @RequiresEdt + private fun refreshPopup() { + val body = popup ?: return + body.component.revalidate() + body.component.repaint() } @RequiresEdt @@ -320,6 +345,7 @@ class TaskToolView( override fun dumpLabel() = "TaskToolView#$contentId(${labelText()})" companion object { + private const val POPUP_MIN_CHARS = 60 fun canRender(content: Tool): Boolean = content.name == "task" } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewTest.kt index 687e16e55b7..1fe7fcf10a1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewTest.kt @@ -201,8 +201,27 @@ class TaskToolViewTest : BasePlatformTestCase() { assertSame(live, scroll(view)) } + fun `test collapsed task popup is a bounded scrollable box`() { + val view = view(task(children = children(40))) + view.collapse() + + val popup = view.headerPopup()!!.build() + try { + // Fixed height cap, width bounded by the wide popup cap, and both scrollbars present so + // streaming content scrolls instead of resizing the balloon. + assertEquals(JBUI.scale(SessionUiStyle.View.Popup.MAX_HEIGHT), popup.component.preferredSize.height) + assertTrue(popup.component.preferredSize.width <= JBUI.scale(SessionUiStyle.View.Popup.WIDE_MAX_WIDTH)) + val scrolls = descendants(popup.component).filterIsInstance() + assertTrue(scrolls.any { it.horizontalScrollBarPolicy == ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED }) + assertTrue(scrolls.any { it.verticalScrollBarPolicy == ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED }) + } finally { + Disposer.dispose(popup.disposable) + } + } + fun `test collapsed task popup reflects streaming child updates`() { val view = view(task(children = listOf(child("c1", "read")))) + val live = scroll(view) view.collapse() val popup = view.headerPopup()!!.build() @@ -213,6 +232,11 @@ class TaskToolViewTest : BasePlatformTestCase() { } finally { Disposer.dispose(popup.disposable) } + + assertNull(live!!.parent) + view.expand() + assertSame(live, scroll(view)) + assertEquals(2, rows(view).size) } private fun view(tool: Tool, onOpen: ((String, String) -> Unit)? = null): TaskToolView = TaskToolView(tool, onOpenSubagent = onOpen).also { views.add(it) } From 009dd19478a55f211b38c45596548775bf48316c Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 20 Aug 2026 16:03:46 -0400 Subject: [PATCH 8/9] fix(jetbrains): keep task open action in the non-fit left group The open-action anchor lived in the header fill slot, a fitHorizontal stack that clips trailing children to zero width. A long summary could starve the anchor so the hover open control failed to appear. Move the summary and anchor into the non-fit left group like the edit/patch cards, so the anchor always reserves its width right after the text. --- .../client/session/views/tool/TaskToolView.kt | 8 ++++--- .../client/session/views/TaskToolViewTest.kt | 22 +++++++++++++++++-- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt index 9dceab31e12..1f4b619601b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt @@ -59,9 +59,11 @@ class TaskToolView( ) init { - // Place the open action right after the summary text (like the edit/patch card), not pinned - // to the far right: the summary lives in the flexible header slot, so append it there. - (parts.slot as Stack).next(open.anchor) + // Mirror the edit/patch cards: move the summary and the open action into the non-fit left + // group so the anchor always reserves its width right after the text. Left in the fill slot + // (a fitHorizontal stack), a long summary would clip the trailing anchor to zero width and + // the hover open control could fail to appear. + parts.header.left(parts.sub, open.anchor) applyStyle(style) sync() if (item.childTools.isNotEmpty()) expand() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewTest.kt index 1fe7fcf10a1..383f7cf76f5 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewTest.kt @@ -239,11 +239,29 @@ class TaskToolViewTest : BasePlatformTestCase() { assertEquals(2, rows(view).size) } + fun `test open action reserves width even with a long summary`() { + val view = view(task(children = listOf(child("c1", "read")), description = "d".repeat(400))) + // A narrow header would starve a trailing child in the fill slot; the left group must not. + realize(view, JBUI.scale(320), JBUI.scale(160)) + + val anchor = view.copyAnchor + assertTrue(anchor.preferredSize.width > 0) + assertEquals(anchor.preferredSize.width, anchor.width) + } + + private fun realize(component: Component, width: Int, height: Int) { + component.setSize(width, height) + if (component is Container) { + component.doLayout() + component.components.forEach { realize(it, it.width, it.height) } + } + } + private fun view(tool: Tool, onOpen: ((String, String) -> Unit)? = null): TaskToolView = TaskToolView(tool, onOpenSubagent = onOpen).also { views.add(it) } - private fun task(children: List = emptyList(), sessionId: String? = "ses_child") = Tool("part_task", "task", toolKind("task")).also { + private fun task(children: List = emptyList(), sessionId: String? = "ses_child", description: String = "Find files") = Tool("part_task", "task", toolKind("task")).also { it.state = ToolExecState.COMPLETED - it.input = mapOf("subagent_type" to "explore", "description" to "Find files") + it.input = mapOf("subagent_type" to "explore", "description" to description) it.metadata = sessionId?.let { id -> mapOf("sessionId" to id) }.orEmpty() it.childSessionId = sessionId it.childTools = children From b39277a457e9a0176f6726940877a888a4648f36 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 20 Aug 2026 16:31:52 -0400 Subject: [PATCH 9/9] refactor(jetbrains): unify changes card open action; add sub-agent translations Route ChangesCardView.Header's open-in-diff button through the shared HeaderOpenAction, matching the edit/patch and task cards, so the toolbarButton + hoverPlaceholder wiring lives in one place. Add the new sub-agent bundle keys (openSubagent, subagent.title, subagent.path) to all locale files. --- .../client/session/ui/ChangesCardView.kt | 20 ++++++++----------- .../client/session/ui/ModifiedFilesView.kt | 2 +- .../views/permission/PermissionDiffView.kt | 6 +++--- .../messages/KiloBundle_ar.properties | 3 +++ .../messages/KiloBundle_bs.properties | 3 +++ .../messages/KiloBundle_da.properties | 3 +++ .../messages/KiloBundle_de.properties | 3 +++ .../messages/KiloBundle_es.properties | 3 +++ .../messages/KiloBundle_fr.properties | 3 +++ .../messages/KiloBundle_ja.properties | 3 +++ .../messages/KiloBundle_ko.properties | 3 +++ .../messages/KiloBundle_nl.properties | 3 +++ .../messages/KiloBundle_no.properties | 3 +++ .../messages/KiloBundle_pl.properties | 3 +++ .../messages/KiloBundle_pt_BR.properties | 3 +++ .../messages/KiloBundle_ru.properties | 3 +++ .../messages/KiloBundle_th.properties | 3 +++ .../messages/KiloBundle_tr.properties | 3 +++ .../messages/KiloBundle_uk.properties | 3 +++ .../messages/KiloBundle_zh_CN.properties | 3 +++ .../messages/KiloBundle_zh_TW.properties | 3 +++ 21 files changed, 66 insertions(+), 16 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ChangesCardView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ChangesCardView.kt index 2f282c73de6..ba8fe4f31ed 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ChangesCardView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ChangesCardView.kt @@ -7,11 +7,11 @@ import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.ui.popup.HeaderPopupRequest import ai.kilocode.client.session.ui.selection.SessionCopyTarget import ai.kilocode.client.session.ui.selection.SessionSelection -import ai.kilocode.client.session.ui.selection.hoverPlaceholder import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.SessionViewIcons import ai.kilocode.client.session.views.base.AbstractSessionPartView +import ai.kilocode.client.session.views.base.HeaderOpenAction import ai.kilocode.client.session.views.base.PartHeader import ai.kilocode.client.session.views.tool.EditFileChange import ai.kilocode.client.session.views.tool.PatchBody @@ -19,8 +19,6 @@ import ai.kilocode.client.session.views.tool.setFont import ai.kilocode.client.session.views.tool.setForeground import ai.kilocode.client.session.views.tool.setIcon import ai.kilocode.client.ui.DiffBadge -import ai.kilocode.client.ui.ToolbarButtonAction -import ai.kilocode.client.ui.toolbarButton import ai.kilocode.rpc.dto.DiffFileDto import com.intellij.ui.EditorTextField import com.intellij.ui.components.JBLabel @@ -41,13 +39,13 @@ internal abstract class ChangesCardView( protected var sessionId: String? = null override val copyEligible: Boolean get() = items.any(::openable) - override val copyAnchor: JComponent get() = parts.anchor - override val copyToolbar: JComponent get() = parts.diff + override val copyAnchor: JComponent get() = parts.open.anchor + override val copyToolbar: JComponent get() = parts.open.button init { body.parent = this body.overflow = ::openDiffViewer - parts.diff.addActionListener { openDiffViewer() } + parts.open.button.addActionListener { openDiffViewer() } applyStyle(style) } @@ -60,7 +58,7 @@ internal abstract class ChangesCardView( val additions = files.sumOf { it.additions } val deletions = files.sumOf { it.deletions } parts.update(files.size, additions, deletions) - parts.diff.isEnabled = value.any(::openable) + parts.open.enabled = value.any(::openable) syncExpandable(files.any { it.patch.isNotBlank() }) if (isExpanded()) body.updateFiles(files) revalidate() @@ -128,15 +126,13 @@ internal abstract class ChangesCardView( val glyph = JBLabel() val title = JBLabel(title) val count = JBLabel() - val diff = toolbarButton( - ToolbarButtonAction(SessionViewIcons.openDiff, KiloBundle.message("session.part.tool.openDiff")) {}, - ).apply { isEnabled = false } - val anchor: JComponent = hoverPlaceholder(diff) + val open = HeaderOpenAction(SessionViewIcons.openDiff, KiloBundle.message("session.part.tool.openDiff")) {} + .apply { enabled = false } val panel = PartHeader().apply { leading(glyph) left(this@Header.title) titleGap() - left(count, PartHeader.centered(badge), anchor) + left(count, PartHeader.centered(badge), open.anchor) } @RequiresEdt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt index d4b35009750..5a79a88c6f4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ModifiedFilesView.kt @@ -44,7 +44,7 @@ internal class ModifiedFilesView private constructor( fun setDiffs(diffs: List): Boolean { if (items == diffs) { val visible = diffs.isNotEmpty() - parts.diff.isEnabled = visible + parts.open.enabled = visible if (isVisible == visible) return false isVisible = visible revalidate() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionDiffView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionDiffView.kt index 199039b5574..c045e80de36 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionDiffView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionDiffView.kt @@ -57,13 +57,13 @@ internal class PermissionDiffView private constructor( internal fun openDiffForTest() = openDiffViewer() @RequiresEdt - internal fun openDiffEnabledForTest() = parts.diff.isEnabled + internal fun openDiffEnabledForTest() = parts.open.enabled @RequiresEdt - internal fun openDiffButtonForTest() = parts.diff + internal fun openDiffButtonForTest() = parts.open.button @RequiresEdt - internal fun openDiffAnchorForTest() = parts.anchor + internal fun openDiffAnchorForTest() = parts.open.anchor @RequiresEdt internal fun codeEditorsForTest() = cardCodeEditors() diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties index 270c7195d6c..3e13dd70f0c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties @@ -483,3 +483,6 @@ settings.context.watcher.input.prompt=أدخل نمط glob لتجاهله: # Auto-Approve settings settings.autoApprove.edit=تحرير worktree.menu.configure=تكوين Worktree جديد... +session.part.tool.openSubagent=فتح الوكيل الفرعي في المحرر +session.subagent.title=جلسة الوكيل الفرعي +session.subagent.path=Kilo / الوكلاء الفرعيون / {0} diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties index 05aef866eec..1127915a57b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties @@ -483,3 +483,6 @@ settings.context.watcher.input.prompt=Unesite glob uzorak za ignoriranje: # Auto-Approve settings settings.autoApprove.edit=Uredi worktree.menu.configure=Konfiguriši novi worktree... +session.part.tool.openSubagent=Otvori pod-agenta u uređivaču +session.subagent.title=Sesija pod-agenta +session.subagent.path=Kilo / Pod-agenti / {0} diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties index f9609ea6122..35de81c384c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties @@ -483,3 +483,6 @@ settings.context.watcher.input.prompt=Indtast et glob-mønster, der skal ignorer # Auto-Approve settings settings.autoApprove.edit=Rediger worktree.menu.configure=Konfigurer nyt worktree... +session.part.tool.openSubagent=Åbn underagent i editor +session.subagent.title=Underagent-session +session.subagent.path=Kilo / Underagenter / {0} diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties index 4bd38e84aa4..c93405d6f0f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties @@ -483,3 +483,6 @@ settings.context.watcher.input.prompt=Glob-Muster zum Ignorieren eingeben: # Auto-Approve settings settings.autoApprove.edit=Bearbeiten worktree.menu.configure=Neuen Worktree konfigurieren... +session.part.tool.openSubagent=Unter-Agent im Editor öffnen +session.subagent.title=Unter-Agent-Sitzung +session.subagent.path=Kilo / Unter-Agenten / {0} diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties index ef404651064..987175f6d0e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties @@ -483,3 +483,6 @@ settings.context.watcher.input.prompt=Introduce un patrón glob para ignorar: # Auto-Approve settings settings.autoApprove.edit=Editar worktree.menu.configure=Configurar nuevo worktree... +session.part.tool.openSubagent=Abrir subagente en el editor +session.subagent.title=Sesión de subagente +session.subagent.path=Kilo / Subagentes / {0} diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties index 8e0a8ad0d3f..a8d5960d97d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties @@ -483,3 +483,6 @@ settings.context.watcher.input.prompt=Saisissez un motif glob à ignorer : # Auto-Approve settings settings.autoApprove.edit=Modifier worktree.menu.configure=Configurer un nouveau worktree... +session.part.tool.openSubagent=Ouvrir le sous-agent dans l'éditeur +session.subagent.title=Session du sous-agent +session.subagent.path=Kilo / Sous-agents / {0} diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties index 045114c282a..313fdd9b6a6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties @@ -483,3 +483,6 @@ settings.context.watcher.input.prompt=無視するglobパターンを入力し # Auto-Approve settings settings.autoApprove.edit=編集 worktree.menu.configure=新しいワークツリーを設定... +session.part.tool.openSubagent=サブエージェントをエディタで開く +session.subagent.title=サブエージェントセッション +session.subagent.path=Kilo / サブエージェント / {0} diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties index 7dbe6cfd882..8d131c6cdd7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties @@ -483,3 +483,6 @@ settings.context.watcher.input.prompt=무시할 glob 패턴을 입력하세요: # Auto-Approve settings settings.autoApprove.edit=편집 worktree.menu.configure=새 워크트리 구성... +session.part.tool.openSubagent=하위 에이전트를 편집기에서 열기 +session.subagent.title=하위 에이전트 세션 +session.subagent.path=Kilo / 하위 에이전트 / {0} diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties index e4f7ab607ba..da4d33ac6aa 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties @@ -483,3 +483,6 @@ settings.context.watcher.input.prompt=Voer een glob-patroon in om te negeren: # Auto-Approve settings settings.autoApprove.edit=Bewerken worktree.menu.configure=Nieuwe worktree configureren... +session.part.tool.openSubagent=Subagent openen in editor +session.subagent.title=Subagent-sessie +session.subagent.path=Kilo / Subagents / {0} diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties index 29a75918ffa..cfad2067884 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties @@ -483,3 +483,6 @@ settings.context.watcher.input.prompt=Skriv inn et glob-mønster som skal ignore # Auto-Approve settings settings.autoApprove.edit=Rediger worktree.menu.configure=Konfigurer nytt worktree... +session.part.tool.openSubagent=Åpne underagent i editor +session.subagent.title=Underagent-økt +session.subagent.path=Kilo / Underagenter / {0} diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties index cbbc4b4c4f7..7374c84ee56 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties @@ -483,3 +483,6 @@ settings.context.watcher.input.prompt=Wpisz wzorzec glob do ignorowania: # Auto-Approve settings settings.autoApprove.edit=Edytuj worktree.menu.configure=Skonfiguruj nowy worktree... +session.part.tool.openSubagent=Otwórz podagenta w edytorze +session.subagent.title=Sesja podagenta +session.subagent.path=Kilo / Podagenci / {0} diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties index f0ee1503925..91556b034d2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties @@ -483,3 +483,6 @@ settings.context.watcher.input.prompt=Digite um padrão glob para ignorar: # Auto-Approve settings settings.autoApprove.edit=Editar worktree.menu.configure=Configurar novo worktree... +session.part.tool.openSubagent=Abrir subagente no editor +session.subagent.title=Sessão do subagente +session.subagent.path=Kilo / Subagentes / {0} diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties index f962b87dc80..a3bb483a21d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties @@ -483,3 +483,6 @@ settings.context.watcher.input.prompt=Введите glob-шаблон для и # Auto-Approve settings settings.autoApprove.edit=Изменить worktree.menu.configure=Настроить новое рабочее дерево... +session.part.tool.openSubagent=Открыть субагента в редакторе +session.subagent.title=Сессия субагента +session.subagent.path=Kilo / Субагенты / {0} diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties index 603c551a7ec..77fae80689d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties @@ -483,3 +483,6 @@ settings.context.watcher.input.prompt=ป้อนรูปแบบ glob ที # Auto-Approve settings settings.autoApprove.edit=แก้ไข worktree.menu.configure=กำหนดค่า worktree ใหม่... +session.part.tool.openSubagent=เปิดซับเอเจนต์ในตัวแก้ไข +session.subagent.title=เซสชันซับเอเจนต์ +session.subagent.path=Kilo / ซับเอเจนต์ / {0} diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties index bbb2f976e1f..62400e996d9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties @@ -483,3 +483,6 @@ settings.context.watcher.input.prompt=Yok sayılacak bir glob kalıbı girin: # Auto-Approve settings settings.autoApprove.edit=Düzenle worktree.menu.configure=Yeni worktree yapılandır... +session.part.tool.openSubagent=Alt aracıyı düzenleyicide aç +session.subagent.title=Alt aracı oturumu +session.subagent.path=Kilo / Alt aracılar / {0} diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties index 736139cd0c4..8bbd4b8d382 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties @@ -483,3 +483,6 @@ settings.context.watcher.input.prompt=Введіть glob-шаблон для і # Auto-Approve settings settings.autoApprove.edit=Редагувати worktree.menu.configure=Налаштувати нове робоче дерево... +session.part.tool.openSubagent=Відкрити субагента в редакторі +session.subagent.title=Сесія субагента +session.subagent.path=Kilo / Субагенти / {0} diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties index f200e216de0..4951eacc80d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties @@ -483,3 +483,6 @@ settings.context.watcher.input.prompt=输入要忽略的 glob 模式: # Auto-Approve settings settings.autoApprove.edit=编辑 worktree.menu.configure=配置新工作树... +session.part.tool.openSubagent=在编辑器中打开子代理 +session.subagent.title=子代理会话 +session.subagent.path=Kilo / 子代理 / {0} diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties index 0cd6341fa9c..faf77135c34 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties @@ -483,3 +483,6 @@ settings.context.watcher.input.prompt=輸入要忽略的 glob 模式: # Auto-Approve settings settings.autoApprove.edit=編輯 worktree.menu.configure=設定新工作樹... +session.part.tool.openSubagent=在編輯器中開啟子代理 +session.subagent.title=子代理工作階段 +session.subagent.path=Kilo / 子代理 / {0}