fix(jetbrains): render prompt attachments as chips

This commit is contained in:
kirillk
2026-08-07 17:54:40 -04:00
parent 62923adb51
commit 74470aa861
18 changed files with 299 additions and 65 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Render prompt attachments inside the sent message bubble with file chips, image previews, and selection-aware file opening.
@@ -26,6 +26,8 @@ import com.intellij.execution.process.CapturingProcessHandler
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.components.service
import com.intellij.openapi.editor.ScrollType
import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.fileEditor.OpenFileDescriptor
import com.intellij.openapi.project.Project
import com.intellij.openapi.project.ProjectManager
@@ -288,7 +290,7 @@ class KiloWorkspaceRpcApiImpl internal constructor(
}.ifBlank { null }
}
override suspend fun openFile(path: String, line: Int?, column: Int?): Boolean {
override suspend fun openFile(path: String, line: Int?, column: Int?, endLine: Int?): Boolean {
val item = clean(path) ?: return false
val target = file(item)?.takeIf { it.isAbsolute } ?: return false
val vf = LocalFileSystem.getInstance().refreshAndFindFileByPath(target.toString()) ?: return false
@@ -296,7 +298,7 @@ class KiloWorkspaceRpcApiImpl internal constructor(
LOG.warn("No project available to open file: $path")
return false
}
navigate(project, vf, line, column)
navigate(project, vf, line, column, endLine)
return true
}
@@ -374,8 +376,26 @@ class KiloWorkspaceRpcApiImpl internal constructor(
null
}
private suspend fun navigate(project: Project, file: VirtualFile, line: Int? = null, column: Int? = null) = suspendCancellableCoroutine { cont ->
private suspend fun navigate(project: Project, file: VirtualFile, line: Int? = null, column: Int? = null, endLine: Int? = null) = suspendCancellableCoroutine { cont ->
ApplicationManager.getApplication().invokeLater({
if (line != null && endLine != null) {
val editor = FileEditorManager.getInstance(project).openTextEditor(
OpenFileDescriptor(project, file, (line - 1).coerceAtLeast(0), 0),
true,
)
val doc = editor?.document
if (editor != null && doc != null && doc.lineCount > 0) {
val start = (line - 1).coerceIn(0, doc.lineCount - 1)
val end = (endLine - 1).coerceIn(start, doc.lineCount - 1)
val from = doc.getLineStartOffset(start)
val to = doc.getLineEndOffset(end)
editor.selectionModel.setSelection(from, to)
editor.caretModel.moveToOffset(from)
editor.scrollingModel.scrollToCaret(ScrollType.CENTER)
}
if (cont.isActive) cont.resume(Unit)
return@invokeLater
}
val descriptor = if (line == null) {
OpenFileDescriptor(project, file)
} else {
@@ -179,19 +179,19 @@ class KiloWorkspaceService internal constructor(
}
}
suspend fun openPath(directory: String, path: String, line: Int? = null, column: Int? = null): Boolean {
suspend fun openPath(directory: String, path: String, line: Int? = null, column: Int? = null, endLine: Int? = null): Boolean {
val match = files(directory, path).firstOrNull() ?: return false
return try {
call { openFile(match.path, line, column) }
call { openFile(match.path, line, column, endLine) }
} catch (e: Exception) {
LOG.warn("workspace file open failed for path=${match.path}", e)
false
}
}
suspend fun openFile(path: String, line: Int? = null, column: Int? = null): Boolean {
suspend fun openFile(path: String, line: Int? = null, column: Int? = null, endLine: Int? = null): Boolean {
return try {
call { openFile(path, line, column) }
call { openFile(path, line, column, endLine) }
} catch (e: Exception) {
LOG.warn("workspace file open failed for path=$path", e)
false
@@ -59,7 +59,7 @@ class SessionFileLinks(
}
val target = parse(href)
scope.launch {
val ok = service.openPath(dir, target.path, target.line, target.column)
val ok = service.openPath(dir, target.path, target.line, target.column, target.endLine)
if (ok) {
track(target, "direct")
return@launch
@@ -72,7 +72,7 @@ class SessionFileLinks(
when (val result = decide(false, found)) {
Resolution.Opened -> Unit
is Resolution.OpenDirect -> {
val opened = service.openPath(dir, result.file.path, target.line, target.column)
val opened = service.openPath(dir, result.file.path, target.line, target.column, target.endLine)
track(target, if (opened) "search_direct" else "missing")
}
is Resolution.Choose -> {
@@ -104,7 +104,7 @@ class SessionFileLinks(
.createPopupChooserBuilder(files)
.setRenderer(FileRenderer())
.setItemChosenCallback { file ->
scope.launch { service.openPath(dir, file.path, target.line, target.column) }
scope.launch { service.openPath(dir, file.path, target.line, target.column, target.endLine) }
}
.createPopup()
popup.show(anchor ?: RelativePoint.getCenterOf(root))
@@ -143,11 +143,11 @@ class SessionFileLinks(
data object Missing : Resolution
}
data class Target(val path: String, val line: Int? = null, val column: Int? = null)
data class Target(val path: String, val line: Int? = null, val column: Int? = null, val endLine: Int? = null)
companion object {
private const val FILE_SEARCH_LIMIT = 50
private val LINE = Regex(":(\\d+)(?:-\\d+)?(?::(\\d+))?$")
private val LINE = Regex(":(\\d+)(?:-(\\d+))?(?::(\\d+))?$")
private val SCHEME = Regex("^([A-Za-z][A-Za-z0-9+.-]*):")
fun parse(href: String): Target {
@@ -155,6 +155,7 @@ class SessionFileLinks(
return Target(
href.substring(0, match.range.first),
match.groupValues[1].toIntOrNull(),
match.groupValues.getOrNull(3)?.takeIf { it.isNotBlank() }?.toIntOrNull(),
match.groupValues.getOrNull(2)?.takeIf { it.isNotBlank() }?.toIntOrNull(),
)
}
@@ -921,12 +921,13 @@ class SessionUi(
return
}
if (uri.scheme == "file") {
val path = runCatching { Path.of(uri).toString() }.getOrNull() ?: run {
val path = runCatching { Path.of(cleanAttachmentUri(uri)).toString() }.getOrNull() ?: run {
LOG.info("kind=attachment-open skipped=true reason=invalid-file-uri message=$messageId part=${item.id} url=${attachmentUrl(url)}")
return
}
LOG.info("kind=attachment-open route=file session=${controller.id ?: "none"} message=$messageId part=${item.id} path=$path")
fileLinks.open(path, null)
val target = attachmentHref(path, item)
LOG.info("kind=attachment-open route=file session=${controller.id ?: "none"} message=$messageId part=${item.id} path=$target")
fileLinks.open(target, null)
return
}
LOG.info("kind=attachment-open route=browser session=${controller.id ?: "none"} message=$messageId part=${item.id} url=${attachmentUrl(url)}")
@@ -937,6 +938,14 @@ class SessionUi(
?: item.url.substringBefore(',').substringAfterLast('/').takeIf { it.isNotBlank() }
?: "attachment"
private fun attachmentHref(path: String, item: FileAttachment): String {
val start = item.startLine ?: return path
val end = item.endLine ?: start
return "$path:$start-$end"
}
private fun cleanAttachmentUri(uri: URI): URI = URI(uri.scheme, uri.authority, uri.path, null, null)
private fun attachmentUrl(url: String): String {
val scheme = url.substringBefore(':', missingDelimiterValue = "none")
return "scheme=$scheme chars=${url.length} embedded=${isEmbeddedAttachment(url)}"
@@ -73,6 +73,8 @@ class FileAttachment(id: String) : Content(id) {
var url: String = ""
var filename: String? = null
var source: PartSourceDto? = null
var startLine: Int? = null
var endLine: Int? = null
}
/** Tool invocation with lifecycle state. */
@@ -498,6 +498,9 @@ class SessionModel {
existing.url = dto.url ?: ""
existing.filename = dto.filename
existing.source = dto.source
val range = range(existing.url)
existing.startLine = range?.first
existing.endLine = range?.last
}
is Tool -> {
val old = existing.childSessionId
@@ -552,6 +555,9 @@ class SessionModel {
url = dto.url ?: ""
filename = dto.filename
source = dto.source
val range = range(url)
startLine = range?.first
endLine = range?.last
}
"tool" -> Tool(dto.id, dto.tool ?: "unknown", toolKind(dto.tool)).apply {
messageID = dto.messageID
@@ -581,6 +587,20 @@ class SessionModel {
for (l in listeners) l.onEvent(event)
}
private fun range(url: String): IntRange? {
val query = runCatching { java.net.URI.create(url).rawQuery }.getOrNull() ?: return null
val args = query.split('&')
.mapNotNull {
val index = it.indexOf('=')
if (index < 0) return@mapNotNull null
it.substring(0, index) to it.substring(index + 1)
}
.toMap()
val start = args["start"]?.toIntOrNull()?.takeIf { it > 0 } ?: return null
val end = args["end"]?.toIntOrNull()?.takeIf { it >= start } ?: start
return start..end
}
private fun trackChild(messageId: String, content: Content) {
val tool = content as? Tool ?: return
val child = tool.childSessionId ?: return
@@ -47,6 +47,78 @@ data class AttachmentCardItem(
val path: Path? = null,
)
class AttachmentChip(
private val item: AttachmentCardItem,
private val file: Boolean,
private val startLine: Int? = null,
private val endLine: Int? = null,
open: (() -> Unit)? = null,
) : JPanel(BorderLayout()) {
private val tip = tooltip(item)
private val open = open?.let { callback ->
object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
callback()
}
}
}
init {
isOpaque = false
border = JBUI.Borders.empty(0, JBUI.scale(SessionUiStyle.View.Attachment.CHIP_HORIZONTAL_PADDING))
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
toolTipText = tip
accessibleContext?.accessibleName = KiloBundle.message("prompt.attachment.open", item.name)
val label = JBLabel(label()).apply {
icon = attachmentIcon(item.mime, item.name)
iconTextGap = JBUI.scale(SessionUiStyle.View.Attachment.CHIP_ICON_GAP)
toolTipText = tip
}
add(label, BorderLayout.CENTER)
watch(this)
}
override fun getPreferredSize(): Dimension {
val size = super.getPreferredSize()
return Dimension(size.width, JBUI.scale(SessionUiStyle.View.Attachment.CHIP_HEIGHT))
}
override fun getMinimumSize(): Dimension = preferredSize
override fun paintComponent(g: Graphics) {
val g2 = g.create() as Graphics2D
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
val arc = JBUI.scale(SessionUiStyle.View.Attachment.CORNER_ARC)
g2.color = SessionUiStyle.View.Surface.bgColor()
g2.fillRoundRect(0, 0, width, height, arc, arc)
g2.color = SessionUiStyle.View.Outline.color()
g2.drawRoundRect(0, 0, width - 1, height - 1, arc, arc)
} finally {
g2.dispose()
}
super.paintComponent(g)
}
private fun label(): String {
val start = startLine
val end = endLine
if (file && start != null && end != null) return KiloBundle.message("session.attachment.file.range", item.name, start, end)
if (file) return item.name
return KiloBundle.message("session.attachment.unknown", item.mime.ifBlank { "unknown" })
}
private fun watch(node: Component) {
if (node is JComponent) node.toolTipText = tip
open?.let {
node.removeMouseListener(it)
node.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
node.addMouseListener(it)
}
if (node is Container) node.components.forEach(::watch)
}
}
open class AttachmentCard(
private val item: AttachmentCardItem,
remove: (() -> Unit)? = null,
@@ -121,6 +121,9 @@ object SessionUiStyle {
const val CARD_HEIGHT = 59
const val CLOSE_SIZE = 18
const val CORNER_ARC = 8
const val CHIP_HEIGHT = 28
const val CHIP_HORIZONTAL_PADDING = 8
const val CHIP_ICON_GAP = 6
}
/** Full-session file drop overlay geometry and colors. */
@@ -6,12 +6,14 @@ import ai.kilocode.client.session.model.Content
import ai.kilocode.client.session.model.FileAttachment
import ai.kilocode.client.session.ui.attachment.AttachmentCard
import ai.kilocode.client.session.ui.attachment.AttachmentCardItem
import ai.kilocode.client.session.ui.attachment.AttachmentChip
import ai.kilocode.client.session.views.base.PartView
import ai.kilocode.client.ui.UiStyle
import com.intellij.util.ui.JBUI
import java.awt.FlowLayout
import java.net.URI
import java.nio.file.Path
import javax.swing.JComponent
class AttachmentView(
private var item: FileAttachment,
@@ -48,20 +50,31 @@ class AttachmentView(
override fun dumpLabel(): String = "AttachmentView#${item.id}:${name(item)}"
private fun chip(item: FileAttachment) = AttachmentCard(
AttachmentCardItem(name(item), item.mime, item.url),
open = { openAttachment(item) },
)
private fun chip(item: FileAttachment): JComponent {
val card = AttachmentCardItem(name(item), item.mime, item.url)
if (item.mime.startsWith("image/")) return AttachmentCard(card, open = { openAttachment(item) })
return AttachmentChip(card, file = file(item), startLine = item.startLine, endLine = item.endLine, open = { openAttachment(item) })
}
private fun same(next: FileAttachment) = item.mime == next.mime && item.url == next.url && item.filename == next.filename
private fun same(next: FileAttachment) = item.mime == next.mime &&
item.url == next.url &&
item.filename == next.filename &&
item.startLine == next.startLine &&
item.endLine == next.endLine
private fun file(item: FileAttachment): Boolean {
if (item.source?.path?.isNotBlank() == true) return true
val uri = runCatching { URI.create(item.url) }.getOrNull() ?: return false
return uri.scheme == "file"
}
companion object {
fun openDefault(item: FileAttachment, openFile: SessionFileOpener, openUrl: (String) -> Unit) {
val url = item.url.takeIf { it.isNotBlank() } ?: return
val uri = runCatching { URI.create(url) }.getOrNull() ?: return
if (uri.scheme == "file") {
val path = runCatching { Path.of(uri).toString() }.getOrNull() ?: return
openFile(path, null)
val path = runCatching { Path.of(clean(uri)).toString() }.getOrNull() ?: return
openFile(href(path, item), null)
return
}
if (SessionFileLinks.isFileHref(url)) {
@@ -70,6 +83,14 @@ class AttachmentView(
}
openUrl(url)
}
private fun href(path: String, item: FileAttachment): String {
val start = item.startLine ?: return path
val end = item.endLine ?: start
return "$path:$start-$end"
}
private fun clean(uri: URI): URI = URI(uri.scheme, uri.authority, uri.path, null, null)
}
private fun name(item: FileAttachment) = item.filename?.takeIf { it.isNotBlank() }
@@ -212,12 +212,11 @@ class MessageView(
}
}
val view = view(content)
val item = wrapPrompt(view)
view.resize = resize
view.hover = hover
view.applyStyle(style)
parts[content.id] = view
add(item)
wrapPrompt(view)?.let { add(it) }
}
@RequiresEdt
@@ -227,7 +226,9 @@ class MessageView(
it.hover = hover
it.applyStyle(style)
attachments = it
add(it)
val node = ensurePromptWrap()
promptBox?.add(it, BorderLayout.SOUTH)
if (node.parent == null) add(node)
}
view.upsert(content)
parts[content.id] = view
@@ -253,20 +254,19 @@ class MessageView(
@RequiresEdt
private fun replacePart(content: Content, existing: PartView) {
val at = components.indexOfFirst { it === existing }.takeIf { it >= 0 } ?: componentCount
val at = components.indexOfFirst { it === existing || it === wrap }.takeIf { it >= 0 } ?: componentCount
parts.remove(content.id)
aliases.values.removeAll { it == content.id }
sources.keys.removeAll { it !in aliases }
detach(existing)
remove(existing)
removeView(existing)
if (existing === prompt) prompt = null
Disposer.dispose(existing)
val view = view(content)
val item = wrapPrompt(view)
view.resize = resize
view.hover = hover
view.applyStyle(style)
parts[content.id] = view
add(item, at)
wrapPrompt(view)?.let { add(it, at) }
syncBorder()
refresh()
}
@@ -294,10 +294,11 @@ class MessageView(
}
aliases.values.removeAll { it == contentId }
sources.keys.removeAll { it !in aliases }
detach(view)
remove(view)
removeView(view)
Disposer.dispose(view)
if (view === prompt) prompt = null
syncBorder()
syncPromptWrap()
refresh()
return true
}
@@ -325,10 +326,10 @@ class MessageView(
@RequiresEdt
private fun rebuildParts() {
parts.values.distinct().forEach {
detach(it)
remove(it)
removeView(it)
Disposer.dispose(it)
}
wrap?.let { remove(it) }
parts.clear()
aliases.clear()
sources.clear()
@@ -442,10 +443,10 @@ class MessageView(
@RequiresEdt
override fun dispose() {
parts.values.forEach {
detach(it)
remove(it)
removeView(it)
Disposer.dispose(it)
}
wrap?.let { remove(it) }
parts.clear()
aliases.clear()
sources.clear()
@@ -502,19 +503,42 @@ class MessageView(
}
@RequiresEdt
private fun wrapPrompt(view: PartView): JComponent {
private fun removeView(view: PartView) {
detach(view)
view.parent?.remove(view)
}
@RequiresEdt
private fun wrapPrompt(view: PartView): JComponent? {
if (role != SessionUiStyle.View.Message.USER_ROLE) return view
if (view !is PromptView) return view
prompt = view
val node = ensurePromptWrap()
val box = promptBox ?: return node
if (view.parent !== box) box.add(view, BorderLayout.CENTER)
node.bar.setActive(true)
return node.takeIf { it.parent == null }
}
@RequiresEdt
private fun ensurePromptWrap(): PromptWrap {
val existing = wrap
if (existing != null) return existing
val box = JPanel(BorderLayout()).also {
it.isOpaque = false
it.add(view, BorderLayout.CENTER)
promptBox = it
}
val node = PromptWrap(box)
wrap = node
node.bar.setActive(true)
return node
return PromptWrap(box).also { wrap = it }
}
@RequiresEdt
private fun syncPromptWrap() {
val node = wrap ?: return
val box = promptBox ?: return
if (box.componentCount > 0) return
node.parent?.remove(node)
wrap = null
promptBox = null
}
private inner class PromptWrap(
@@ -4,6 +4,7 @@ import ai.kilocode.client.session.model.Content
import ai.kilocode.client.session.model.FileAttachment
import ai.kilocode.client.session.ui.attachment.AttachmentCard
import ai.kilocode.client.session.ui.attachment.AttachmentCardItem
import ai.kilocode.client.session.ui.attachment.AttachmentChip
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.base.PartView
import ai.kilocode.client.ui.UiStyle
@@ -12,6 +13,8 @@ import com.intellij.ui.components.JBScrollPane
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBUI
import java.awt.Dimension
import java.net.URI
import javax.swing.JComponent
import javax.swing.ScrollPaneConstants
class PromptAttachmentView(
@@ -21,7 +24,7 @@ class PromptAttachmentView(
override val contentId: String = "attachments:$messageId"
private val items = LinkedHashMap<String, FileAttachment>()
private val cards = LinkedHashMap<String, AttachmentCard>()
private val cards = LinkedHashMap<String, JComponent>()
private val row = Stack.horizontal(gap = UiStyle.Gap.sm())
private val scroll = JBScrollPane(row).apply {
border = null
@@ -35,9 +38,9 @@ class PromptAttachmentView(
isOpaque = false
border = JBUI.Borders.empty(
0,
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING),
0,
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING),
0,
)
add(scroll)
}
@@ -111,12 +114,23 @@ class PromptAttachmentView(
repaint()
}
private fun card(item: FileAttachment) = AttachmentCard(
AttachmentCardItem(name(item), item.mime, item.url),
open = { openAttachment(item) },
)
private fun card(item: FileAttachment): JComponent {
val card = AttachmentCardItem(name(item), item.mime, item.url)
if (item.mime.startsWith("image/")) return AttachmentCard(card, open = { openAttachment(item) })
return AttachmentChip(card, file = file(item), startLine = item.startLine, endLine = item.endLine, open = { openAttachment(item) })
}
private fun same(a: FileAttachment, b: FileAttachment) = a.mime == b.mime && a.url == b.url && a.filename == b.filename
private fun same(a: FileAttachment, b: FileAttachment) = a.mime == b.mime &&
a.url == b.url &&
a.filename == b.filename &&
a.startLine == b.startLine &&
a.endLine == b.endLine
private fun file(item: FileAttachment): Boolean {
if (item.source?.path?.isNotBlank() == true) return true
val uri = runCatching { URI.create(item.url) }.getOrNull() ?: return false
return uri.scheme == "file"
}
private fun bar() = scroll.horizontalScrollBar.preferredSize.height
@@ -227,6 +227,8 @@ session.attachment.unsupported=Cannot preview {0}
session.attachment.mime=Type: {0}
session.attachment.size=Size: {0} bytes
session.attachment.error=Failed to load attachment: {0}
session.attachment.file.range={0}:{1}-{2}
session.attachment.unknown=Attached content ({0})
prompt.action.enhance=Enhance prompt
prompt.action.enhance.loading=Enhancing prompt...
prompt.action.enhance.description=The 'Enhance Prompt' button helps improve your prompt by providing additional context, clarification, or rephrasing. Try typing a prompt in here and clicking the button again to see how it works.
@@ -41,7 +41,7 @@ class SessionFileLinksTest : BasePlatformTestCase() {
fun `test parse strips line range and column suffixes`() {
assertEquals(SessionFileLinks.Target("src/Foo.kt", line = 12), SessionFileLinks.parse("src/Foo.kt:12"))
assertEquals(SessionFileLinks.Target("src/Foo.kt", line = 12), SessionFileLinks.parse("src/Foo.kt:12-20"))
assertEquals(SessionFileLinks.Target("src/Foo.kt", line = 12, endLine = 20), SessionFileLinks.parse("src/Foo.kt:12-20"))
assertEquals(SessionFileLinks.Target("src/Foo.kt", line = 12, column = 3), SessionFileLinks.parse("src/Foo.kt:12:3"))
}
@@ -96,6 +96,18 @@ class SessionFileLinksTest : BasePlatformTestCase() {
assertEquals("true", events.single().second["hasLine"])
}
fun `test open forwards line range to workspace service`() = runBlocking {
val file = WorkspaceFileDto("/test/src/Foo.kt", "Foo.kt")
val done = CompletableDeferred<Unit>()
rpc.fileResolver = { path -> if (path == "src/Foo.kt") listOf(file) else emptyList() }
val links = SessionFileLinks("/test", service, scope, JPanel(), openUrl = {}) { _, _ -> done.complete(Unit) }
links.open("src/Foo.kt:12-20", null)
withTimeout(OPEN_TIMEOUT_MS) { done.await() }
assertEquals(listOf(FakeWorkspaceRpcApi.Opened("/test/src/Foo.kt", 12, null, 20)), rpc.openedFiles)
}
private companion object {
const val OPEN_TIMEOUT_MS = 5_000L
}
@@ -22,6 +22,7 @@ import ai.kilocode.client.session.views.question.QuestionView
import ai.kilocode.client.session.ui.selection.SessionCopyTarget
import ai.kilocode.client.session.views.MessageToolbar
import ai.kilocode.client.session.views.MessageView
import ai.kilocode.client.session.views.PromptAttachmentView
import ai.kilocode.client.session.views.TextView
import ai.kilocode.client.session.views.TurnView
import ai.kilocode.client.session.views.base.PartView
@@ -809,7 +810,9 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
layout(message)
val box = promptBox(message)
val point = SwingUtilities.convertPoint(box, Point(), message)
assertTrue("prompt box should be below attachment", point.y > 0)
val attachment = components(message).filterIsInstance<PromptAttachmentView>().single()
val attachmentPoint = SwingUtilities.convertPoint(attachment, Point(), box)
assertTrue("attachment should be inside prompt box below prompt text", attachmentPoint.y > 0)
val image = BufferedImage(message.width, message.height, BufferedImage.TYPE_INT_ARGB)
val graphics = image.createGraphics()
@@ -818,7 +821,7 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
val line = SessionUiStyle.View.Outline.color().rgb
assertEquals(line, Color(image.getRGB(point.x + box.width / 2, point.y), true).rgb)
assertFalse(line == Color(image.getRGB(point.x + box.width / 2, 0), true).rgb)
assertEquals(line, Color(image.getRGB(point.x + box.width / 2, point.y + box.height - 1), true).rgb)
}
fun `test created ContentDelta is not double applied`() {
@@ -1635,7 +1638,7 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
}
private fun promptBox(root: MessageView): Component {
return components(root).first { it.parent != root && it is JPanel && it.componentCount == 1 && it.components.single() is TextView }
return components(root).first { it.parent != root && it is JPanel && it.components.any { child -> child is TextView } }
}
private fun components(root: Component): List<Component> {
@@ -3,6 +3,7 @@ package ai.kilocode.client.session.ui
import ai.kilocode.client.session.model.SessionModel
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.ui.attachment.AttachmentCard
import ai.kilocode.client.session.ui.attachment.AttachmentChip
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.AttachmentView
import ai.kilocode.client.session.views.PromptAttachmentView
@@ -20,6 +21,7 @@ import ai.kilocode.rpc.dto.PartSourceTextDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBUI
import java.awt.Container
import java.awt.event.MouseEvent
@@ -270,16 +272,21 @@ class SessionUiUpdateTest : BasePlatformTestCase() {
val attachment = msg.part("f1")!!
val other = msg.part("f2")!!
assertSame(msg, attachment.parent)
assertNotSame(msg, attachment.parent)
assertSame(attachment, other)
assertEquals(listOf("p1", "f1", "f2"), msg.partIds())
assertEquals(1, msg.components.filterIsInstance<PromptAttachmentView>().size)
assertEquals(2, findAll(attachment, AttachmentCard::class.java).size)
assertEquals(1, findAll(msg, PromptAttachmentView::class.java).size)
assertEquals(1, findAll(attachment, AttachmentCard::class.java).size)
assertEquals(1, findAll(attachment, AttachmentChip::class.java).size)
val cards = findAll(attachment, AttachmentCard::class.java)
for (card in cards) {
card.dispatchEvent(MouseEvent(card, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), 0, 1, 1, 1, false))
}
val chips = findAll(attachment, AttachmentChip::class.java)
for (chip in chips) {
chip.dispatchEvent(MouseEvent(chip, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), 0, 1, 1, 1, false))
}
assertEquals(listOf("data:image/png;base64,aGVsbG8=", "data:text/plain;base64,aGVsbG8="), opened)
}
@@ -356,7 +363,26 @@ class SessionUiUpdateTest : BasePlatformTestCase() {
val view = panel.findMessage("u1")!!.part("f1")
assertTrue(view is PromptAttachmentView)
assertNotNull(find(view!!, AttachmentCard::class.java))
assertNotNull(find(view!!, AttachmentChip::class.java))
}
fun `test source less file selection renders filename range chip`() {
model.upsertMessage(msg("u1", "user"))
model.updateContent("u1", PartDto(
id = "f1",
sessionID = "ses",
messageID = "u1",
type = "file",
mime = "text/plain",
url = "file:///tmp/HvJwtFilter.java?start=12&end=40",
filename = "HvJwtFilter.java",
))
val view = panel.findMessage("u1")!!.part("f1")!!
val chip = find(view, AttachmentChip::class.java)
assertNotNull(chip)
assertTrue(findAll(chip!!, JBLabel::class.java).any { it.text == "HvJwtFilter.java:12-40" })
}
fun `test source backed image attachment still renders in prompt strip`() {
@@ -400,7 +426,7 @@ class SessionUiUpdateTest : BasePlatformTestCase() {
assertNull(msg.part("p2"))
assertEquals(listOf("p1", "f1"), msg.partIds())
assertTrue(msg.part("p1") is TextView)
assertEquals(1, msg.components.filterIsInstance<PromptAttachmentView>().size)
assertEquals(1, findAll(msg, PromptAttachmentView::class.java).size)
}
fun `test prompt text panel is removed when content becomes empty`() {
@@ -497,8 +523,8 @@ class SessionUiUpdateTest : BasePlatformTestCase() {
),
)
val card = find(item.findMessage("u1")!!.part("f1")!!, AttachmentCard::class.java)!!
card.dispatchEvent(MouseEvent(card, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), 0, 1, 1, 1, false))
val chip = find(item.findMessage("u1")!!.part("f1")!!, AttachmentChip::class.java)!!
chip.dispatchEvent(MouseEvent(chip, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), 0, 1, 1, 1, false))
assertEquals(listOf("u1" to "data:text/plain;base64,aGVsbG8="), opened)
}
@@ -111,10 +111,10 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi {
return branchName
}
override suspend fun openFile(path: String, line: Int?, column: Int?): Boolean {
override suspend fun openFile(path: String, line: Int?, column: Int?, endLine: Int?): Boolean {
assertNotEdt("openFile")
opened.add(path)
openedFiles.add(Opened(path, line, column))
openedFiles.add(Opened(path, line, column, endLine))
return openResult
}
@@ -150,5 +150,5 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi {
return openResult
}
data class Opened(val path: String, val line: Int?, val column: Int?)
data class Opened(val path: String, val line: Int?, val column: Int?, val endLine: Int? = null)
}
@@ -67,7 +67,7 @@ interface KiloWorkspaceRpcApi : RemoteApi<Unit> {
suspend fun branchName(directory: String): String?
/** Open an absolute backend file path in the IDE. */
suspend fun openFile(path: String, line: Int? = null, column: Int? = null): Boolean
suspend fun openFile(path: String, line: Int? = null, column: Int? = null, endLine: Int? = null): Boolean
/** Resolve the editable local config target. */
suspend fun localConfigTarget(directory: String): ConfigTargetDto