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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/subagent-session-tabs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---

Open sub-agent task sessions in read-only editor tabs from JetBrains task cards.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ 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.ensureSubagentSessionEditorKind
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
Expand Down Expand Up @@ -205,6 +209,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

Expand Down Expand Up @@ -284,10 +289,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
Expand All @@ -297,6 +303,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).
Expand All @@ -306,7 +313,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)
Expand Down Expand Up @@ -356,52 +363,53 @@ 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) },
follow = { scroll.following() },
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,
)
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)
Expand Down Expand Up @@ -435,6 +443,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(
Expand All @@ -450,51 +467,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(
Expand Down Expand Up @@ -558,10 +582,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())
}

Expand Down Expand Up @@ -700,6 +726,7 @@ class SessionUi(
}

private fun sendPrompt(text: String, files: List<PromptPartDto>, select: PromptSelection? = null) {
if (readonly) return
if (text.isBlank() && files.isEmpty()) return
prompt.clear()
val follow = scroll.following()
Expand Down Expand Up @@ -862,6 +889,17 @@ class SessionUi(
}
}

@RequiresEdt
private fun openSubagent(sessionId: String, title: String) {
service<SubagentTitleCache>().put(sessionId, title)
ensureSubagentSessionEditorKind()
project.service<KiloVfsManager>().open(
Comment thread
kirillk marked this conversation as resolved.
Resolved
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()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<SessionUiFactory>().create(project, workspace, manager, ref, timers)
},
resolve: (String) -> Workspace = { dir -> service<KiloWorkspaceService>().workspace(dir) },
status: () -> Map<String, SessionActivityKind> = { 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()
}
}
Loading