From 6f11e3576488e06e99337c81abb29f5e8aa8908c Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 13 Jul 2026 10:38:29 +0200 Subject: [PATCH 01/12] fix(cli): preserve chunk compaction errors --- .changeset/preserve-compaction-errors.md | 5 ++ .../src/kilocode/session/compaction-chunks.ts | 45 ++++++++-- .../session-compaction-chunks.test.ts | 84 ++++++++++++++++++- .../test/kilocode/session-overflow.test.ts | 13 +++ 4 files changed, 139 insertions(+), 8 deletions(-) create mode 100644 .changeset/preserve-compaction-errors.md diff --git a/.changeset/preserve-compaction-errors.md b/.changeset/preserve-compaction-errors.md new file mode 100644 index 0000000000..cefd57adba --- /dev/null +++ b/.changeset/preserve-compaction-errors.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Preserve gateway and provider errors when chunked compaction fails instead of reporting every failure as a context overflow. diff --git a/packages/opencode/src/kilocode/session/compaction-chunks.ts b/packages/opencode/src/kilocode/session/compaction-chunks.ts index e040d87470..1cc3e802ff 100644 --- a/packages/opencode/src/kilocode/session/compaction-chunks.ts +++ b/packages/opencode/src/kilocode/session/compaction-chunks.ts @@ -32,6 +32,7 @@ export namespace KiloCompactionChunks { type Output = { result: SessionProcessor.Result output: string | undefined + error: MessageV2.Assistant["error"] } type Deps = { @@ -271,7 +272,11 @@ export namespace KiloCompactionChunks { model: mdl, }) const parts = MessageV2.parts(worker.message.id) - return { result, output: text(worker.message, parts) } + return { + result, + output: text(worker.message, parts), + error: worker.message.error ?? worker.compactError?.(), + } }).pipe( Effect.ensuring( input.session.removeMessage({ sessionID: input.sessionID, messageID: worker.message.id }).pipe(Effect.ignore), @@ -279,9 +284,27 @@ export namespace KiloCompactionChunks { ) const result = out.result const output = out.output - if (result !== "continue") return { result, output: undefined } - if (!output) return { result: "stop" as const, output: undefined } - return { result, output } + if (result !== "continue") return { result, output: undefined, error: out.error } + if (!output) return { result: "stop" as const, output: undefined, error: out.error } + return { result, output, error: undefined } + }) + } + + function fatal(output: Output | undefined) { + return output?.result === "stop" && !!output.error && output.error.name !== "ContextOverflowError" + } + + function fail(input: Input, output: Output | undefined) { + return Effect.gen(function* () { + if (output?.result !== "stop") return false + const error = output.error + if (!error || error.name === "ContextOverflowError") return false + + input.target.error = error + input.target.finish = "error" + input.target.time.completed = Date.now() + yield* input.updateMessage(input.target) + return true }) } @@ -329,7 +352,8 @@ export namespace KiloCompactionChunks { (group) => reduce({ ...input, summaries: group, depth: input.depth + 1 }), { concurrency: 1 }, ) - if (next.some((item) => item.result !== "continue" || !item.output)) return result + const failed = next.find(fatal) ?? next.find((item) => item.result !== "continue" || !item.output) + if (failed) return fatal(failed) ? failed : result return yield* reduce({ ...input, summaries: next.map((item) => item.output!), depth: input.depth + 2 }) }) } @@ -343,13 +367,20 @@ export namespace KiloCompactionChunks { const partial = yield* Effect.forEach(chunks, (chunk) => summarize({ ...input, chunk, total: chunks.length }), { concurrency: Math.min(CONCURRENCY, chunks.length), }) - if (partial.some((item) => item.result !== "continue" || !item.output)) return "compact" as const + const failed = partial.find(fatal) ?? partial.find((item) => item.result !== "continue" || !item.output) + if (failed) { + if (yield* fail(input, failed)) return "stop" as const + return "compact" as const + } const final = chunks.length === 1 && (yield* large({ messages: chunks[0].messages, model: input.model, size })) ? partial[0] : yield* reduce({ ...input, summaries: partial.map((item) => item.output!), depth: 0 }) - if (!final || final.result !== "continue" || !final.output) return "compact" as const + if (!final || final.result !== "continue" || !final.output) { + if (yield* fail(input, final)) return "stop" as const + return "compact" as const + } yield* input.updatePart({ id: PartID.ascending(), diff --git a/packages/opencode/test/kilocode/session-compaction-chunks.test.ts b/packages/opencode/test/kilocode/session-compaction-chunks.test.ts index e56ab3df59..ca3ef398dd 100644 --- a/packages/opencode/test/kilocode/session-compaction-chunks.test.ts +++ b/packages/opencode/test/kilocode/session-compaction-chunks.test.ts @@ -146,7 +146,7 @@ function reply(text: string, capture?: (input: LLM.StreamInput) => void) { } } -function fakeRuntime(outputTokenMax?: number) { +function fakeRuntime(outputTokenMax?: number, error?: MessageV2.Assistant["error"]) { const calls: string[] = [] const outputs: number[] = [] const bus = Bus.layer @@ -167,6 +167,12 @@ function fakeRuntime(outputTokenMax?: number) { Effect.gen(function* () { outputs.push(input.model.limit.output) calls.push(JSON.stringify(stream.messages)) + if (error) { + input.assistantMessage.error = error + input.assistantMessage.finish = "error" + yield* sessions.updateMessage(input.assistantMessage) + return "stop" as const + } const text = stream.messages.some((msg) => JSON.stringify(msg).includes("Create a new anchored summary"), ) @@ -215,6 +221,48 @@ function fakeRuntime(outputTokenMax?: number) { } } +async function failure(error: MessageV2.Assistant["error"]) { + await using tmp = await tmpdir() + return provideTestInstance({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + await user(session.id, "oversized " + "x".repeat(80_000)) + await Effect.runPromise( + KiloSessionCompaction.create({ + session: store, + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }), + ) + + const { rt } = fakeRuntime(undefined, error) + try { + const msgs = await svc.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + const result = await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), + ) + const all = await svc.messages({ sessionID: session.id }) + const summary = all.find((msg) => msg.info.role === "assistant" && msg.info.summary) + return { result, summary } + } finally { + await rt.dispose() + } + }, + }) +} + function liveRuntime(layer: Layer.Layer, context = 10_000) { const bus = Bus.layer const status = SessionStatus.layer.pipe(Layer.provide(bus)) @@ -293,6 +341,40 @@ describe("KiloCompactionChunks", () => { expect(KiloCompactionChunks.budget({ cfg, model, outputTokenMax })).toBe(5_692) }) + test("preserves gateway errors from chunk workers", async () => { + const error = new MessageV2.APIError({ + message: "The operation was aborted", + statusCode: 504, + isRetryable: true, + responseBody: '{"error_type":"timeout"}', + }).toObject() + + const result = await failure(error) + + expect(result.result).toBe("stop") + expect(result.summary?.info.role).toBe("assistant") + if (result.summary?.info.role !== "assistant") return + expect(result.summary.info.finish).toBe("error") + expect(result.summary.info.error).toEqual(error) + }) + + test("keeps context overflow on the terminal compaction path", async () => { + const result = await failure( + new MessageV2.ContextOverflowError({ + message: "worker context overflow", + }).toObject(), + ) + + expect(result.result).toBe("stop") + expect(result.summary?.info.role).toBe("assistant") + if (result.summary?.info.role !== "assistant") return + expect(result.summary.info.error?.name).toBe("ContextOverflowError") + if (result.summary.info.error?.name !== "ContextOverflowError") return + expect(result.summary.info.error.data.message).toBe( + "Session too large to compact - context exceeds model limit even after stripping media", + ) + }) + test("falls back to chunk workers after the first compaction overflows", async () => { await using tmp = await tmpdir() await provideTestInstance({ diff --git a/packages/opencode/test/kilocode/session-overflow.test.ts b/packages/opencode/test/kilocode/session-overflow.test.ts index 1c5075c008..4cca536949 100644 --- a/packages/opencode/test/kilocode/session-overflow.test.ts +++ b/packages/opencode/test/kilocode/session-overflow.test.ts @@ -123,6 +123,19 @@ describe("Kilo auto-compaction threshold", () => { expect(isOverflow({ cfg: conf, model: mdl, tokens: { ...tokens(0), total: 150_000 } })).toBe(true) }) + + test("uses the output cap as the reserve for single-window gateway models", () => { + const mdl = model({ context: 262_144, output: 262_144 }) + + expect(usable({ cfg: cfg(), model: mdl })).toBe(230_144) + expect(usable({ cfg: cfg({ reserved: 20_000 }), model: mdl })).toBe(230_144) + }) + + test("keeps usable context for small single-window models with large output limits", () => { + const mdl = model({ context: 40_000, output: 262_144 }) + + expect(usable({ cfg: cfg(), model: mdl })).toBe(8_000) + }) }) describe("Kilo request estimation", () => { From f0621df7ecd121e048bec7ae510d55f94d5b9583 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 13 Jul 2026 11:41:46 +0200 Subject: [PATCH 02/12] fix(cli): classify empty compaction responses --- .../src/kilocode/session/compaction-chunks.ts | 12 ++++++- .../session-compaction-chunks.test.ts | 34 +++++++++++++------ 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/kilocode/session/compaction-chunks.ts b/packages/opencode/src/kilocode/session/compaction-chunks.ts index 1cc3e802ff..692834f257 100644 --- a/packages/opencode/src/kilocode/session/compaction-chunks.ts +++ b/packages/opencode/src/kilocode/session/compaction-chunks.ts @@ -285,7 +285,17 @@ export namespace KiloCompactionChunks { const result = out.result const output = out.output if (result !== "continue") return { result, output: undefined, error: out.error } - if (!output) return { result: "stop" as const, output: undefined, error: out.error } + if (!output) + return { + result: "stop" as const, + output: undefined, + error: + out.error ?? + new MessageV2.APIError({ + message: "Compaction worker returned an empty response", + isRetryable: true, + }).toObject(), + } return { result, output, error: undefined } }) } diff --git a/packages/opencode/test/kilocode/session-compaction-chunks.test.ts b/packages/opencode/test/kilocode/session-compaction-chunks.test.ts index ca3ef398dd..6a22d29a38 100644 --- a/packages/opencode/test/kilocode/session-compaction-chunks.test.ts +++ b/packages/opencode/test/kilocode/session-compaction-chunks.test.ts @@ -146,7 +146,7 @@ function reply(text: string, capture?: (input: LLM.StreamInput) => void) { } } -function fakeRuntime(outputTokenMax?: number, error?: MessageV2.Assistant["error"]) { +function fakeRuntime(outputTokenMax?: number, error?: MessageV2.Assistant["error"], empty = false) { const calls: string[] = [] const outputs: number[] = [] const bus = Bus.layer @@ -180,13 +180,14 @@ function fakeRuntime(outputTokenMax?: number, error?: MessageV2.Assistant["error : calls.length === 1 ? "chunk one" : "chunk two" - yield* sessions.updatePart({ - id: PartID.ascending(), - messageID: input.assistantMessage.id, - sessionID: input.sessionID, - type: "text", - text, - }) + if (!empty) + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: input.assistantMessage.id, + sessionID: input.sessionID, + type: "text", + text, + }) input.assistantMessage.finish = "stop" return "continue" as const }), @@ -221,7 +222,7 @@ function fakeRuntime(outputTokenMax?: number, error?: MessageV2.Assistant["error } } -async function failure(error: MessageV2.Assistant["error"]) { +async function failure(error?: MessageV2.Assistant["error"], empty = false) { await using tmp = await tmpdir() return provideTestInstance({ directory: tmp.path, @@ -238,7 +239,7 @@ async function failure(error: MessageV2.Assistant["error"]) { }), ) - const { rt } = fakeRuntime(undefined, error) + const { rt } = fakeRuntime(undefined, error, empty) try { const msgs = await svc.messages({ sessionID: session.id }) const parent = msgs.at(-1)?.info.id @@ -375,6 +376,19 @@ describe("KiloCompactionChunks", () => { ) }) + test("reports empty chunk worker responses as API errors", async () => { + const result = await failure(undefined, true) + + expect(result.result).toBe("stop") + expect(result.summary?.info.role).toBe("assistant") + if (result.summary?.info.role !== "assistant") return + expect(result.summary.info.finish).toBe("error") + expect(result.summary.info.error?.name).toBe("APIError") + if (result.summary.info.error?.name !== "APIError") return + expect(result.summary.info.error.data.message).toBe("Compaction worker returned an empty response") + expect(result.summary.info.error.data.isRetryable).toBe(true) + }) + test("falls back to chunk workers after the first compaction overflows", async () => { await using tmp = await tmpdir() await provideTestInstance({ From e372cb3d54b36465156fbca4b01cd160ce2fa804 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 13 Jul 2026 16:30:31 +0200 Subject: [PATCH 03/12] fix(vscode): preserve focus after closing inactive tab --- .changeset/sidebar-tab-close-focus.md | 5 +++++ .../webview-ui/src/components/chat/SessionTabStrip.tsx | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 .changeset/sidebar-tab-close-focus.md diff --git a/.changeset/sidebar-tab-close-focus.md b/.changeset/sidebar-tab-close-focus.md new file mode 100644 index 0000000000..38e8d7c760 --- /dev/null +++ b/.changeset/sidebar-tab-close-focus.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep keyboard focus on the active sidebar tab after closing an inactive session tab. diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabStrip.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabStrip.tsx index f2e9d1baf7..fabd2cda72 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabStrip.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabStrip.tsx @@ -6,7 +6,7 @@ import { useLocalTabs } from "../../context/local-tabs" import { useSession } from "../../context/session" import { isPendingTab } from "../../utils/local-tabs" import { useTabScroll } from "../../utils/tab-scroll" -import { focusPrompt, focusTabElement, handleTabKey } from "../../utils/tab-navigation" +import { focusPrompt, focusSelectedTab, focusTabElement, handleTabKey } from "../../utils/tab-navigation" import { setTabWidths } from "../../utils/tab-widths" import { useVSCode } from "../../context/vscode" import { SessionTab } from "./SessionTab" @@ -61,7 +61,9 @@ export const SessionTabStrip: Component = () => { const release = () => setTabWidths(false, document) const close = (id: string) => { freeze() + const active = tabs.active() === id tabs.close(id) + if (!active) focusSelectedTab(document, focusPrompt) requestAnimationFrame(release) } const closeOthers = (id: string) => { From 5c98a0d1d407efb06f92496fc66f1c823f12d577 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 13 Jul 2026 11:51:02 -0400 Subject: [PATCH 04/12] fix(jetbrains): improve rollback scrolling and custom answer font --- .changeset/jetbrains-rollback-redo-font.md | 5 ++ .../ai/kilocode/client/session/SessionUi.kt | 52 ++++++++++++++-- .../client/session/scroll/SessionScroll.kt | 61 +++++++++++++++++++ .../client/session/ui/prompt/PromptPanel.kt | 20 +----- .../session/ui/style/SessionEditorStyle.kt | 32 ++++++++++ .../session/views/question/QuestionView.kt | 20 ++---- .../client/session/SessionScrollTest.kt | 44 ++++++++++++- .../client/session/views/QuestionViewTest.kt | 29 +++++++++ 8 files changed, 226 insertions(+), 37 deletions(-) create mode 100644 .changeset/jetbrains-rollback-redo-font.md diff --git a/.changeset/jetbrains-rollback-redo-font.md b/.changeset/jetbrains-rollback-redo-font.md new file mode 100644 index 0000000000..bd25756167 --- /dev/null +++ b/.changeset/jetbrains-rollback-redo-font.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Fix JetBrains rollback and redo scrolling and align plan custom response font with the prompt input. 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 2bd25bf5fe..12d7f90dc5 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 @@ -57,6 +57,7 @@ import ai.kilocode.log.ChatLogSummary import ai.kilocode.rpc.dto.ModelLimitDto import ai.kilocode.rpc.dto.PromptDto import ai.kilocode.rpc.dto.PromptPartDto +import ai.kilocode.rpc.dto.SessionRevertDto import com.intellij.util.ui.JBUI import ai.kilocode.log.KiloLog import com.intellij.ide.BrowserUtil @@ -127,6 +128,8 @@ class SessionUi( private var pending = false private var loaded: Boolean? = null private var revertPrompt: String? = null + private var pendingRollback: String? = null + private var pendingRedo: String? = null private val flushMs = Registry.intValue("kilo.session.flushMs", EVENT_FLUSH_MS.toInt()) .takeIf { it > 0 } @@ -362,8 +365,8 @@ class SessionUi( repo = workspace.directory, resize = { anchor, fn -> scroll.preserve(anchor, fn) }, revert = ::revert, - cancelRevert = controller::cancelRevert, - banner = RevertBanner(controller.model, controller::redo, controller::redoAll, controller::cancelRevert, focus), + cancelRevert = ::cancelRevert, + banner = RevertBanner(controller.model, ::redo, controller::redoAll, ::cancelRevert, focus), ).also { it.onHover = { view, on -> if (on) popup.show(view) else popup.notifyExit(view) } } @@ -537,7 +540,7 @@ class SessionUi( is SessionModelEvent.SessionUpdated -> onSessionUpdated() - is SessionModelEvent.RevertChanged -> syncPromptRevert() + is SessionModelEvent.RevertChanged -> onRevertChanged(event.revert) is SessionModelEvent.TurnAdded, is SessionModelEvent.TurnUpdated, @@ -688,10 +691,47 @@ class SessionUi( @RequiresEdt private fun revert(id: String) { - scroll.followBottom(true) + pendingRollback = id + pendingRedo = null controller.revert(id) } + @RequiresEdt + private fun redo() { + pendingRedo = controller.model.revert()?.messageID + pendingRollback = null + controller.redo() + } + + @RequiresEdt + private fun cancelRevert() { + pendingRollback = null + pendingRedo = null + controller.cancelRevert() + } + + @RequiresEdt + private fun onRevertChanged(revert: SessionRevertDto?) { + syncPromptRevert() + val rollback = pendingRollback + if (rollback != null) { + if (revert?.messageID == rollback) { + pendingRollback = null + scroll.followBottom(true) + return + } + pendingRollback = null + } + val redo = pendingRedo + if (redo == null) return + if (!controller.model.isRevertedMessage(redo)) { + pendingRedo = null + scroll.scrollMessageBottom(redo) + return + } + if (revert != null) pendingRedo = null + } + @RequiresEdt private fun syncPromptRevert() { val saved = revertPrompt @@ -811,6 +851,10 @@ class SessionUi( private fun onStateChanged(state: SessionState) { if (disposed) return if (state is SessionState.Reverting) overlay.clear() + if (state is SessionState.Error) { + pendingRollback = null + pendingRedo = null + } prompt.setBusy(state.isBusy()) load.setState(state) scroll.setQuestionPending(questionPending(state)) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt index dd891fa1cc..04c7bdb56e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt @@ -134,6 +134,27 @@ internal class SessionScroll( followBottom(component.viewport.view === messages && tail) } + @RequiresEdt + fun scrollMessageBottom(id: String): Boolean { + val target = messages.findMessage(id) ?: return false + if (!target.isVisible) return false + user = false + pause = false + stable = -1 + auto = true + show(messages) + auto = false + val gen = ++seq + if (SwingUtilities.isEventDispatchThread()) { + messagePass(gen, id, FOLLOW_PASSES) + return true + } + ApplicationManager.getApplication().invokeLater { + messagePass(gen, id, FOLLOW_PASSES) + } + return true + } + @RequiresEdt fun following(): Boolean { return component.viewport.view === messages && tail @@ -290,6 +311,39 @@ internal class SessionScroll( } } + @RequiresEdt + private fun messagePass(id: Int, message: String, remaining: Int) { + if (id != seq) return + val target = messages.findMessage(message) + if (target == null || !target.isVisible) { + stable = -1 + updateJump() + return + } + auto = true + try { + layoutScroll() + val y = messageBottom(target) + component.viewport.viewPosition = Point(0, y) + bar.value = y + tail = near() + updateJump() + } finally { + auto = false + } + syncValue() + if (remaining <= 0) { + stable = -1 + return + } + val next = messageBottom(target) + val left = if (next == stable) remaining - 1 else FOLLOW_PASSES + stable = next + ApplicationManager.getApplication().invokeLater { + messagePass(id, message, left) + } + } + @RequiresEdt private fun layoutScroll() { root.validate() @@ -305,6 +359,13 @@ internal class SessionScroll( bar.value = bottom() } + @RequiresEdt + private fun messageBottom(target: JComponent): Int { + val point = SwingUtilities.convertPoint(target, Point(0, target.height.coerceAtLeast(1)), messages) + val extent = component.viewport.extentSize.height + return (point.y - extent).coerceIn(0, bottom()) + } + @RequiresEdt private fun bottom(): Int { val bar = component.verticalScrollBar diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt index ed36d318ef..dd7a957990 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt @@ -378,24 +378,8 @@ class PromptPanel( @RequiresEdt private fun chrome(ed: EditorEx) { if (ed.isDisposed) return - style.applyTranscriptToEditor(ed) + style.applyPromptToEditor(ed) if (ed.isDisposed) return - val bg = style.editorBackground - ed.setBorder(JBUI.Borders.empty()) - ed.scrollPane.border = JBUI.Borders.empty() - ed.scrollPane.viewportBorder = JBUI.Borders.empty( - 0, - JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_HORIZONTAL_INSET), - 0, - JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_HORIZONTAL_INSET), - ) - ed.backgroundColor = bg - ed.component.background = bg - ed.contentComponent.background = bg - ed.scrollPane.background = bg - ed.scrollPane.viewport.background = bg - ed.scrollPane.revalidate() - ed.scrollPane.repaint() } @RequiresEdt @@ -482,7 +466,7 @@ class PromptPanel( this.style = style background = style.editorScheme.defaultBackground shell.background = style.editorScheme.defaultBackground - editor.font = style.transcriptFont + style.applyTranscriptToField(editor) editor.getEditor(false)?.let(::chrome) editor.background = style.editorBackground syncEditorHeight() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt index 16f40d3449..8096ba8272 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt @@ -4,9 +4,12 @@ import ai.kilocode.client.ui.UiStyle import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.openapi.editor.colors.EditorColorsScheme import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.ui.EditorTextField import com.intellij.util.ui.JBFont +import com.intellij.util.ui.JBUI import java.awt.Color import java.awt.Font +import javax.swing.ScrollPaneConstants import kotlin.math.roundToInt /** @@ -62,6 +65,35 @@ data class SessionEditorStyle( } } + /** Apply standard transcript typography to an editor text field and its embedded editor when available. */ + fun applyTranscriptToField(field: EditorTextField) { + field.font = transcriptFont + field.getEditor(false)?.let(::applyTranscriptToEditor) + } + + /** Apply the visible prompt-input text styling to embedded session editor components. */ + fun applyPromptToEditor(editor: EditorEx) { + if (editor.isDisposed) return + applyTranscriptToEditor(editor) + if (editor.isDisposed) return + editor.setBorder(JBUI.Borders.empty()) + editor.scrollPane.border = JBUI.Borders.empty() + editor.scrollPane.viewportBorder = JBUI.Borders.empty( + 0, + JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_HORIZONTAL_INSET), + 0, + JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_HORIZONTAL_INSET), + ) + editor.backgroundColor = editorBackground + editor.component.background = editorBackground + editor.contentComponent.background = editorBackground + editor.scrollPane.background = editorBackground + editor.scrollPane.viewport.background = editorBackground + editor.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + editor.scrollPane.revalidate() + editor.scrollPane.repaint() + } + companion object { /** Builds a style snapshot from the current global editor color scheme. */ fun current(): SessionEditorStyle { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt index 6cd19df3aa..9c564775d1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt @@ -25,7 +25,6 @@ import com.intellij.ui.components.JBRadioButton import com.intellij.ui.components.JBTextArea import com.intellij.util.ui.JBUI import com.intellij.util.ui.components.BorderLayoutPanel -import javax.swing.ScrollPaneConstants import java.awt.BorderLayout import java.awt.Color import java.awt.Component @@ -165,9 +164,9 @@ class QuestionView( this.style = style card.applyStyle(style) customEditor?.let { ed -> - ed.font = style.transcriptFont - ed.getEditor(false)?.let(style::applyTranscriptToEditor) - ed.background = style.editorScheme.defaultBackground + style.applyTranscriptToField(ed) + ed.background = style.editorBackground + syncEditorHeight(ed) } val changed = texts.fold(false) { acc, item -> setFont(item.first, item.second) || acc } if (!changed) return @@ -496,21 +495,14 @@ class QuestionView( ed.setShowPlaceholderWhenFocused(true) ed.setOneLineMode(false) ed.addSettingsProvider { ex -> - style.applyTranscriptToEditor(ex) - ex.setBorder(JBUI.Borders.empty()) - ex.scrollPane.border = JBUI.Borders.empty() - ex.scrollPane.viewportBorder = JBUI.Borders.empty() - ex.backgroundColor = style.editorScheme.defaultBackground - ex.scrollPane.background = style.editorScheme.defaultBackground - ex.scrollPane.viewport.background = style.editorScheme.defaultBackground + style.applyPromptToEditor(ex) ex.settings.isUseSoftWraps = true ex.settings.isPaintSoftWraps = false ex.settings.isAdditionalPageAtBottom = false - ex.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER } selection?.register(ed)?.let(regs::add) - ed.font = style.transcriptFont - ed.background = style.editorScheme.defaultBackground + style.applyTranscriptToField(ed) + ed.background = style.editorBackground // Pre-fill with saved text. This call also forces lazy document creation so // that addDocumentListener can install on a non-null document immediately. diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt index 488cb141cc..7d0a6ebe4f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt @@ -1061,13 +1061,28 @@ class SessionScrollTest : SessionUiTestBase() { assertTrue(jumpButton().isVisible) } - fun `test rollback click follows bottom and hides jump button`() { + fun `test rollback click does not scroll before marker update`() { showMessages() fillTranscript(48) val bar = scrollBar() setValue(bar, bottom(bar) / 2) + val value = bar.value assertTrue(jumpButton().isVisible) + rollback("msg_36").doClick() + settle() + drainScroll() + + assertEquals(value, bar.value) + assertTrue(jumpButton().isVisible) + } + + fun `test rollback scrolls after marker shows banner`() { + showMessages() + fillTranscript(48) + val bar = scrollBar() + setValue(bar, bottom(bar) / 2) + rollback("msg_36").doClick() settle() drainScroll() @@ -1079,6 +1094,25 @@ class SessionScrollTest : SessionUiTestBase() { assertFalse(jumpButton().isVisible) } + fun `test redo scrolls to restored message bottom`() { + showMessages() + fillTranscript(48) + val bar = scrollBar() + emit(ChatEventDto.SessionUpdated("ses_test", session("ses_test").copy(revert = SessionRevertDto("msg_36")))) + drainScroll() + setValue(bar, 0) + + button(KiloBundle.message("revert.banner.redo")).doClick() + settle() + drainScroll() + emit(ChatEventDto.SessionUpdated("ses_test", session("ses_test").copy(revert = SessionRevertDto("msg_37")))) + drainScroll() + + val expected = messageBottomValue("msg_36") + assertTrue("expected=$expected bottom=${bottom(bar)}", expected < bottom(bar)) + assertTrue("value=${bar.value} expected=$expected", kotlin.math.abs(bar.value - expected) <= 1) + } + // ------ helpers ------ private fun button(text: String): JButton = findAll(ui).first { it.text == text } @@ -1093,6 +1127,14 @@ class SessionScrollTest : SessionUiTestBase() { .first { it.toolTipText == KiloBundle.message("revert.message.rollback") } } + private fun messageBottomValue(id: String): Int { + val pane = scrollComponent() as JBScrollPane + val messages = find(ui) + val message = messages.findMessage(id) ?: error("missing message $id") + val point = SwingUtilities.convertPoint(message, Point(0, message.height.coerceAtLeast(1)), messages) + return (point.y - pane.viewport.extentSize.height).coerceIn(0, bottom(scrollBar())) + } + private inline fun option(label: String): T where T : AbstractButton = findAll(ui).first { it.actionCommand == label } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt index e2591ece67..ae87a37612 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt @@ -17,6 +17,7 @@ import com.intellij.ui.components.JBCheckBox import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBRadioButton import com.intellij.ui.components.JBTextArea +import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import java.awt.BorderLayout import java.awt.Component @@ -287,6 +288,10 @@ class QuestionViewTest : BasePlatformTestCase() { assertEquals(style.transcriptFont.fontName, editor.colorsScheme.editorFontName) assertEquals(style.transcriptFont.size, editor.colorsScheme.editorFontSize) assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, editor.scrollPane.horizontalScrollBarPolicy) + val ins = editor.scrollPane.viewportBorder.getBorderInsets(editor.scrollPane) + val pad = JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_HORIZONTAL_INSET) + assertEquals(pad, ins.left) + assertEquals(pad, ins.right) assertTrue(editor.settings.isUseSoftWraps) assertFalse(editor.settings.isPaintSoftWraps) } finally { @@ -295,6 +300,30 @@ class QuestionViewTest : BasePlatformTestCase() { } } + fun `test custom answer editor style updates use transcript font`() { + view.show(customSingleQuestion("q_custom_style_update")) + + findAll(view).first { it.actionCommand == "" }.doClick() + val field = findAll(view).first() + view.addNotify() + try { + layout(view) + UIUtil.dispatchAllInvocationEvents() + val editor = field.getEditor(true) ?: error("missing editor") + val style = SessionEditorStyle.create(family = "Courier New", size = 26) + + view.applyStyle(style) + + assertEquals(style.transcriptFont, field.font) + assertEquals(style.transcriptFont.fontName, editor.colorsScheme.editorFontName) + assertEquals(style.transcriptFont.size, editor.colorsScheme.editorFontSize) + assertFalse(style.editorFont.fontName == editor.colorsScheme.editorFontName) + } finally { + view.hideView() + view.removeNotify() + } + } + // ------ multi-question navigation ------ fun `test multi question shows one question at a time and navigates`() { From 414848ad88d536a8a86f68ac89d344f5103d1e94 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 13 Jul 2026 18:11:50 +0200 Subject: [PATCH 05/12] fix(vscode): preserve focus when middle-clicking tab --- .../webview-ui/src/components/chat/SessionTabStrip.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabStrip.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabStrip.tsx index fabd2cda72..068a5d9d63 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabStrip.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/SessionTabStrip.tsx @@ -35,7 +35,7 @@ export const SessionTabStrip: Component = () => { if (event.button !== 1) return event.preventDefault() event.stopPropagation() - close(id) + close(id, false) } const key = (id: string, event: KeyboardEvent) => { const root = event.currentTarget instanceof HTMLElement ? event.currentTarget.closest(".am-tab-list") : null @@ -59,11 +59,11 @@ export const SessionTabStrip: Component = () => { const root = () => document.querySelector("[data-component=session-tabs] .am-tab-list") const freeze = () => setTabWidths(true, document) const release = () => setTabWidths(false, document) - const close = (id: string) => { + const close = (id: string, restore = true) => { freeze() const active = tabs.active() === id tabs.close(id) - if (!active) focusSelectedTab(document, focusPrompt) + if (!active && restore) focusSelectedTab(document, focusPrompt) requestAnimationFrame(release) } const closeOthers = (id: string) => { From 15a3132aa2210b49093a5bc17218682549cf1989 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 13 Jul 2026 15:08:09 -0400 Subject: [PATCH 06/12] fix(jetbrains): show auto-hiding vertical scrollbar when prompt overflows cap Both the main prompt editor and the custom question-response editor now grow up to ~1/3 of the session root height, then enable a standard auto-hiding vertical scrollbar (appears on scroll/hover, fades on inactivity). While content fits, no scrollbar is shown. - PromptPanel: syncEditorScroll toggles verticalScrollBarPolicy between VERTICAL_SCROLLBAR_NEVER and VERTICAL_SCROLLBAR_AS_NEEDED based on content vs capped height. - QuestionView: same capped-height + auto-hiding scrollbar behavior for the custom-answer editor; also syncs on component resize and addNotify. - Tests assert the policy flips correctly after overflow and returns to NEVER when content shrinks back to fit. --- .../client/session/ui/prompt/PromptPanel.kt | 20 ++++-- .../session/views/question/QuestionView.kt | 67 +++++++++++++++++-- .../client/session/ui/PromptPanelTest.kt | 20 +++++- .../client/session/views/QuestionViewTest.kt | 49 ++++++++++++++ 4 files changed, 142 insertions(+), 14 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt index dd7a957990..90312d7572 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt @@ -177,12 +177,9 @@ class PromptPanel( ed.settings.setBlockCursor(false) SpellCheckingEditorCustomizationProvider.getInstance().getDisabledCustomization()?.customize(ed) ed.putUserData(PROMPT_ATTACHMENT_PASTE_HANDLER_KEY, PromptAttachmentPasteHandler { processPaste(it) }) - ed.setVerticalScrollbarVisible(false) ed.setHorizontalScrollbarVisible(false) - ed.scrollPane.verticalScrollBarPolicy = - ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER - ed.scrollPane.horizontalScrollBarPolicy = - ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER + ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER installCompletionShortcut(ed) completion?.let { MentionNavigator(ed, it).install() } installFileDrop(ed.contentComponent, "editor") @@ -911,6 +908,7 @@ class PromptPanel( val content = editor.preferredSize.height val sessionCap = rootCap(min) val height = minOf(content, sessionCap ?: content).coerceAtLeast(min) + syncEditorScroll(view, content > height) if (before == height && lower == height) { editor.preferredSize = JBDimension(0, height) editor.minimumSize = JBDimension(0, height) @@ -922,6 +920,18 @@ class PromptPanel( repaint() } + @RequiresEdt + private fun syncEditorScroll(ed: EditorEx?, overflow: Boolean) { + // AS_NEEDED keeps the standard auto-hiding editor scrollbar (appears on + // scroll/hover, fades on inactivity); NEVER hides it entirely when the + // content fits so no bar is shown at all. + ed?.scrollPane?.verticalScrollBarPolicy = if (overflow) { + ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + } else { + ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER + } + } + @RequiresEdt private fun rootCap(min: Int): Int? { val root = root ?: return null diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt index 9c564775d1..c08851d4e2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.Question import ai.kilocode.client.session.model.QuestionItem import ai.kilocode.client.session.model.QuestionOption +import ai.kilocode.client.session.ui.SessionRootPanel import ai.kilocode.client.session.ui.SessionView import ai.kilocode.client.session.ui.editor.SessionEditorTextField import ai.kilocode.client.session.views.SessionViewIcons @@ -11,11 +12,15 @@ import ai.kilocode.client.session.views.base.BaseQuestionView import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.HoverIcon import ai.kilocode.client.ui.UiStyle import ai.kilocode.rpc.dto.QuestionReplyDto import com.intellij.openapi.Disposable import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.editor.event.DocumentEvent +import com.intellij.openapi.editor.event.DocumentListener +import com.intellij.openapi.editor.ex.EditorEx import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.IconLoader @@ -23,6 +28,8 @@ import com.intellij.ui.components.JBCheckBox import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBRadioButton import com.intellij.ui.components.JBTextArea +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBDimension import com.intellij.util.ui.JBUI import com.intellij.util.ui.components.BorderLayoutPanel import java.awt.BorderLayout @@ -31,6 +38,8 @@ import java.awt.Component import java.awt.Dimension import java.awt.GridBagLayout import java.awt.Rectangle +import java.awt.event.ComponentAdapter +import java.awt.event.ComponentEvent import java.awt.event.FocusAdapter import java.awt.event.FocusEvent import java.awt.event.MouseAdapter @@ -40,9 +49,8 @@ import javax.swing.Box import javax.swing.BoxLayout import javax.swing.ButtonGroup import javax.swing.JPanel -import com.intellij.openapi.editor.event.DocumentEvent -import com.intellij.openapi.editor.event.DocumentListener -import com.intellij.util.concurrency.annotations.RequiresEdt +import javax.swing.ScrollPaneConstants +import javax.swing.SwingUtilities /** Question tool form rendered inside the session transcript. */ class QuestionView( @@ -70,6 +78,12 @@ class QuestionView( // The custom editor for the currently shown question; null when not shown. private var customEditor: SessionEditorTextField? = null private var customFocus: FocusAdapter? = null + private val resize = object : ComponentAdapter() { + @RequiresEdt + override fun componentResized(e: ComponentEvent) { + customEditor?.let(::syncEditorHeight) + } + } private val card = BaseQuestionView(selection, focus) @@ -111,6 +125,7 @@ class QuestionView( init { isOpaque = false isVisible = false + addComponentListener(resize) nav.add(back) nav.add(fwd) @@ -122,6 +137,12 @@ class QuestionView( add(card, BorderLayout.CENTER) } + @RequiresEdt + override fun addNotify() { + super.addNotify() + customEditor?.let(::syncEditorHeight) + } + @RequiresEdt fun show(q: Question) { if (q.items.isEmpty()) { @@ -499,6 +520,10 @@ class QuestionView( ex.settings.isUseSoftWraps = true ex.settings.isPaintSoftWraps = false ex.settings.isAdditionalPageAtBottom = false + ex.setHorizontalScrollbarVisible(false) + ex.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER + ex.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + syncEditorHeight(ed, ex) } selection?.register(ed)?.let(regs::add) style.applyTranscriptToField(ed) @@ -538,13 +563,41 @@ class QuestionView( @RequiresEdt private fun syncEditorHeight(ed: SessionEditorTextField) { - val editor = ed.getEditor(false) + syncEditorHeight(ed, ed.getEditor(false)) + } + + @RequiresEdt + private fun syncEditorHeight(ed: SessionEditorTextField, editor: EditorEx?) { val estimated = estimatedLines(ed) val lines = maxOf(editor?.offsetToVisualPosition(editor.document.textLength)?.line?.plus(1) ?: estimated, estimated) val line = editor?.lineHeight ?: ed.getFontMetrics(ed.font).height - val height = line * lines.coerceAtLeast(1) + JBUI.scale(16) - ed.preferredSize = Dimension(0, height) - ed.minimumSize = Dimension(0, height) + val min = line + JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_CHROME) + val content = line * lines.coerceAtLeast(1) + JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_CHROME) + val cap = rootCap(min) + val height = minOf(content, cap ?: content).coerceAtLeast(min) + syncEditorScroll(editor, content > height) + ed.preferredSize = JBDimension(0, height) + ed.minimumSize = JBDimension(0, height) + } + + @RequiresEdt + private fun syncEditorScroll(ed: EditorEx?, overflow: Boolean) { + // AS_NEEDED keeps the standard auto-hiding editor scrollbar (appears on + // scroll/hover, fades on inactivity); NEVER hides it entirely when the + // content fits so no bar is shown at all. + ed?.scrollPane?.verticalScrollBarPolicy = if (overflow) { + ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + } else { + ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER + } + } + + @RequiresEdt + private fun rootCap(min: Int): Int? { + val root = SwingUtilities.getAncestorOfClass(SessionRootPanel::class.java, this) as? SessionRootPanel + ?: return null + if (root.height <= 0) return null + return (root.height / 3).coerceAtLeast(min) } @RequiresEdt diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt index b8020f2c64..0ee49b8edd 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt @@ -332,6 +332,10 @@ class PromptPanelTest : BasePlatformTestCase() { val chrome = (panel.preferredSize.height - editor.preferredSize.height).coerceAtLeast(0) assertTrue(editor.preferredSize.height <= root.height / 3 - chrome + 1) + assertEquals( + ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, + editor.getEditor(false)!!.scrollPane.verticalScrollBarPolicy, + ) } fun `test attachment strip is included in session root cap`() { @@ -350,17 +354,29 @@ class PromptPanelTest : BasePlatformTestCase() { assertTrue(attachedEditor.preferredSize.height < plainEditor.preferredSize.height) } - fun `test prompt editor hides scrollbars and keeps soft wraps`() { + fun `test prompt editor hides scrollbars until content overflows cap`() { val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) realize(panel, 180, 400) - val editor = (panel.defaultFocusedComponent as EditorTextField).getEditor(false)!! + val field = panel.defaultFocusedComponent as EditorTextField + val editor = field.getEditor(false)!! assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, editor.scrollPane.verticalScrollBarPolicy) assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, editor.scrollPane.horizontalScrollBarPolicy) assertTrue(editor.settings.isUseSoftWraps) assertFalse(editor.settings.isPaintSoftWraps) assertFalse(editor.settings.isBlockCursor) + + field.text = (1..40).joinToString("\n") { "line $it" } + UIUtil.dispatchAllInvocationEvents() + + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, editor.scrollPane.verticalScrollBarPolicy) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, editor.scrollPane.horizontalScrollBarPolicy) + + field.text = "short" + UIUtil.dispatchAllInvocationEvents() + + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, editor.scrollPane.verticalScrollBarPolicy) } fun `test prompt editor highlights validated commands and mentions`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt index ae87a37612..57ef7ea8d4 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.model.Question import ai.kilocode.client.session.model.QuestionItem import ai.kilocode.client.session.model.QuestionOption +import ai.kilocode.client.session.ui.SessionRootPanel import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.views.base.BaseQuestionView @@ -34,6 +35,7 @@ class QuestionViewTest : BasePlatformTestCase() { private val replies = mutableListOf>>>() private val rejects = mutableListOf() + private val roots = mutableListOf() private var scrolls = 0 private lateinit var view: QuestionView @@ -47,6 +49,15 @@ class QuestionViewTest : BasePlatformTestCase() { ) } + override fun tearDown() { + try { + roots.asReversed().forEach { it.removeNotify() } + roots.clear() + } finally { + super.tearDown() + } + } + // ------ empty question ------ fun `test empty question hides view and clears stale request id`() { @@ -737,6 +748,33 @@ class QuestionViewTest : BasePlatformTestCase() { assertTrue("custom editor should grow when soft-wrapped text needs more lines", ed.preferredSize.height > initial) } + fun `test custom editor enables vertical scrollbar only after cap`() { + view.show(customSingleQuestion("q_custom_cap")) + val root = realize(view, 240, 600) + + val customRadio = findAll(view).first { it.actionCommand == "" } + customRadio.doClick() + layoutTree(root) + + val ed = findAll(view).first() + val editor = ed.getEditor(false)!! + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, editor.scrollPane.verticalScrollBarPolicy) + + ed.text = (1..40).joinToString("\n") { "line $it" } + layoutTree(root) + UIUtil.dispatchAllInvocationEvents() + + assertTrue(ed.preferredSize.height <= root.height / 3) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, editor.scrollPane.verticalScrollBarPolicy) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, editor.scrollPane.horizontalScrollBarPolicy) + + ed.text = "short" + layoutTree(root) + UIUtil.dispatchAllInvocationEvents() + + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, editor.scrollPane.verticalScrollBarPolicy) + } + fun `test blank custom input does not enable submit`() { view.show(customSingleQuestion("q_custom_blank")) @@ -1027,6 +1065,17 @@ class QuestionViewTest : BasePlatformTestCase() { layoutTree(root) } + private fun realize(child: Component, width: Int, height: Int): SessionRootPanel { + val root = SessionRootPanel() + root.setSize(width, height) + root.content.add(child, BorderLayout.CENTER) + root.addNotify() + layoutTree(root) + UIUtil.dispatchAllInvocationEvents() + roots.add(root) + return root + } + private fun layoutTree(root: Container) { root.doLayout() for (child in root.components) { From 6881b19d415600d5556f27b1699e834f97fa4eec Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 13 Jul 2026 15:13:55 -0400 Subject: [PATCH 07/12] fix(jetbrains): align progress footer with transcript inset --- .../kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt | 3 +-- .../ai/kilocode/client/session/ui/ProgressPanelTest.kt | 6 ++---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt index bbc44bd12c..801e39b559 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt @@ -6,7 +6,6 @@ import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget -import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack import ai.kilocode.client.ui.layout.StackAxis @@ -45,7 +44,7 @@ class ProgressPanel( isVisible = false border = JBUI.Borders.empty( UiStyle.Gap.sm(), - JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), + 0, 0, 0, ) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt index b1a7e56f01..98ebd18fa1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt @@ -5,12 +5,10 @@ import ai.kilocode.client.session.model.Permission import ai.kilocode.client.session.model.PermissionMeta import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionState -import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.UiStyle import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase -import com.intellij.util.ui.JBUI /** * Verifies [ProgressPanel] show/hide behaviour driven by direct [SessionModel] @@ -49,11 +47,11 @@ class ProgressPanelTest : BasePlatformTestCase() { assertEquals("Thinking\u2026", panel.labelText()) } - fun `test panel uses transcript row padding`() { + fun `test panel relies on transcript inset for left padding`() { val ins = panel.insets assertEquals(UiStyle.Gap.sm(), ins.top) - assertEquals(JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), ins.left) + assertEquals(0, ins.left) assertEquals(0, ins.bottom) assertEquals(0, ins.right) } From de06c407f91fd8131c6c703386b1684e3cf0e363 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 13 Jul 2026 15:40:19 -0400 Subject: [PATCH 08/12] feat(jetbrains): show elapsed time in progress footer --- .changeset/jetbrains-progress-elapsed-time.md | 5 + .../client/session/ui/ProgressPanel.kt | 64 ++++++++- .../client/session/ui/ProgressPanelTest.kt | 134 +++++++++++++++++- 3 files changed, 189 insertions(+), 14 deletions(-) create mode 100644 .changeset/jetbrains-progress-elapsed-time.md diff --git a/.changeset/jetbrains-progress-elapsed-time.md b/.changeset/jetbrains-progress-elapsed-time.md new file mode 100644 index 0000000000..11a85d57b4 --- /dev/null +++ b/.changeset/jetbrains-progress-elapsed-time.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show elapsed time in the JetBrains progress footer while Kilo is working. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt index 801e39b559..3f07625406 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt @@ -9,10 +9,14 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack import ai.kilocode.client.ui.layout.StackAxis +import ai.kilocode.client.util.UiTimerSource +import ai.kilocode.client.util.UiTimers import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer import com.intellij.ui.AnimatedIcon import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBUI +import com.intellij.util.ui.components.BorderLayoutPanel /** * Progress footer rendered at the bottom of the session transcript while the @@ -30,14 +34,20 @@ import com.intellij.util.ui.JBUI class ProgressPanel( model: SessionModel, parent: Disposable, -) : Stack(StackAxis.HORIZONTAL, UiStyle.Gap.md()), SessionEditorStyleTarget { + private val clock: UiTimerSource = UiTimers, +) : BorderLayoutPanel(), SessionEditorStyleTarget { private var style = SessionEditorStyle.current() private var state: SessionState = SessionState.Idle + private var began = 0L private val label = JBLabel().apply { foreground = style.editorForeground } + private val elapsed = JBLabel().apply { + foreground = UiStyle.Colors.weak() + } private val spinner = JBLabel(AnimatedIcon.Default()) + private val tick = clock.timer(1000) { syncElapsed() } init { isOpaque = false @@ -50,8 +60,13 @@ class ProgressPanel( ) applyStyle(SessionEditorStyle.current()) - next(spinner) - next(label) + addToLeft( + Stack(StackAxis.HORIZONTAL, UiStyle.Gap.md()) + .next(spinner) + .next(label), + ) + addToRight(elapsed) + Disposer.register(parent) { tick.stop() } model.addListener(parent) { event -> if (event is SessionModelEvent.StateChanged) onState(event.state) @@ -61,6 +76,9 @@ class ProgressPanel( /** Exposed for test assertions. */ fun labelText(): String = label.text + /** Exposed for test assertions. */ + fun elapsedText(): String = elapsed.text + /** Exposed for test assertions. */ fun labelForeground() = label.foreground @@ -71,26 +89,46 @@ class ProgressPanel( spinner.isVisible = true label.text = state.text label.foreground = style.editorForeground - isVisible = true + showProgress() } is SessionState.Retry -> { spinner.isVisible = true label.text = retryText(state) label.foreground = UiStyle.Colors.warningLabelForeground() - isVisible = true + showProgress() } is SessionState.Offline -> { spinner.isVisible = false label.text = state.message.ifBlank { KiloBundle.message("session.status.offline") } label.foreground = UiStyle.Colors.errorLabelForeground() - isVisible = true + showProgress() } - else -> isVisible = false + else -> hideProgress() } revalidate() repaint() } + private fun showProgress() { + if (!isVisible) { + began = clock.now() + syncElapsed() + } + if (!tick.isRunning()) tick.start() + isVisible = true + } + + private fun hideProgress() { + tick.stop() + isVisible = false + } + + private fun syncElapsed() { + elapsed.text = elapsedText((clock.now() - began).coerceAtLeast(0)) + revalidate() + repaint() + } + private fun retryText(state: SessionState.Retry): String { val base = state.message.ifBlank { KiloBundle.message("session.status.retry") } return if (state.attempt > 0) { @@ -101,8 +139,20 @@ class ProgressPanel( override fun applyStyle(style: SessionEditorStyle) { this.style = style label.font = style.regularFont + elapsed.font = style.regularFont + elapsed.foreground = UiStyle.Colors.weak() if (state is SessionState.Busy) label.foreground = style.editorForeground revalidate() repaint() } + + private fun elapsedText(ms: Long): String { + val total = ms / 1000 + val sec = total % 60 + val min = (total / 60) % 60 + val hour = total / 3600 + if (hour > 0) return "${hour}h ${min}m ${sec}s" + if (min > 0) return "${min}m ${sec}s" + return "${sec}s" + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt index 98ebd18fa1..2c112f306b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt @@ -6,9 +6,14 @@ import ai.kilocode.client.session.model.PermissionMeta import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.util.UiTimer +import ai.kilocode.client.util.UiTimerSource import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBLabel +import java.awt.Component +import java.awt.Container /** * Verifies [ProgressPanel] show/hide behaviour driven by direct [SessionModel] @@ -45,6 +50,7 @@ class ProgressPanelTest : BasePlatformTestCase() { assertTrue(panel.isVisible) assertEquals("Thinking\u2026", panel.labelText()) + assertEquals("0s", panel.elapsedText()) } fun `test panel relies on transcript inset for left padding`() { @@ -101,14 +107,66 @@ class ProgressPanelTest : BasePlatformTestCase() { assertEquals("Rate limited", panel.labelText()) } + fun `test elapsed time ticks while progress is visible`() { + val clock = FakeClock() + replace(clock) + + model.setState(SessionState.Busy("Thinking")) + + assertEquals("0s", panel.elapsedText()) + assertTrue(clock.timer.isRunning()) + + clock.advance(59_000) + assertEquals("59s", panel.elapsedText()) + + clock.advance(23_000) + assertEquals("1m 22s", panel.elapsedText()) + + clock.advance(3_600_000) + assertEquals("1h 1m 22s", panel.elapsedText()) + } + + fun `test elapsed time is right aligned`() { + val clock = FakeClock() + replace(clock) + + model.setState(SessionState.Busy("Thinking")) + panel.setSize(300, panel.preferredSize.height) + panel.doLayout() + + val time = labels(panel).first { it.text == "0s" } + + assertEquals(panel.width - panel.insets.right, time.x + time.width) + } + + fun `test elapsed time continues across visible progress states and stops when hidden`() { + val clock = FakeClock() + replace(clock) + + model.setState(SessionState.Busy("Thinking")) + clock.advance(61_000) + model.setState(SessionState.Retry("Rate limited", attempt = 1, next = 0L)) + + assertEquals("1m 1s", panel.elapsedText()) + + model.setState(SessionState.Idle) + assertFalse(clock.timer.isRunning()) + + clock.advance(1_000) + assertEquals("1m 1s", panel.elapsedText()) + + model.setState(SessionState.Busy("Thinking again")) + assertEquals("0s", panel.elapsedText()) + } + fun `test reverting state is busy`() { assertTrue(SessionState.Reverting("x", SessionState.Reverting.Kind.ROLLBACK).isBusy()) } fun `test state churn retains footer components`() { - val count = panel.componentCount - val icon = panel.components[0] - val text = panel.components[1] + val clock = FakeClock() + replace(clock) + val comps = components(panel) repeat(500) { i -> model.setState(SessionState.Busy("Thinking $i")) @@ -116,9 +174,7 @@ class ProgressPanelTest : BasePlatformTestCase() { model.setState(SessionState.Offline("Computer appears offline", requestId = "req$i")) model.setState(SessionState.Idle) - assertEquals(count, panel.componentCount) - assertSame(icon, panel.components[0]) - assertSame(text, panel.components[1]) + assertEquals(comps, components(panel)) } } @@ -148,6 +204,13 @@ class ProgressPanelTest : BasePlatformTestCase() { // ------ helpers ------ + private fun replace(clock: FakeClock) { + Disposer.dispose(parent) + parent = Disposer.newDisposable("test replacement") + model = SessionModel() + panel = ProgressPanel(model, parent, clock) + } + private fun stub() = Permission( id = "perm1", sessionId = "ses", @@ -157,5 +220,62 @@ class ProgressPanelTest : BasePlatformTestCase() { meta = PermissionMeta(raw = emptyMap()), ) - private fun spinner() = panel.components[0] + private fun spinner() = labels(panel).first { it.icon != null } + + private fun labels(root: Container): List { + val items = mutableListOf() + for (child in root.components) { + if (child is JBLabel) items.add(child) + if (child is Container) items.addAll(labels(child)) + } + return items + } + + private fun components(root: Container): List { + val items = mutableListOf() + for (child in root.components) { + items.add(child) + if (child is Container) items.addAll(components(child)) + } + return items + } + + private class FakeClock : UiTimerSource { + var time = 0L + lateinit var timer: FakeTimer + + override fun now(): Long = time + + override fun timer(ms: Int, repeats: Boolean, action: () -> Unit): UiTimer { + timer = FakeTimer(action) + return timer + } + + fun advance(ms: Long) { + time += ms + timer.fire() + } + } + + private class FakeTimer(private val action: () -> Unit) : UiTimer { + private var running = false + + override fun start() { + running = true + } + + override fun stop() { + running = false + } + + override fun restart() { + running = true + } + + override fun isRunning(): Boolean = running + + fun fire() { + if (running) action() + } + } } From 227c65d1004fc1f48e71335cc574a2e6986c4893 Mon Sep 17 00:00:00 2001 From: Josh Holmer Date: Mon, 13 Jul 2026 16:15:01 -0400 Subject: [PATCH 09/12] fix(indexing): retry remote embedder validation on fail --- .changeset/neat-mammals-fry.md | 5 +++++ packages/kilo-indexing/src/indexing/constants/index.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/neat-mammals-fry.md diff --git a/.changeset/neat-mammals-fry.md b/.changeset/neat-mammals-fry.md new file mode 100644 index 0000000000..08000c3287 --- /dev/null +++ b/.changeset/neat-mammals-fry.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-indexing": patch +--- + +Retry remote embedder validation up to twice on failure diff --git a/packages/kilo-indexing/src/indexing/constants/index.ts b/packages/kilo-indexing/src/indexing/constants/index.ts index ef8a666673..bd36aceaac 100644 --- a/packages/kilo-indexing/src/indexing/constants/index.ts +++ b/packages/kilo-indexing/src/indexing/constants/index.ts @@ -50,7 +50,7 @@ export const INITIAL_MANAGER_RECOVERY_DELAY_MS = 500 /**Embedder Validation */ export const REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS = 15_000 -export const REMOTE_EMBEDDER_VALIDATION_MAX_RETRIES = 0 +export const REMOTE_EMBEDDER_VALIDATION_MAX_RETRIES = 2 export const OLLAMA_EMBEDDER_REQUEST_TIMEOUT_MS = 120_000 /**OpenAI Embedder */ From 18e798e81cd3a6584c6820c9ac710ceac24d0a97 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 13 Jul 2026 16:20:47 -0400 Subject: [PATCH 10/12] fix(jetbrains): align prompt button icons with IDE theme --- .changeset/jetbrains-platform-stop-icon.md | 5 +++++ .changeset/jetbrains-send-scroll-color.md | 5 +++++ .../kilocode/client/session/ui/prompt/PromptPanel.kt | 2 +- .../frontend/src/main/resources/icons/send.svg | 2 +- .../frontend/src/main/resources/icons/send_dark.svg | 2 +- .../ai/kilocode/client/session/ui/PromptPanelTest.kt | 11 +++++++++++ 6 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 .changeset/jetbrains-platform-stop-icon.md create mode 100644 .changeset/jetbrains-send-scroll-color.md diff --git a/.changeset/jetbrains-platform-stop-icon.md b/.changeset/jetbrains-platform-stop-icon.md new file mode 100644 index 0000000000..12d9af092a --- /dev/null +++ b/.changeset/jetbrains-platform-stop-icon.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Use the IntelliJ stop icon for the JetBrains prompt stop button. diff --git a/.changeset/jetbrains-send-scroll-color.md b/.changeset/jetbrains-send-scroll-color.md new file mode 100644 index 0000000000..619d8f7a51 --- /dev/null +++ b/.changeset/jetbrains-send-scroll-color.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Match the JetBrains prompt send icon color to the scroll-to-bottom button across themes. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt index 90312d7572..3c12781144 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt @@ -116,7 +116,7 @@ class PromptPanel( companion object { private val LOG = KiloLog.create(PromptPanel::class.java) private val SEND_ICON: Icon = IconLoader.getIcon("/icons/send.svg", PromptPanel::class.java) - private val STOP_ICON: Icon = IconLoader.getIcon("/icons/stop.svg", PromptPanel::class.java) + private val STOP_ICON: Icon = AllIcons.Actions.Suspend private val SHIELD_ICON: Icon = IconLoader.getIcon("/icons/shield.svg", PromptPanel::class.java) private val SHIELD_FILLED_ICON: Icon = IconLoader.getIcon("/icons/shield-filled.svg", PromptPanel::class.java) private val WAND_ICON: Icon = IconLoader.getIcon("/icons/wand-sparkles.svg", PromptPanel::class.java) diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/send.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/send.svg index 41f740ad42..a27a8776ad 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/send.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/send.svg @@ -1,3 +1,3 @@ - + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/send_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/send_dark.svg index 8e075e2d9b..1f98c1a2e9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/icons/send_dark.svg +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/send_dark.svg @@ -1,3 +1,3 @@ - + diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt index 0ee49b8edd..2f85481cc9 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt @@ -1001,9 +1001,15 @@ class PromptPanelTest : BasePlatformTestCase() { panel.setBusy(true) assertEquals("Stop", panel.buttonForTest().toolTipText) + assertSame(AllIcons.Actions.Suspend, panel.buttonForTest().icon) assertTrue(panel.isStopEnabled) } + fun `test send icon matches scroll button theme colors`() { + assertTrue(resource("/icons/send.svg").contains("fill=\"#0066B8\"")) + assertTrue(resource("/icons/send_dark.svg").contains("fill=\"#0A7BD8\"")) + } + fun `test busy disables send button`() { val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) panel.setReady(true) @@ -1379,6 +1385,11 @@ class PromptPanelTest : BasePlatformTestCase() { } } + private fun resource(path: String): String { + val stream = PromptPanel::class.java.getResourceAsStream(path) ?: error("missing resource $path") + return stream.use { it.readBytes().decodeToString() } + } + private class FileListTransferable(private val files: List) : Transferable { override fun getTransferDataFlavors(): Array = arrayOf(DataFlavor.javaFileListFlavor) From b62105a6490b268526eca51ff139934f36d0d6b0 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 13 Jul 2026 16:43:49 -0400 Subject: [PATCH 11/12] fix(jetbrains): refine prompt action spacing --- .../jetbrains-prompt-action-separator.md | 5 +++++ .changeset/jetbrains-prompt-right-padding.md | 5 +++++ .../client/session/ui/prompt/PromptPanel.kt | 13 ++++++++++++- .../client/session/ui/PromptPanelTest.kt | 18 ++++++++++++++++-- 4 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 .changeset/jetbrains-prompt-action-separator.md create mode 100644 .changeset/jetbrains-prompt-right-padding.md diff --git a/.changeset/jetbrains-prompt-action-separator.md b/.changeset/jetbrains-prompt-action-separator.md new file mode 100644 index 0000000000..826715ea32 --- /dev/null +++ b/.changeset/jetbrains-prompt-action-separator.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Add a separator before the JetBrains prompt send button. diff --git a/.changeset/jetbrains-prompt-right-padding.md b/.changeset/jetbrains-prompt-right-padding.md new file mode 100644 index 0000000000..1ffd4e59c4 --- /dev/null +++ b/.changeset/jetbrains-prompt-right-padding.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Match the JetBrains prompt send-button right padding to the bottom padding. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt index 3c12781144..9c1eb60d8e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt @@ -76,6 +76,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.awt.BasicStroke import java.awt.BorderLayout +import java.awt.Component import java.awt.Cursor import java.awt.Graphics import java.awt.Graphics2D @@ -142,7 +143,7 @@ class PromptPanel( JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), ) } private val attachments = mutableListOf() @@ -241,6 +242,14 @@ class PromptPanel( accessibleContext.accessibleName = KiloBundle.message("prompt.action.enhance") addActionListener { enhance() } } + private val separator = object : JComponent() { + override fun getPreferredSize() = JBUI.size(1, JBUI.scale(16)) + override fun getMinimumSize() = preferredSize + override fun getMaximumSize() = preferredSize + }.apply { + alignmentY = Component.CENTER_ALIGNMENT + border = JBUI.Borders.customLineLeft(SessionUiStyle.View.Prompt.separator()) + } @Volatile private var busy = false @@ -287,6 +296,8 @@ class PromptPanel( bar.add(Box.createHorizontalStrut(JBUI.scale(SessionUiStyle.View.Prompt.CONTROL_GAP))) bar.add(enhance) bar.add(Box.createHorizontalStrut(JBUI.scale(SessionUiStyle.View.Prompt.CONTROL_GAP))) + bar.add(separator) + bar.add(Box.createHorizontalStrut(JBUI.scale(SessionUiStyle.View.Prompt.CONTROL_GAP))) bar.add(button) shell.add(bar, BorderLayout.SOUTH) add(shell, BorderLayout.CENTER) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt index 2f85481cc9..1707362a30 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt @@ -97,6 +97,7 @@ import java.io.File import java.util.Base64 import javax.imageio.ImageIO import javax.swing.JButton +import javax.swing.JComponent import javax.swing.JPanel import javax.swing.ImageIcon import javax.swing.ScrollPaneConstants @@ -192,6 +193,16 @@ class PromptPanelTest : BasePlatformTestCase() { assertEquals(pad, ins.right) } + fun `test prompt shell right padding matches bottom padding`() { + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) + val shell = panel.shellForTest() + val ins = shell.border.getBorderInsets(shell) + + assertEquals(JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), ins.left) + assertEquals(JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), ins.bottom) + assertEquals(ins.bottom, ins.right) + } + fun `test prompt focus outline follows editor focus`() { val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) realize(panel, 260, 400) @@ -1054,18 +1065,21 @@ class PromptPanelTest : BasePlatformTestCase() { assertSame(icon, button.icon) } - fun `test auto approve and enhance buttons sit next to send button`() { + fun `test auto approve enhance separator and send buttons sit in order`() { val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) val auto = autoApproveButton(panel) val enhance = enhanceButton(panel) val send = panel.buttonForTest() val items = auto.parent.components.toList() + val sep = items[items.indexOf(enhance) + 2] as JComponent assertTrue(SwingUtilities.isDescendingFrom(auto, panel.shellForTest())) assertSame(auto.parent, enhance.parent) assertSame(auto.parent, send.parent) assertEquals(2, items.indexOf(enhance) - items.indexOf(auto)) - assertEquals(2, items.indexOf(send) - items.indexOf(enhance)) + assertEquals(4, items.indexOf(send) - items.indexOf(enhance)) + assertEquals(JBUI.scale(1), sep.preferredSize.width) + assertNotNull(sep.border) } fun `test enhance button follows connection and busy state`() { From 142225f0ffe1faf7a33b9dfd6ca1286e0080a58c Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 13 Jul 2026 17:10:35 -0400 Subject: [PATCH 12/12] test(jetbrains): avoid environment-specific font assertion --- .../kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt index 57ef7ea8d4..1f19245a78 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt @@ -328,7 +328,6 @@ class QuestionViewTest : BasePlatformTestCase() { assertEquals(style.transcriptFont, field.font) assertEquals(style.transcriptFont.fontName, editor.colorsScheme.editorFontName) assertEquals(style.transcriptFont.size, editor.colorsScheme.editorFontSize) - assertFalse(style.editorFont.fontName == editor.colorsScheme.editorFontName) } finally { view.hideView() view.removeNotify()