Merge pull request #12571 from Kilo-Org/developing-liver

feat(jetbrains): support queued prompts
This commit is contained in:
Kirill Kalishev
2026-07-27 14:21:48 -04:00
committed by GitHub
43 changed files with 397 additions and 26 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Allow sending prompts while a session is busy and show queued prompts with a remove action.
@@ -65,6 +65,7 @@ class KiloBackendChatManager(
"session.status",
"session.updated",
"session.idle",
"session.queue.changed",
"session.compacted",
"session.diff",
"permission.asked",
@@ -90,14 +91,15 @@ class KiloBackendChatManager(
if (watcher?.isActive == true) return
watcher = cs.launch {
sse.collect { event ->
if (event.type in CHAT_EVENTS) {
val type = if (event.type in CHAT_EVENTS) event.type else KiloCliDataParser.extractEventType(event.data)
if (type in CHAT_EVENTS) {
val events = try {
normalizer.parse(event.type, event.data)
normalizer.parse(type, event.data)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log.warn(
"route=chat-events parse=false type=${event.type} bytes=${event.data.length} ${ChatLogSummary.body(event.data)}",
"route=chat-events parse=false type=$type raw=${event.type} bytes=${event.data.length} ${ChatLogSummary.body(event.data)}",
e,
)
return@collect
@@ -120,7 +122,7 @@ class KiloBackendChatManager(
_events.emit(parsed)
}
} else {
log.warn("route=chat-events parse=null type=${event.type} bytes=${event.data.length} ${ChatLogSummary.body(event.data)}")
log.warn("route=chat-events parse=null type=$type raw=${event.type} bytes=${event.data.length} ${ChatLogSummary.body(event.data)}")
}
}
}
@@ -264,6 +266,27 @@ class KiloBackendChatManager(
postCancellable("/session/$id/revert?directory=${encode(dir)}", body, "revert", "${ChatLogSummary.sid(id)} kind=revert")
}
suspend fun deleteMessage(id: String, dir: String, message: String): Boolean {
log.info("${ChatLogSummary.sid(id)} kind=deleteMessage ${ChatLogSummary.dir(dir)} message=$message")
val http = requireClient()
val url = requireBase()
val request = Request.Builder()
.url("$url/session/$id/message/$message?directory=${encode(dir)}")
.delete()
.build()
val call = http.newCall(request)
call.timeout().timeout(REVERT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
return call.await().use { response ->
val raw = response.body?.string().orEmpty().trim()
if (!response.isSuccessful) {
log.warn("deleteMessage failed: HTTP ${response.code}")
raw.takeIf { it.isNotBlank() }?.let { log.debug { "${ChatLogSummary.sid(id)} kind=deleteMessage error=${ChatLogSummary.body(it)}" } }
return@use false
}
raw != "false"
}
}
suspend fun unrevert(id: String, dir: String) {
log.info("${ChatLogSummary.sid(id)} kind=unrevert ${ChatLogSummary.dir(dir)}")
postCancellable("/session/$id/unrevert?directory=${encode(dir)}", "{}", "unrevert", "${ChatLogSummary.sid(id)} kind=unrevert")
@@ -248,6 +248,12 @@ object KiloCliDataParser {
ChatEventDto.SessionIdle(sid)
}
"session.queue.changed" -> {
val sid = props.str("sessionID") ?: return null
val queued = props["queued"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList()
ChatEventDto.SessionQueueChanged(sid, queued)
}
"session.compacted" -> {
val sid = props.str("sessionID") ?: return null
ChatEventDto.SessionCompacted(sid)
@@ -132,6 +132,9 @@ class KiloSessionRpcApiImpl internal constructor(
override suspend fun revert(id: String, directory: String, messageID: String, partID: String?) =
ready { chat.revert(id, sessions.getDirectory(id, directory), messageID, partID) }
override suspend fun deleteMessage(id: String, directory: String, messageID: String): Boolean =
ready { chat.deleteMessage(id, sessions.getDirectory(id, directory), messageID) }
override suspend fun unrevert(id: String, directory: String) =
ready { chat.unrevert(id, sessions.getDirectory(id, directory)) }
@@ -90,6 +90,32 @@ class KiloBackendChatManagerTest {
assertEquals("{}", mock.lastUnrevertBody)
}
@Test
fun `delete message sends queued message delete request`() = runBlocking {
val port = mock.start()
val chat = KiloBackendChatManager(scope, TestLog())
chat.start(OkHttpClient(), port, MutableSharedFlow())
val result = chat.deleteMessage("ses_abc", "/test/project", "msg1")
assertTrue(result)
assertEquals(1, mock.requestCount("/session/ses_abc/message/msg1"))
assertTrue(mock.lastMessageDeletePath!!.startsWith("/session/ses_abc/message/msg1?directory="))
}
@Test
fun `delete message returns false for queued drop miss`() = runBlocking {
val port = mock.start()
val chat = KiloBackendChatManager(scope, TestLog())
chat.start(OkHttpClient(), port, MutableSharedFlow())
mock.messageDeleteResponse = "false"
val result = chat.deleteMessage("ses_abc", "/test/project", "msg1")
assertEquals(false, result)
assertEquals(1, mock.requestCount("/session/ses_abc/message/msg1"))
}
@Test
fun `revert failure throws on non successful response`() = runBlocking {
val port = mock.start()
@@ -202,4 +228,21 @@ class KiloBackendChatManagerTest {
assertEquals("ses_abc", event.sessionID)
assertTrue(log.messages.any { it.contains("route=chat-events parse=false type=session.error") }, log.messages.joinToString("\n"))
}
@Test
fun `global message event type is extracted from payload`() = runBlocking {
val port = mock.start()
val sse = MutableSharedFlow<SseEvent>(replay = 8)
val chat = KiloBackendChatManager(scope, TestLog())
chat.start(OkHttpClient(), port, sse)
val received = async(start = CoroutineStart.UNDISPATCHED) { withTimeout(5_000) { chat.events.first() } }
withTimeout(5_000) { sse.subscriptionCount.first { it > 0 } }
sse.emit(SseEvent("message", """{"payload":{"type":"session.queue.changed","properties":{"sessionID":"ses_abc","queued":["msg2"]}}}"""))
val event = received.await()
assertTrue(event is ChatEventDto.SessionQueueChanged)
assertEquals("ses_abc", event.sessionID)
assertEquals(listOf("msg2"), event.queued)
}
}
@@ -651,6 +651,19 @@ class KiloCliDataParserTest {
assertTrue(result is ChatEventDto.SessionCompacted)
}
@Test
fun `parseChatEvent - session queue changed`() {
val data = globalEvent("""
"type": "session.queue.changed",
"properties": { "sessionID": "ses_1", "queued": ["msg2", "msg3"] }
""")
val result = KiloCliDataParser.parseChatEvent("session.queue.changed", data)
assertNotNull(result)
assertTrue(result is ChatEventDto.SessionQueueChanged)
assertEquals("ses_1", result.sessionID)
assertEquals(listOf("msg2", "msg3"), result.queued)
}
@Test
fun `parseChatEvent - session updated`() {
val data = globalEvent("""
@@ -117,11 +117,14 @@ class MockCliServer : AutoCloseable {
@Volatile var lastCloudSessionImportBody: String? = null
@Volatile var summarizeStatus = 200
@Volatile var revertStatus = 200
@Volatile var messageDeleteStatus = 200
@Volatile var messageDeleteResponse = "true"
@Volatile var unrevertStatus = 200
@Volatile var lastSummarizePath: String? = null
@Volatile var lastSummarizeBody: String? = null
@Volatile var lastRevertPath: String? = null
@Volatile var lastRevertBody: String? = null
@Volatile var lastMessageDeletePath: String? = null
@Volatile var lastUnrevertPath: String? = null
@Volatile var lastUnrevertBody: String? = null
@Volatile var promptStatus = 200
@@ -438,6 +441,10 @@ class MockCliServer : AutoCloseable {
lastRevertBody = body
respond(output, revertStatus, sessionCreate)
}
bare.matches(Regex("/session/ses_[^/]+/message/[^/]+")) && method == "DELETE" -> {
lastMessageDeletePath = path
respond(output, messageDeleteStatus, messageDeleteResponse)
}
bare.matches(Regex("/session/ses_[^/]+/unrevert")) && method == "POST" -> {
lastUnrevertPath = path
lastUnrevertBody = body
@@ -202,6 +202,9 @@ class KiloSessionService internal constructor(
log.info("${ChatLogSummary.sid(id)} kind=revert ok=true")
}
suspend fun deleteMessage(id: String, dir: String, message: String): Boolean =
call { deleteMessage(id, dir, message) }
suspend fun unrevert(id: String, dir: String) {
call { unrevert(id, dir) }
}
@@ -367,6 +367,7 @@ class SessionUi(
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),
).also {
it.onHover = { view, on -> if (on) popup.show(view) else popup.notifyExit(view) }
@@ -543,6 +544,8 @@ class SessionUi(
is SessionModelEvent.RevertChanged -> onRevertChanged(event.revert)
is SessionModelEvent.QueueChanged -> Unit
is SessionModelEvent.TurnAdded,
is SessionModelEvent.TurnUpdated,
is SessionModelEvent.ContentAdded,
@@ -464,6 +464,27 @@ class SessionController(
}
}
fun deleteQueuedMessage(message: String) {
assertEdt()
val id = sid ?: return
cs.launch {
try {
val ok = sessions.deleteMessage(id, directory, message)
if (!ok) {
capture("Session Error", sessionProps(id) + mapOf("context" to "delete-message", "errorClass" to "DeleteMiss"))
LOG.warn("${ChatLogSummary.sid(id)} kind=deleteMessage missed message=$message")
return@launch
}
capture("Conversation Queued Message Removed", sessionProps(id))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
capture("Session Error", sessionProps(id) + mapOf("context" to "delete-message", "errorClass" to e::class.java.name))
LOG.warn("${ChatLogSummary.sid(id)} kind=deleteMessage failed message=${e.message}", e)
}
}
}
fun unrevert() {
assertEdt()
val id = sid ?: return
@@ -1368,6 +1389,8 @@ class SessionController(
idle()
}
is ChatEventDto.SessionQueueChanged -> updateModel { model.setQueued(event.queued.toSet()) }
is ChatEventDto.SessionCompacted -> {
capture("Context Condensed", sessionProps(event.sessionID))
model.markCompacted()
@@ -1406,7 +1429,8 @@ class SessionController(
is ChatEventDto.QuestionRejected,
is ChatEventDto.SessionStatusChanged,
is ChatEventDto.SessionUpdated,
is ChatEventDto.SessionIdle -> {
is ChatEventDto.SessionIdle,
is ChatEventDto.SessionQueueChanged -> {
edt {
if (disposed) return@edt
updateModel { handleMetadata(event) }
@@ -1428,6 +1452,7 @@ class SessionController(
is ChatEventDto.SessionStatusChanged -> status(event.status)
is ChatEventDto.SessionUpdated -> model.setSession(event.session)
is ChatEventDto.SessionIdle -> idle()
is ChatEventDto.SessionQueueChanged -> model.setQueued(event.queued.toSet())
else -> Unit
}
}
@@ -2312,6 +2337,7 @@ private fun matchesSession(event: ChatEventDto, id: String): Boolean = when (eve
is ChatEventDto.SessionStatusChanged -> event.sessionID == id
is ChatEventDto.SessionUpdated -> event.sessionID == id
is ChatEventDto.SessionIdle -> event.sessionID == id
is ChatEventDto.SessionQueueChanged -> event.sessionID == id
is ChatEventDto.SessionCompacted -> event.sessionID == id
is ChatEventDto.SessionDiffChanged -> event.sessionID == id
is ChatEventDto.TodoUpdated -> event.sessionID == id
@@ -74,6 +74,9 @@ class SessionModel {
private var revert: SessionRevertDto? = null
var queued: Set<String> = emptySet()
private set
var header: SessionHeaderSnapshot = emptyHeader()
private set
@@ -125,6 +128,9 @@ class SessionModel {
return idx >= 0 && pos >= idx
}
@RequiresEdt
fun isQueued(id: String): Boolean = id in queued
@RequiresEdt
fun turn(id: String): Turn? = turnEntries[id]
@@ -295,6 +301,13 @@ class SessionModel {
fire(SessionModelEvent.RevertChanged(revert))
}
@RequiresEdt
fun setQueued(ids: Set<String>) {
if (queued == ids) return
queued = ids
fire(SessionModelEvent.QueueChanged(ids))
}
@RequiresEdt
fun setDiff(diff: List<DiffFileDto>) {
this.diff = diff
@@ -329,6 +342,7 @@ class SessionModel {
hiddenText.clear()
session = null
revert = null
queued = emptySet()
state = SessionState.Idle
diff = emptyList()
todos = emptyList()
@@ -363,6 +377,7 @@ class SessionModel {
hiddenText.clear()
session = null
revert = null
queued = emptySet()
state = SessionState.Idle
diff = emptyList()
todos = emptyList()
@@ -60,6 +60,9 @@ sealed class SessionModelEvent {
data class RevertChanged(val revert: SessionRevertDto?) : SessionModelEvent() {
override fun toString() = "RevertChanged ${revert?.messageID ?: "none"}"
}
data class QueueChanged(val queued: Set<String>) : SessionModelEvent() {
override fun toString() = "QueueChanged [${queued.sorted().joinToString(", ")}]"
}
data class HeaderUpdated(val header: SessionHeaderSnapshot) : SessionModelEvent() {
override fun toString() = "HeaderUpdated visible=${header.visible}"
}
@@ -60,6 +60,7 @@ class SessionMessageListPanel(
private val resize: ((JComponent, () -> Unit) -> Unit)? = null,
private val revert: ((String) -> Unit)? = null,
private val cancelRevert: (() -> Unit)? = null,
private val deleteQueued: ((String) -> Unit)? = null,
private val banner: RevertBanner? = null,
) : SessionLayoutPanel(
SessionUiStyle.SessionLayout.GAP,
@@ -148,6 +149,12 @@ class SessionMessageListPanel(
refresh()
}
is SessionModelEvent.QueueChanged -> {
syncQueued()
syncSettled()
refresh()
}
// Message events: structural changes are handled via turn events above.
is SessionModelEvent.MessageAdded,
is SessionModelEvent.MessageUpdated,
@@ -216,7 +223,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)
val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover, revert, deleteQueued)
turnViews[turn.id] = tv
for (msgId in turn.messageIds) {
val msg = model.message(msgId) ?: continue
@@ -224,6 +231,7 @@ class SessionMessageListPanel(
register(msgId, tv, mv)
}
tv.syncCopyToolbars()
syncQueued(tv)
syncReverted()
add(tv)
syncSettled()
@@ -251,6 +259,7 @@ class SessionMessageListPanel(
register(id, tv, mv)
}
tv.syncCopyToolbars()
syncQueued(tv)
syncReverted()
syncSettled()
@@ -279,7 +288,7 @@ class SessionMessageListPanel(
removeAll()
for (turn in model.turns()) {
val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover, revert)
val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover, revert, deleteQueued)
turnViews[turn.id] = tv
for (msgId in turn.messageIds) {
val msg = model.message(msgId) ?: continue
@@ -287,11 +296,13 @@ class SessionMessageListPanel(
register(msgId, tv, mv)
}
tv.syncCopyToolbars()
syncQueued(tv)
add(tv)
}
syncActive(model.state)
syncSettled(model.state)
syncQueued()
syncReverted()
syncReverting(model.state)
banner?.update()
@@ -321,6 +332,7 @@ class SessionMessageListPanel(
removeAll()
syncActive(model.state)
syncSettled(model.state)
syncQueued()
syncReverting(model.state)
banner?.update()
anchorFooter()
@@ -384,10 +396,18 @@ class SessionMessageListPanel(
}
private fun syncSettled(state: SessionState = model.state) {
val active = if (state.isBusy()) turnViews.values.lastOrNull() else null
val active = if (state.isBusy()) turnViews.values.lastOrNull { !model.isQueued(it.id) } else null
for (view in turnViews.values) view.setSettled(view !== active)
}
private fun syncQueued() {
for (view in turnViews.values) syncQueued(view)
}
private fun syncQueued(view: TurnView) {
view.setQueued(model.isQueued(view.id)) { id -> deleteQueued?.invoke(id) }
}
/**
* Re-insert [question], [permission], [login], and [progress] as the last children
* so active views always render after all turn views, and progress is last.
@@ -207,6 +207,7 @@ class SessionHeaderPanel(
is SessionModelEvent.TodosUpdated,
is SessionModelEvent.SessionUpdated,
is SessionModelEvent.RevertChanged,
is SessionModelEvent.QueueChanged,
is SessionModelEvent.Compacted,
is SessionModelEvent.HistoryLoaded,
is SessionModelEvent.Cleared,
@@ -212,7 +212,7 @@ class PromptPanel(
isFocusPainted = false
addActionListener {
syncTooltip()
val id = if (busy) StopSessionAction.ID else SendPromptAction.ID
val id = if (busy && !hasDraft()) StopSessionAction.ID else SendPromptAction.ID
val action = ActionManager.getInstance().getAction(id)
?: return@addActionListener
val ctx = DataManager.getInstance().getDataContext(button)
@@ -258,7 +258,7 @@ class PromptPanel(
private var request = 0L
override val isSendEnabled: Boolean
get() = ready && !busy && !submitting && (text().isNotEmpty() || attachments.isNotEmpty())
get() = ready && !submitting && (text().isNotEmpty() || attachments.isNotEmpty())
override val isStopEnabled: Boolean
get() = busy
@@ -273,6 +273,7 @@ class PromptPanel(
syncEditorHeight()
triggerCompletion(e)
syncHighlights()
syncButton()
onChange()
}
})
@@ -418,7 +419,7 @@ class PromptPanel(
fun setBusy(value: Boolean) {
busy = value
if (value) invalidateEnhancement() else syncEnhance()
button.icon = if (value) STOP_ICON else SEND_ICON
syncButton()
syncTooltip()
}
@@ -628,6 +629,11 @@ class PromptPanel(
}
}
@RequiresEdt
private fun syncButton() {
button.icon = if (busy && !hasDraft()) STOP_ICON else SEND_ICON
}
@RequiresEdt
private fun submit(src: String) {
if (!isSendEnabled) return
@@ -885,19 +891,20 @@ class PromptPanel(
}
private fun tooltip(): String {
val id = if (busy) StopSessionAction.ID else SendPromptAction.ID
val text = if (busy) {
val stop = busy && !hasDraft()
val id = if (stop) StopSessionAction.ID else SendPromptAction.ID
val text = if (stop) {
KiloBundle.message("prompt.button.stop")
} else {
KiloBundle.message("prompt.button.send")
}
val tip = KeymapUtil.createTooltipText(text, id)
if (busy) return tip
val stop = KeymapUtil.getFirstKeyboardShortcutText(StopSessionAction.ID)
if (stop.isEmpty()) return tip
if (stop) return tip
val shortcut = KeymapUtil.getFirstKeyboardShortcutText(StopSessionAction.ID)
if (shortcut.isEmpty()) return tip
return XmlStringUtil.wrapInHtml(
XmlStringUtil.escapeString(tip) + "<br>" +
XmlStringUtil.escapeString(KiloBundle.message("prompt.button.send.tooltip.stop", stop))
XmlStringUtil.escapeString(KiloBundle.message("prompt.button.send.tooltip.stop", shortcut))
)
}
@@ -19,13 +19,21 @@ import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.views.base.PartView
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.ui.ToolbarButtonAction
import ai.kilocode.client.ui.layout.HAlign
import ai.kilocode.client.ui.layout.VAlign
import ai.kilocode.client.ui.layout.align
import ai.kilocode.client.ui.toolbarButton
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Stack
import com.intellij.icons.AllIcons
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
import com.intellij.ui.components.JBLabel
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import java.awt.BorderLayout
import java.awt.Point
import java.awt.Graphics
@@ -397,6 +405,12 @@ class MessageView(
wrap?.setReverting(active, text, onCancel)
}
@RequiresEdt
fun setQueued(active: Boolean, onDelete: () -> Unit) {
if (role != SessionUiStyle.View.Message.USER_ROLE) return
wrap?.setQueued(active, onDelete)
}
private val promptToolbar: MessageToolbar?
get() = wrap?.bar
@@ -494,21 +508,26 @@ class MessageView(
private inner class PromptWrap(
private val box: JPanel,
) : JPanel(BorderLayout()), SessionCopyTarget {
private val footer = JPanel(BorderLayout()).also { it.isOpaque = false }
val bar = MessageToolbar(
{ prompt?.copyMarkdown(trim = false) },
revert?.let { fn -> { fn(msg.info.id) } },
)
private val placeholder = bar.placeholder()
private var progress: RevertProgress? = null
private var reverting = false
private var progress: RevertProgress? = null
private var queuedRow: JPanel? = null
private var queued = false
override val copyAnchor: JComponent get() = placeholder
override val copyToolbar: JComponent? get() = if (reverting) null else bar
override val copyToolbar: JComponent? get() = if (reverting || queued) null else bar
init {
isOpaque = false
add(box, BorderLayout.CENTER)
add(placeholder.align(HAlign.RIGHT, VAlign.TOP), BorderLayout.SOUTH)
footer.border = JBUI.Borders.emptyTop(UiStyle.Gap.xs())
footer.add(placeholder.align(HAlign.RIGHT, VAlign.TOP), BorderLayout.CENTER)
add(footer, BorderLayout.SOUTH)
}
override fun copyText(): String? = prompt?.copyMarkdown(trim = false)
@@ -523,19 +542,54 @@ class MessageView(
node.setText(text)
if (reverting) return
reverting = true
remove((layout as BorderLayout).getLayoutComponent(BorderLayout.SOUTH))
add(node.align(HAlign.LEFT, VAlign.TOP), BorderLayout.SOUTH)
swapFooter(node.align(HAlign.LEFT, VAlign.TOP))
revalidate()
repaint()
return
}
if (!reverting) return
reverting = false
remove((layout as BorderLayout).getLayoutComponent(BorderLayout.SOUTH))
add(placeholder.align(HAlign.RIGHT, VAlign.TOP), BorderLayout.SOUTH)
swapFooter(placeholder.align(HAlign.RIGHT, VAlign.TOP))
revalidate()
repaint()
}
@RequiresEdt
fun setQueued(active: Boolean, onDelete: () -> Unit) {
if (active) {
val node = queuedRow ?: queue(onDelete).also { queuedRow = it }
if (queued) return
queued = true
swapFooter(node.align(HAlign.RIGHT, VAlign.TOP))
revalidate()
repaint()
return
}
if (!queued) return
queued = false
swapFooter(placeholder.align(HAlign.RIGHT, VAlign.TOP))
revalidate()
repaint()
}
private fun swapFooter(node: JComponent) {
footer.removeAll()
footer.add(node, BorderLayout.CENTER)
}
private fun queue(onDelete: () -> Unit) = Stack.horizontal(UiStyle.Gap.sm()).also { row ->
row.isOpaque = false
row.next(JBLabel(KiloBundle.message("session.queued")).apply {
foreground = UIUtil.getContextHelpForeground()
})
row.next(toolbarButton(
ToolbarButtonAction(
AllIcons.Actions.Close,
KiloBundle.message("session.queued.remove"),
onDelete,
),
))
}
}
private fun assistantBorder() = JBUI.Borders.empty()
@@ -36,6 +36,7 @@ class TurnView(
private val repo: String? = null,
private val hover: ((PartView, Boolean) -> Unit)? = null,
private val revert: ((String) -> Unit)? = null,
private val deleteQueued: ((String) -> Unit)? = null,
) : SessionLayoutPanel(SessionUiStyle.SessionLayout.GAP), Disposable, SessionEditorStyleTarget, SessionView {
private val messages = LinkedHashMap<String, MessageView>()
@@ -71,6 +72,12 @@ class TurnView(
return view
}
@RequiresEdt
fun setQueued(active: Boolean, onDelete: (String) -> Unit) {
val anchor = messages.values.firstOrNull { it.role == SessionUiStyle.View.Message.USER_ROLE } ?: return
anchor.setQueued(active) { onDelete(id) }
}
/** Remove the [MessageView] for [msgId] if present. */
fun removeMessage(msgId: String) {
removeMessageChanged(msgId)
@@ -31,6 +31,8 @@ session.copy.hover=Copy
session.copy.prompt=Copy prompt
session.copy.response=Copy response
session.copy.copied=Copied
session.queued=Queued
session.queued.remove=Remove queued message
session.drop.files.title=Drop files here
session.drop.files.subtitle=to add them to the prompt
session.file.missing=Couldn''t find ''{0}'' in this repository.
@@ -23,6 +23,8 @@ session.copy.hover=نسخ
session.copy.prompt=نسخ الموجه
session.copy.response=نسخ الرد
session.copy.copied=تم النسخ
session.queued=في قائمة الانتظار
session.queued.remove=إزالة الرسالة من قائمة الانتظار
session.tab.new=جلسة جديدة
session.tab.untitled=جلسة بدون عنوان
@@ -23,6 +23,8 @@ session.copy.hover=Kopiraj
session.copy.prompt=Kopiraj prompt
session.copy.response=Kopiraj odgovor
session.copy.copied=Kopirano
session.queued=U redu čekanja
session.queued.remove=Ukloni poruku iz reda čekanja
session.tab.new=Nova sesija
session.tab.untitled=Sesija bez naslova
@@ -23,6 +23,8 @@ session.copy.hover=Kopiér
session.copy.prompt=Kopiér prompt
session.copy.response=Kopiér svar
session.copy.copied=Kopieret
session.queued=I kø
session.queued.remove=Fjern besked fra køen
session.tab.new=Ny session
session.tab.untitled=Unavngivet session
@@ -23,6 +23,8 @@ session.copy.hover=Kopieren
session.copy.prompt=Prompt kopieren
session.copy.response=Antwort kopieren
session.copy.copied=Kopiert
session.queued=In Warteschlange
session.queued.remove=Nachricht aus Warteschlange entfernen
session.tab.new=Neue Sitzung
session.tab.untitled=Unbenannte Sitzung
@@ -23,6 +23,8 @@ session.copy.hover=Copiar
session.copy.prompt=Copiar prompt
session.copy.response=Copiar respuesta
session.copy.copied=Copiado
session.queued=En cola
session.queued.remove=Eliminar mensaje en cola
session.tab.new=Nueva sesión
session.tab.untitled=Sesión sin título
@@ -23,6 +23,8 @@ session.copy.hover=Copier
session.copy.prompt=Copier le prompt
session.copy.response=Copier la réponse
session.copy.copied=Copié
session.queued=En attente
session.queued.remove=Supprimer le message en attente
session.tab.new=Nouvelle session
session.tab.untitled=Session sans titre
@@ -23,6 +23,8 @@ session.copy.hover=コピー
session.copy.prompt=プロンプトをコピー
session.copy.response=応答をコピー
session.copy.copied=コピーしました
session.queued=キュー済み
session.queued.remove=キュー済みメッセージを削除
session.tab.new=新しいセッション
session.tab.untitled=名前なしのセッション
@@ -23,6 +23,8 @@ session.copy.hover=복사
session.copy.prompt=프롬프트 복사
session.copy.response=응답 복사
session.copy.copied=복사됨
session.queued=대기 중
session.queued.remove=대기 중인 메시지 제거
session.tab.new=새 세션
session.tab.untitled=제목 없는 세션
@@ -23,6 +23,8 @@ session.copy.hover=Kopiëren
session.copy.prompt=Prompt kopiëren
session.copy.response=Antwoord kopiëren
session.copy.copied=Gekopieerd
session.queued=In wachtrij
session.queued.remove=Bericht uit wachtrij verwijderen
session.tab.new=Nieuwe sessie
session.tab.untitled=Naamloze sessie
@@ -23,6 +23,8 @@ session.copy.hover=Kopier
session.copy.prompt=Kopier prompt
session.copy.response=Kopier svar
session.copy.copied=Kopiert
session.queued=I kø
session.queued.remove=Fjern melding fra køen
session.tab.new=Ny økt
session.tab.untitled=Uten tittel
@@ -23,6 +23,8 @@ session.copy.hover=Kopiuj
session.copy.prompt=Kopiuj prompt
session.copy.response=Kopiuj odpowiedź
session.copy.copied=Skopiowano
session.queued=W kolejce
session.queued.remove=Usuń wiadomość z kolejki
session.tab.new=Nowa sesja
session.tab.untitled=Sesja bez tytułu
@@ -23,6 +23,8 @@ session.copy.hover=Copiar
session.copy.prompt=Copiar prompt
session.copy.response=Copiar resposta
session.copy.copied=Copiado
session.queued=Na fila
session.queued.remove=Remover mensagem da fila
session.tab.new=Nova sessão
session.tab.untitled=Sessão sem título
@@ -23,6 +23,8 @@ session.copy.hover=Копировать
session.copy.prompt=Скопировать промпт
session.copy.response=Скопировать ответ
session.copy.copied=Скопировано
session.queued=В очереди
session.queued.remove=Удалить сообщение из очереди
session.tab.new=Новая сессия
session.tab.untitled=Незаголовок сессия
@@ -23,6 +23,8 @@ session.copy.hover=คัดลอก
session.copy.prompt=คัดลอกพรอมต์
session.copy.response=คัดลอกคำตอบ
session.copy.copied=คัดลอกแล้ว
session.queued=อยู่ในคิว
session.queued.remove=ลบข้อความในคิว
session.tab.new=เซสชันใหม่
session.tab.untitled=เซสชันไม่มีชื่อ
@@ -23,6 +23,8 @@ session.copy.hover=Kopyala
session.copy.prompt=Promptu kopyala
session.copy.response=Yanıtı kopyala
session.copy.copied=Kopyalandı
session.queued=Kuyrukta
session.queued.remove=Kuyruktaki mesajı kaldır
session.tab.new=Yeni oturum
session.tab.untitled=Başlıksız oturum
@@ -23,6 +23,8 @@ session.copy.hover=Копіювати
session.copy.prompt=Скопіювати промпт
session.copy.response=Скопіювати відповідь
session.copy.copied=Скопійовано
session.queued=У черзі
session.queued.remove=Видалити повідомлення з черги
session.tab.new=Нова сесія
session.tab.untitled=Сесія без назви
@@ -23,6 +23,8 @@ session.copy.hover=复制
session.copy.prompt=复制提示词
session.copy.response=复制回复
session.copy.copied=已复制
session.queued=已排队
session.queued.remove=移除排队消息
session.tab.new=新建会话
session.tab.untitled=无标题会话
@@ -23,6 +23,8 @@ session.copy.hover=複製
session.copy.prompt=複製提示詞
session.copy.response=複製回覆
session.copy.copied=已複製
session.queued=已排入佇列
session.queued.remove=移除佇列中的訊息
session.tab.new=新建工作階段
session.tab.untitled=未命名的工作階段
@@ -102,6 +102,46 @@ class PromptLifecycleTest : SessionControllerTestBase() {
assertFalse(message.properties.containsValue("git-changes"))
}
fun `test session queue changed updates queued set`() {
val (c, _, modelEvents) = prompted()
emit(ChatEventDto.SessionQueueChanged("ses_test", listOf("u2")))
assertEquals(setOf("u2"), c.model.queued)
assertModelEvents(
"""
QueueChanged [u2]
""",
modelEvents,
)
emit(ChatEventDto.SessionQueueChanged("ses_test", emptyList()))
assertEquals(emptySet<String>(), c.model.queued)
}
fun `test delete queued message delegates to RPC`() {
val (c, _, _) = prompted()
edt { c.deleteQueuedMessage("u2") }
flush()
assertEquals(listOf(ai.kilocode.client.testing.FakeSessionRpcApi.MessageDeleteCall("ses_test", "/test", "u2")), rpc.messageDeletes)
assertTrue(appRpc.telemetry.any { it.event == "Conversation Queued Message Removed" })
}
fun `test delete queued message miss captures error`() {
val (c, _, _) = prompted()
rpc.messageDeleteResult = false
edt { c.deleteQueuedMessage("u2") }
flush()
assertEquals(listOf(ai.kilocode.client.testing.FakeSessionRpcApi.MessageDeleteCall("ses_test", "/test", "u2")), rpc.messageDeletes)
assertFalse(appRpc.telemetry.any { it.event == "Conversation Queued Message Removed" })
assertTrue(appRpc.telemetry.any { it.event == "Session Error" && it.properties["context"] == "delete-message" })
}
fun `test PermissionAsked moves state to AwaitingPermission`() {
val (m, _, _) = prompted()
@@ -1056,7 +1056,7 @@ class PromptPanelTest : BasePlatformTestCase() {
assertTrue(resource("/icons/send_dark.svg").contains("fill=\"#0A7BD8\""))
}
fun `test busy disables send button`() {
fun `test busy allows sending draft`() {
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
panel.setReady(true)
ApplicationManager.getApplication().invokeAndWait { panel.setText("hello") }
@@ -1065,8 +1065,9 @@ class PromptPanelTest : BasePlatformTestCase() {
panel.setBusy(true)
assertFalse(panel.isSendEnabled)
assertTrue(panel.isSendEnabled)
assertTrue(panel.isStopEnabled)
assertNotSame(AllIcons.Actions.Suspend, panel.buttonForTest().icon)
}
fun `test auto approve button toggles and updates tooltip`() {
@@ -28,6 +28,7 @@ import ai.kilocode.client.session.views.tool.TaskToolView
import ai.kilocode.client.session.views.tool.ToolView
import ai.kilocode.client.session.views.todo.TodoWriteView
import ai.kilocode.client.ui.DiffStatBadge
import ai.kilocode.client.ui.HoverIcon
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.MessageDto
@@ -129,6 +130,32 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
)
}
fun `test queued turn shows badge and remove action`() {
var deleted: String? = null
Disposer.dispose(parent)
parent = Disposer.newDisposable("test-queued")
model = SessionModel()
panel = SessionMessageListPanel(model, parent, openFile = openFile, deleteQueued = { deleted = it })
model.upsertMessage(msg("u1", "user"))
model.updateContent("u1", part("p1", "u1", "text", text = "first"))
model.upsertMessage(msg("u2", "user"))
model.updateContent("u2", part("p2", "u2", "text", text = "second"))
model.setQueued(setOf("u2"))
val u1 = panel.findMessage("u1")!!
val u2 = panel.findMessage("u2")!!
assertFalse(components(u1).filterIsInstance<JBLabel>().any { it.text == KiloBundle.message("session.queued") })
assertTrue(components(u2).filterIsInstance<JBLabel>().any { it.text == KiloBundle.message("session.queued") })
val remove = components(u2).filterIsInstance<HoverIcon>().single()
assertEquals(KiloBundle.message("session.queued.remove"), remove.toolTipText)
assertEquals(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR), remove.cursor)
remove.doClick()
assertEquals("u2", deleted)
}
// ------ TurnAdded ------
fun `test user message creates turn and is findable by message id`() {
@@ -96,6 +96,8 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
val aborts = mutableListOf<Pair<String, String>>()
val compacts = mutableListOf<Triple<String, String, ModelSelectionDto>>()
val reverts = mutableListOf<RevertCall>()
val messageDeletes = mutableListOf<MessageDeleteCall>()
var messageDeleteResult = true
val unreverts = mutableListOf<Pair<String, String>>()
val configs = mutableListOf<Pair<String, ConfigUpdateDto>>()
val permissionReplies = mutableListOf<Triple<String, String, PermissionReplyDto>>()
@@ -117,6 +119,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
data class AttachmentCall(val id: String, val directory: String, val messageId: String, val partId: String, val attachmentKey: String?)
data class CommandCall(val id: String, val directory: String, val command: String, val arguments: String, val prompt: PromptDto)
data class RevertCall(val id: String, val directory: String, val message: String, val part: String?)
data class MessageDeleteCall(val id: String, val directory: String, val message: String)
// --- Implementation ---
@@ -229,6 +232,12 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
reverts.add(RevertCall(id, directory, messageID, partID))
}
override suspend fun deleteMessage(id: String, directory: String, messageID: String): Boolean {
assertNotEdt("deleteMessage")
messageDeletes.add(MessageDeleteCall(id, directory, messageID))
return messageDeleteResult
}
override suspend fun unrevert(id: String, directory: String) {
assertNotEdt("unrevert")
unrevertGate?.await()
@@ -33,6 +33,7 @@ object ChatLogSummary {
is ChatEventDto.SessionStatusChanged -> event.sessionID
is ChatEventDto.SessionUpdated -> event.sessionID
is ChatEventDto.SessionIdle -> event.sessionID
is ChatEventDto.SessionQueueChanged -> event.sessionID
is ChatEventDto.SessionCompacted -> event.sessionID
is ChatEventDto.SessionDiffChanged -> event.sessionID
is ChatEventDto.TodoUpdated -> event.sessionID
@@ -211,6 +212,12 @@ object ChatLogSummary {
"evt=session.idle",
)
is ChatEventDto.SessionQueueChanged -> join(
sid(event.sessionID),
"evt=session.queue.changed",
"queued=${event.queued.size}",
)
is ChatEventDto.SessionCompacted -> join(
sid(event.sessionID),
"evt=session.compacted",
@@ -90,6 +90,9 @@ interface KiloSessionRpcApi : RemoteApi<Unit> {
/** Revert a session to a prior user message or part. */
suspend fun revert(id: String, directory: String, messageID: String, partID: String?)
/** Delete a single message (used to remove a queued prompt). */
suspend fun deleteMessage(id: String, directory: String, messageID: String): Boolean
/** Redo all reverted changes for a session. */
suspend fun unrevert(id: String, directory: String)
@@ -254,6 +254,13 @@ sealed class ChatEventDto {
val sessionID: String,
) : ChatEventDto()
@Serializable
@SerialName("session.queue.changed")
data class SessionQueueChanged(
val sessionID: String,
val queued: List<String> = emptyList(),
) : ChatEventDto()
@Serializable
@SerialName("session.compacted")
data class SessionCompacted(