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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -206,6 +210,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 @@ -285,10 +290,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 @@ -298,6 +304,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 @@ -307,7 +314,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 @@ -357,53 +364,54 @@ 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) },
openFile = fileLinks::open,
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 @@ -436,6 +444,16 @@ 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()
val wide = (prompt.width - gap * 2).coerceAtLeast(0)
Expand All @@ -455,51 +473,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 @@ -563,10 +588,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 @@ -712,6 +739,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 @@ -874,6 +902,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.
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
Loading