From 076d8ab1366a1c33f5c5eb944d1e5c9faf288d0c Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 1 Jun 2026 15:26:02 -0400 Subject: [PATCH 01/14] feat(jetbrains): add empty session feedback UI --- .changeset/stellar-forest-feedback.md | 5 + .kilo/plans/1780256109130-stellar-forest.md | 109 ++++++++++ .../ai/kilocode/client/session/SessionUi.kt | 2 +- .../session/ui/empty/EmptySessionFeedback.kt | 117 +++++++++++ .../ui/{ => empty}/EmptySessionPanel.kt | 192 ++++------------- .../client/session/ui/empty/RecentsList.kt | 198 ++++++++++++++++++ .../kotlin/ai/kilocode/client/ui/UiStyle.kt | 14 ++ .../src/main/resources/icons/discord.svg | 3 + .../src/main/resources/icons/discord_dark.svg | 3 + .../resources/messages/KiloBundle.properties | 5 + .../messages/KiloBundle_ar.properties | 5 + .../messages/KiloBundle_bs.properties | 5 + .../messages/KiloBundle_da.properties | 5 + .../messages/KiloBundle_de.properties | 5 + .../messages/KiloBundle_es.properties | 5 + .../messages/KiloBundle_fr.properties | 5 + .../messages/KiloBundle_ja.properties | 5 + .../messages/KiloBundle_ko.properties | 5 + .../messages/KiloBundle_nl.properties | 5 + .../messages/KiloBundle_no.properties | 5 + .../messages/KiloBundle_pl.properties | 5 + .../messages/KiloBundle_pt_BR.properties | 5 + .../messages/KiloBundle_ru.properties | 5 + .../messages/KiloBundle_th.properties | 5 + .../messages/KiloBundle_tr.properties | 5 + .../messages/KiloBundle_uk.properties | 5 + .../messages/KiloBundle_zh_CN.properties | 5 + .../messages/KiloBundle_zh_TW.properties | 5 + .../client/session/SessionUiFactoryTest.kt | 4 +- .../client/session/SessionUiLayoutTest.kt | 2 +- .../session/ui/EmptySessionPanelTest.kt | 48 ++++- 31 files changed, 631 insertions(+), 161 deletions(-) create mode 100644 .changeset/stellar-forest-feedback.md create mode 100644 .kilo/plans/1780256109130-stellar-forest.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionFeedback.kt rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/{ => empty}/EmptySessionPanel.kt (54%) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/RecentsList.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/discord.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/discord_dark.svg diff --git a/.changeset/stellar-forest-feedback.md b/.changeset/stellar-forest-feedback.md new file mode 100644 index 00000000000..98dd0d17c1d --- /dev/null +++ b/.changeset/stellar-forest-feedback.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Add Feedback & Support to the JetBrains empty session screen. diff --git a/.kilo/plans/1780256109130-stellar-forest.md b/.kilo/plans/1780256109130-stellar-forest.md new file mode 100644 index 00000000000..3aafa6c0dd9 --- /dev/null +++ b/.kilo/plans/1780256109130-stellar-forest.md @@ -0,0 +1,109 @@ +# Plan: JetBrains Feedback & Support Button + +## Goal +Add the VS Code empty-state `Feedback & Support` affordance to the JetBrains plugin, including a non-modal popup with the same three destinations: + +- GitHub issues: `https://github.com/Kilo-Org/kilocode/issues/new/choose` +- Discord: `https://kilo.ai/discord` +- Customer support: `https://kilo.ai/support` + +The popup must hide when clicking outside or pressing Escape. + +## Findings + +- The JetBrains empty state is `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt`. +- It already renders the logo, welcome copy, recents, and `Show History`; this is the right place to add the button. +- The VS Code implementation is: + - Button: `packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx` + - Popup content: `packages/kilo-vscode/webview-ui/src/components/chat/FeedbackDialog.tsx` + - Styling: `packages/kilo-vscode/webview-ui/src/styles/welcome.css` +- Local IntelliJ API source is available at `$INTELLIJ_REPO=/Users/kirillk/products/intellij-community`. +- IntelliJ popup API confirmed in local source: + - `JBPopupFactory.getInstance().createComponentPopupBuilder(content, focusComponent)` + - `setModalContext(false)` for non-modal context + - `setRequestFocus(false)` / `setFocusable(false)` if we want the popup not to steal focus + - `setCancelOnClickOutside(true)` for outside-click dismissal + - `setCancelKeyEnabled(true)` for Escape dismissal + - `setCancelOnWindowDeactivation(true)` and `setCancelOnOtherWindowOpen(true)` are available and appropriate for cleanup +- IntelliJ platform icons found in `AllIcons`: + - Feedback button: `AllIcons.Ide.Feedback` + - GitHub action: `AllIcons.Vcs.Vendors.Github` + - Support/help action: `AllIcons.Actions.Help` or `AllIcons.General.ContextHelp` + - Discord: no platform icon found. Add a Discord icon by borrowing the existing VS Code/kilo-ui Discord SVG artwork and adapting it into the JetBrains plugin resources with IntelliJ-compatible SVG colors and dark variant if needed. + +## Implementation Steps + +1. Add localized JetBrains bundle keys. + - Add base English strings to `frontend/src/main/resources/messages/KiloBundle.properties`: + - `feedback.button=Feedback & Support` + - `feedback.dialog.message=We'd love to hear your feedback or help with any issues you're experiencing.` + - `feedback.dialog.github=Report an issue on GitHub` + - `feedback.dialog.discord=Join our Discord community` + - `feedback.dialog.support=Customer Support` + - Reuse existing cancel text if present; otherwise add `common.cancel=Cancel` only if needed. + - Add matching keys to localized `KiloBundle_*.properties` files. If accurate translations are not already available from the VS Code i18n files, copy the VS Code translations for matching locales where practical and leave English fallback only if the JetBrains bundle mechanism supports it cleanly. + +2. Extend `EmptySessionPanel` UI. + - Add a new retained `FeedbackButton`, styled to match VS Code conceptually: + - feedback icon + `Feedback & Support` text + - dashed link-colored border + - transparent background + - hand cursor + - hover state fills with link color and flips foreground/background for contrast + - Use IntelliJ theme APIs instead of raw colors: + - link color: `JBUI.CurrentTheme.Link.Foreground.ENABLED` + - hover link color: `JBUI.CurrentTheme.Link.Foreground.HOVERED` if useful + - editor/panel background from existing session style or `UiStyle.Colors.editorBackground()` / component background + - spacing via `UiStyle.Gap` / `JBUI.Borders` + - rounded arc via `UiStyle.Arc.component()` or `JBUI.getInt("Button.arc", 6)` at paint time + - Add the feedback button below the existing `Show History` button in the empty-state south area, keeping the current centered layout. + - Keep Swing mutations on EDT and preserve retained-component behavior. + +3. Implement the feedback popup in frontend Swing code. + - Add a small popup builder method/class inside `EmptySessionPanel.kt` or a new nearby UI file if the file would become too large. + - Content should mirror VS Code: + - Kilo logo at top using existing `/icons/kilo-content.svg` + - message text + - three action buttons/rows for GitHub, Discord, and Customer Support + - every action should have an icon: `AllIcons.Vcs.Vendors.Github` for GitHub, the borrowed Discord SVG for Discord, and `AllIcons.Actions.Help` or `AllIcons.General.ContextHelp` for Customer Support + - optional Cancel button if it fits the JetBrains UX; Escape/outside click already dismisses + - Add the Discord icon asset under `frontend/src/main/resources/icons/` using the plugin's existing icon naming pattern, for example `discord.svg` and `discord_dark.svg` if the borrowed asset needs separate theme variants. + - Load the Discord icon with `IconLoader.getIcon("/icons/discord.svg", EmptySessionPanel::class.java)` or a small Kilo-owned icon object if multiple call sites need it. + - Use `BrowserUtil.browse(url)` to open links. + - Close the popup after opening a URL. + - Use `JBPopupFactory.createComponentPopupBuilder(content, null)` and configure: + - `.setModalContext(false)` + - `.setRequestFocus(false)` + - `.setFocusable(false)` unless keyboard tabbing inside popup is desired; if buttons need keyboard focus, use `.setFocusable(true)` with `.setRequestFocus(false)` + - `.setCancelOnClickOutside(true)` + - `.setCancelKeyEnabled(true)` + - `.setCancelOnWindowDeactivation(true)` + - `.setCancelOnOtherWindowOpen(true)` + - `.setResizable(false)` + - `.setMovable(false)` + - Show with `popup.showUnderneathOf(feedbackButton)`. + +4. Add test coverage in `EmptySessionPanelTest.kt`. + - Assert feedback button text uses `KiloBundle.message("feedback.button")`. + - Assert feedback button has hand cursor and visible border semantics. + - Add an injectable browse callback or popup action callback only if needed for testing without launching browsers; keep it minimal and avoid production-only test hooks if component traversal can exercise enough behavior. + - Add a test that clicking the feedback button creates/shows popup content, if feasible in `BasePlatformTestCase`; otherwise test the retained popup content builder/action rows directly through package-internal methods. + - Assert the three action labels exist and URL callbacks map to the same URLs as VS Code. + - Assert the Discord action has an icon, so the plan cannot regress to a text-only Discord row. + +5. Add release note. + - This is user-facing for the JetBrains plugin, so add a patch changeset under `.changeset/` unless the repo has a JetBrains-specific release-note mechanism that supersedes changesets. + - Suggested text: `Add Feedback & Support to the JetBrains empty session screen.` + +6. Verify. + - Run the focused frontend/JetBrains tests first, preferably from `packages/kilo-jetbrains/`: + - `./gradlew :frontend:test --tests "ai.kilocode.client.session.ui.EmptySessionPanelTest"` + - Run JetBrains typecheck from `packages/kilo-jetbrains/`: + - `bun run typecheck` or `./gradlew typecheck` + - If touching only frontend UI and tests pass, no backend/SDK generation is needed. + +## Notes + +- Do not add Kotlin UI DSL, Compose, or JCEF. +- Do not modify shared opencode files; this work is entirely under `packages/kilo-jetbrains/` plus a changeset. +- Prefer platform icons where available, but explicitly borrow/add the Discord icon because `AllIcons` does not provide one and the popup should match the VS Code button set visually. 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 133af6862f3..8a4439e31c8 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 @@ -12,7 +12,7 @@ import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.scroll.SessionScroll import ai.kilocode.client.session.ui.ConnectionPanel -import ai.kilocode.client.session.ui.EmptySessionPanel +import ai.kilocode.client.session.ui.empty.EmptySessionPanel import ai.kilocode.client.session.ui.LoadingPanel import ai.kilocode.client.session.ui.ReasoningPicker import ai.kilocode.client.session.ui.mode.ModePicker diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionFeedback.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionFeedback.kt new file mode 100644 index 00000000000..48ca0fcc3e4 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionFeedback.kt @@ -0,0 +1,117 @@ +package ai.kilocode.client.session.ui.empty + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.HAlign +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.layout.VAlign +import ai.kilocode.client.ui.layout.align +import com.intellij.icons.AllIcons +import com.intellij.openapi.ui.popup.Balloon +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.util.IconLoader +import com.intellij.ui.awt.RelativePoint +import com.intellij.ui.components.JBLabel +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.UIUtil +import com.intellij.xml.util.XmlStringUtil +import java.awt.Cursor +import java.awt.Point +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.JButton +import javax.swing.JComponent + +internal class EmptySessionFeedback( + private val browse: (String) -> Unit, +) { + val button: JButton = FeedbackButton().apply { + addActionListener { popup() } + } + + @RequiresEdt + private fun popup() { + lateinit var balloon: Balloon + val content = content { url -> + browse(url) + balloon.hide() + } + val point = RelativePoint(button, Point(button.width / 2, button.height)) + balloon = JBPopupFactory.getInstance() + .createBalloonBuilder(content) + .setHideOnClickOutside(true) + .setHideOnKeyOutside(true) + .setHideOnAction(true) + .setHideOnFrameResize(true) + .setBorderColor(UiStyle.Balloon.border()) + .setFillColor(UiStyle.Balloon.bg()) + .setBorderInsets(UiStyle.Balloon.insets()) + .setPointerSize(UiStyle.Balloon.pointer()) + .setCornerRadius(UiStyle.Balloon.arc()) + .createBalloon() + + balloon.show(point, Balloon.Position.below) + } + + private class FeedbackButton : EmptySessionPanel.ShowHistoryButton(buttonHtml(), AllIcons.Ide.Feedback) + + private class ActionButton(text: String, icon: javax.swing.Icon, action: () -> Unit) : JButton(text, icon) { + init { + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + addActionListener { action() } + } + } + + companion object { + @RequiresEdt + fun content(open: (String) -> Unit): JComponent { + val logo = JBLabel(IconLoader.getIcon("/icons/kilo-content.svg", EmptySessionPanel::class.java)).apply { + horizontalAlignment = JBLabel.CENTER + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + open(KILO_URL) + } + }) + } + val msg = JBLabel(messageHtml()).apply { + foreground = UIUtil.getLabelForeground() + horizontalAlignment = JBLabel.CENTER + } + val actions = Stack.vertical(gap = UiStyle.Gap.sm()) + .next(ActionButton(KiloBundle.message("feedback.dialog.github"), AllIcons.Vcs.Vendors.Github) { + open(GITHUB_ISSUES_URL) + }.align(HAlign.CENTER, VAlign.CENTER)) + .next(ActionButton(KiloBundle.message("feedback.dialog.discord"), DISCORD_ICON) { + open(DISCORD_URL) + }.align(HAlign.CENTER, VAlign.CENTER)) + .next(ActionButton(KiloBundle.message("feedback.dialog.support"), AllIcons.Actions.Help) { + open(SUPPORT_URL) + }.align(HAlign.CENTER, VAlign.CENTER)) + + return Stack.vertical(gap = UiStyle.Gap.lg()) + .fill(UiStyle.Gap.sm()) + .next(logo.align(HAlign.CENTER, VAlign.CENTER)) + .next(msg.align(HAlign.CENTER, VAlign.CENTER)) + .fill(UiStyle.Gap.xs()) + .next(actions.align(HAlign.CENTER, VAlign.CENTER)) + .fill(UiStyle.Gap.xs()) + } + + fun urls() = listOf(GITHUB_ISSUES_URL, DISCORD_URL, SUPPORT_URL) + + private fun messageHtml() = XmlStringUtil.wrapInHtml( + "
${XmlStringUtil.escapeString(KiloBundle.message("feedback.dialog.message"))}
" + ) + + private fun buttonHtml() = XmlStringUtil.wrapInHtml( + XmlStringUtil.escapeString(KiloBundle.message("feedback.button")) + ) + + private const val KILO_URL = "https://kilocode.ai" + private const val GITHUB_ISSUES_URL = "https://github.com/Kilo-Org/kilocode/issues/new/choose" + private const val DISCORD_URL = "https://kilo.ai/discord" + private const val SUPPORT_URL = "https://kilo.ai/support" + private val DISCORD_ICON = IconLoader.getIcon("/icons/discord.svg", EmptySessionPanel::class.java) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt similarity index 54% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt index 7f8283a7067..bd91413f63a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt @@ -1,31 +1,25 @@ -package ai.kilocode.client.session.ui +package ai.kilocode.client.session.ui.empty import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.SessionActivityKind -import ai.kilocode.client.session.SessionRef -import ai.kilocode.client.session.history.HistoryActivitySnapshot -import ai.kilocode.client.session.history.HistoryTime -import ai.kilocode.client.session.history.LocalHistoryItem -import ai.kilocode.client.session.history.itemAt -import ai.kilocode.client.session.history.title +import ai.kilocode.client.session.controller.SessionController 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.session.controller.SessionController -import ai.kilocode.client.ui.FilledBadgeIcon import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Align import ai.kilocode.client.ui.layout.HAlign +import ai.kilocode.client.ui.layout.Stack import ai.kilocode.client.ui.layout.VAlign import ai.kilocode.client.ui.layout.align import ai.kilocode.rpc.dto.SessionDto import com.intellij.icons.AllIcons +import com.intellij.ide.BrowserUtil import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.IconLoader import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.ui.components.JBLabel -import com.intellij.ui.components.JBList import com.intellij.util.ui.Centerizer import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil @@ -35,19 +29,14 @@ import java.awt.BorderLayout import java.awt.Component import java.awt.Cursor import java.awt.Dimension -import java.awt.FlowLayout import java.awt.Graphics import java.awt.Graphics2D import java.awt.RenderingHints import java.awt.event.HierarchyEvent import java.awt.event.MouseAdapter import java.awt.event.MouseEvent -import java.awt.event.MouseMotionAdapter -import javax.swing.DefaultListModel import javax.swing.JButton -import javax.swing.JList -import javax.swing.ListCellRenderer -import javax.swing.ListSelectionModel +import javax.swing.JComponent import javax.swing.Timer /** @@ -63,51 +52,20 @@ class EmptySessionPanel( private val history: () -> Unit = {}, private val activity: () -> Map = { emptyMap() }, private val titles: () -> Map = { emptyMap() }, + private val browse: (String) -> Unit = BrowserUtil::browse, ) : BorderLayoutPanel(), Disposable, SessionEditorStyleTarget { val view: Align = align(HAlign.CENTER, VAlign.CENTER) - private val model = DefaultListModel() - private var hover = -1 private var style = SessionEditorStyle.current() - private var snapshot = HistoryActivitySnapshot() private val timer = Timer(ACTIVITY_MS) { syncActivity() } - - private val recentTitle = JBLabel(KiloBundle.message("session.empty.recent")).apply { - foreground = UIUtil.getContextHelpForeground() - } - - private val list = JBList(model).apply { - isOpaque = false - selectionMode = ListSelectionModel.SINGLE_SELECTION - visibleRowCount = SessionUiStyle.RecentSessions.LIMIT - cellRenderer = SessionRenderer() - cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) - emptyText.clear() - addMouseListener(object : MouseAdapter() { - override fun mouseClicked(e: MouseEvent) { - val item = itemAt(this@apply, e) ?: return - controller.openSession(SessionRef.Local(item.session)) - } - - override fun mouseExited(e: MouseEvent) { - hover = -1 - repaint() - } - }) - addMouseMotionListener(object : MouseMotionAdapter() { - override fun mouseMoved(e: MouseEvent) { - val index = index(e) - if (hover == index) return - hover = index - repaint() - } - }) - } + private val recent = RecentsList(recents, controller) private val historyButton = ShowHistoryButton().apply { addActionListener { history() } } + private val feedback = EmptySessionFeedback(browse) + private val welcomeLabel = JBLabel(welcomeHtml()).apply { foreground = UIUtil.getContextHelpForeground() horizontalAlignment = JBLabel.CENTER @@ -134,7 +92,6 @@ class EmptySessionPanel( Disposer.register(parent, this) isOpaque = false applyStyle(SessionEditorStyle.current()) - setSessions(recents) addHierarchyListener { e -> if (e.changeFlags and HierarchyEvent.SHOWING_CHANGED.toLong() == 0L) return@addHierarchyListener if (isShowing) { @@ -159,40 +116,28 @@ class EmptySessionPanel( add(description.align(HAlign.CENTER, VAlign.CENTER), BorderLayout.CENTER) } - val recent = BorderLayoutPanel().apply { - isOpaque = false - add(recentTitle, BorderLayout.NORTH) - add(list, BorderLayout.CENTER) - } - val south = BorderLayoutPanel().apply { isOpaque = false - add(Centerizer(historyButton, Centerizer.TYPE.HORIZONTAL), BorderLayout.CENTER) + add(Stack.vertical(gap = UiStyle.Gap.lg()) + .next(Centerizer(historyButton, Centerizer.TYPE.HORIZONTAL)) + .next(Centerizer(feedback.button, Centerizer.TYPE.HORIZONTAL)), BorderLayout.CENTER) } add(header, BorderLayout.NORTH) - add(recent, BorderLayout.CENTER) + if (recent.hasSessions()) add(recent, BorderLayout.CENTER) add(south, BorderLayout.SOUTH) } - private fun setSessions(sessions: List) { - model.clear() - sessions.take(SessionUiStyle.RecentSessions.LIMIT).map(::LocalHistoryItem).forEach(model::addElement) - revalidate() - repaint() - } - - internal fun recentCount() = model.size() + internal fun recentCount() = recent.count() internal fun selectRecent(index: Int) { - list.selectedIndex = index + recent.select(index) } - internal fun selectedRecent() = list.selectedIndex + internal fun selectedRecent() = recent.selected() internal fun clickRecent(index: Int) { - list.selectedIndex = index - controller.openSession(SessionRef.Local(model.getElementAt(index).session)) + recent.click(index) } internal fun clickShowHistory() { @@ -201,13 +146,25 @@ class EmptySessionPanel( internal fun showHistoryText() = historyButton.text + internal fun feedbackText() = KiloBundle.message("feedback.button") + + internal fun feedbackCursor() = feedback.button.cursor.type + + internal fun feedbackIcon() = feedback.button.icon + + internal fun feedbackBorderPainted() = feedback.button.isBorderPainted + + internal fun feedbackContent(open: (String) -> Unit = {}): JComponent = EmptySessionFeedback.content(open) + + internal fun feedbackUrls() = EmptySessionFeedback.urls() + internal fun showHistoryBorderPainted() = historyButton.isBorderPainted internal fun showHistoryCursor() = historyButton.cursor.type - internal fun recentCursor() = list.cursor.type + internal fun recentCursor() = recent.cursorType() - internal fun recentVisible() = true + internal fun recentVisible() = recent.hasSessions() internal fun explanationText() = KiloBundle.message("session.empty.welcome") @@ -226,92 +183,25 @@ class EmptySessionPanel( internal fun activeView() = getComponent(0) internal fun text(session: SessionDto, now: Long = System.currentTimeMillis()) = - HistoryTime.relative(LocalHistoryItem(session), now) + recent.text(session, now) internal fun rendererComponent( session: SessionDto, selected: Boolean = false, hover: Boolean = false, ): Component { - val old = this.hover - this.hover = if (hover) 0 else -1 - return list.cellRenderer.getListCellRendererComponent(list, LocalHistoryItem(session), 0, selected, false).also { - this.hover = old - } + return recent.renderer(session, selected, hover) } @RequiresEdt internal fun syncActivity() { - val next = HistoryActivitySnapshot(activity(), titles()) - val changed = snapshot.changed(next) - snapshot = next - repaintRows(changed) + recent.sync(activity(), titles()) } - private fun repaintRows(ids: Set) { - if (ids.isEmpty()) return - repeat(model.size()) { index -> - if (model.getElementAt(index).id !in ids) return@repeat - list.getCellBounds(index, index)?.let(list::repaint) - } - } - - private fun index(e: MouseEvent): Int { - val idx = list.locationToIndex(e.point) - if (idx < 0) return -1 - val box = list.getCellBounds(idx, idx) ?: return -1 - if (!box.contains(e.point)) return -1 - return idx - } - - private inner class SessionRenderer : BorderLayoutPanel(), ListCellRenderer { - private val title = JBLabel() - private val badge = JBLabel().apply { - border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap()) - } - private val time = JBLabel() - private val head = BorderLayoutPanel().apply { - add(BorderLayoutPanel().apply { - layout = FlowLayout(FlowLayout.LEFT, 0, 0) - isOpaque = false - add(title) - add(badge) - }, BorderLayout.CENTER) - } - - init { - layout = BorderLayout(UiStyle.Gap.pad(), 0) - border = JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg()) - head.isOpaque = false - add(head, BorderLayout.CENTER) - add(time, BorderLayout.EAST) - } - - override fun getListCellRendererComponent( - list: JList, - value: LocalHistoryItem?, - index: Int, - selected: Boolean, - focus: Boolean, - ): Component { - val over = selected || hover == index - isOpaque = over - background = if (over) list.selectionBackground else list.background - title.foreground = if (over) list.selectionForeground else UIUtil.getLabelForeground() - time.foreground = if (over) list.selectionForeground else UIUtil.getContextHelpForeground() - title.text = value?.let { snapshot.titles[it.id] ?: title(it) } ?: "" - time.text = value?.let(HistoryTime::relative) ?: "" - setBadge(value?.id?.let(snapshot.activity::get)) - return this - } - - private fun setBadge(kind: SessionActivityKind?) { - badge.isVisible = kind != null - badge.icon = kind?.let { FilledBadgeIcon(it.label(), it.bg(), it.fg()) } - } - } - - private inner class ShowHistoryButton : JButton(KiloBundle.message("session.showHistory"), AllIcons.Vcs.History) { + internal open class ShowHistoryButton( + text: String = KiloBundle.message("session.showHistory"), + icon: javax.swing.Icon = AllIcons.Vcs.History, + ) : JButton(text, icon) { private var over = false init { @@ -361,7 +251,7 @@ class EmptySessionPanel( override fun applyStyle(style: SessionEditorStyle) { this.style = style welcomeLabel.font = style.regularFont - recentTitle.font = style.smallFont + recent.titleFont(style.smallFont) revalidate() repaint() } @@ -374,7 +264,3 @@ class EmptySessionPanel( const val ACTIVITY_MS = 3_000 } } - -private fun Map.changed(next: Map) = (keys + next.keys).filterTo(mutableSetOf()) { - this[it] != next[it] -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/RecentsList.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/RecentsList.kt new file mode 100644 index 00000000000..c3205b69cf8 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/RecentsList.kt @@ -0,0 +1,198 @@ +package ai.kilocode.client.session.ui.empty + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.SessionActivityKind +import ai.kilocode.client.session.SessionRef +import ai.kilocode.client.session.controller.SessionController +import ai.kilocode.client.session.history.HistoryActivitySnapshot +import ai.kilocode.client.session.history.HistoryTime +import ai.kilocode.client.session.history.LocalHistoryItem +import ai.kilocode.client.session.history.itemAt +import ai.kilocode.client.session.history.title +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.ui.FilledBadgeIcon +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.rpc.dto.SessionDto +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBList +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import com.intellij.util.ui.components.BorderLayoutPanel +import java.awt.BorderLayout +import java.awt.Component +import java.awt.Cursor +import java.awt.FlowLayout +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.awt.event.MouseMotionAdapter +import javax.swing.DefaultListModel +import javax.swing.JList +import javax.swing.ListCellRenderer +import javax.swing.ListSelectionModel + +internal class RecentsList( + sessions: List, + private val controller: SessionController, +) : BorderLayoutPanel() { + private val model = DefaultListModel() + private var hover = -1 + private var snapshot = HistoryActivitySnapshot() + + private val title = JBLabel(KiloBundle.message("session.empty.recent")).apply { + foreground = UIUtil.getContextHelpForeground() + } + + private val list = JBList(model).apply { + isOpaque = false + selectionMode = ListSelectionModel.SINGLE_SELECTION + visibleRowCount = SessionUiStyle.RecentSessions.LIMIT + cellRenderer = Renderer() + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + emptyText.clear() + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + val item = itemAt(this@apply, e) ?: return + controller.openSession(SessionRef.Local(item.session)) + } + + override fun mouseExited(e: MouseEvent) { + hover = -1 + repaint() + } + }) + addMouseMotionListener(object : MouseMotionAdapter() { + override fun mouseMoved(e: MouseEvent) { + val index = index(e) + if (hover == index) return + hover = index + repaint() + } + }) + } + + init { + isOpaque = false + add(title, BorderLayout.NORTH) + add(list, BorderLayout.CENTER) + setSessions(sessions) + } + + fun count() = model.size() + + fun hasSessions() = model.size() > 0 + + fun select(index: Int) { + list.selectedIndex = index + } + + fun selected() = list.selectedIndex + + fun click(index: Int) { + list.selectedIndex = index + controller.openSession(SessionRef.Local(model.getElementAt(index).session)) + } + + fun cursorType() = list.cursor.type + + fun titleFont(font: java.awt.Font) { + title.font = font + } + + fun text(session: SessionDto, now: Long = System.currentTimeMillis()) = + HistoryTime.relative(LocalHistoryItem(session), now) + + fun renderer( + session: SessionDto, + selected: Boolean = false, + hover: Boolean = false, + ): Component { + val old = this.hover + this.hover = if (hover) 0 else -1 + return list.cellRenderer.getListCellRendererComponent(list, LocalHistoryItem(session), 0, selected, false).also { + this.hover = old + } + } + + @RequiresEdt + fun sync(activity: Map, titles: Map) { + val next = HistoryActivitySnapshot(activity, titles) + val changed = snapshot.changed(next) + snapshot = next + repaintRows(changed) + } + + private fun setSessions(sessions: List) { + model.clear() + sessions.take(SessionUiStyle.RecentSessions.LIMIT).map(::LocalHistoryItem).forEach(model::addElement) + revalidate() + repaint() + } + + private fun repaintRows(ids: Set) { + if (ids.isEmpty()) return + repeat(model.size()) { index -> + if (model.getElementAt(index).id !in ids) return@repeat + list.getCellBounds(index, index)?.let(list::repaint) + } + } + + private fun index(e: MouseEvent): Int { + val idx = list.locationToIndex(e.point) + if (idx < 0) return -1 + val box = list.getCellBounds(idx, idx) ?: return -1 + if (!box.contains(e.point)) return -1 + return idx + } + + private inner class Renderer : BorderLayoutPanel(), ListCellRenderer { + private val title = JBLabel() + private val badge = JBLabel().apply { + border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap()) + } + private val time = JBLabel() + private val head = BorderLayoutPanel().apply { + add(BorderLayoutPanel().apply { + layout = FlowLayout(FlowLayout.LEFT, 0, 0) + isOpaque = false + add(title) + add(badge) + }, BorderLayout.CENTER) + } + + init { + layout = BorderLayout(UiStyle.Gap.pad(), 0) + border = JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg()) + head.isOpaque = false + add(head, BorderLayout.CENTER) + add(time, BorderLayout.EAST) + } + + override fun getListCellRendererComponent( + list: JList, + value: LocalHistoryItem?, + index: Int, + selected: Boolean, + focus: Boolean, + ): Component { + val over = selected || hover == index + isOpaque = over + background = if (over) list.selectionBackground else list.background + title.foreground = if (over) list.selectionForeground else UIUtil.getLabelForeground() + time.foreground = if (over) list.selectionForeground else UIUtil.getContextHelpForeground() + title.text = value?.let { snapshot.titles[it.id] ?: title(it) } ?: "" + time.text = value?.let(HistoryTime::relative) ?: "" + setBadge(value?.id?.let(snapshot.activity::get)) + return this + } + + private fun setBadge(kind: SessionActivityKind?) { + badge.isVisible = kind != null + badge.icon = kind?.let { FilledBadgeIcon(it.label(), it.bg(), it.fg()) } + } + } +} + +private fun Map.changed(next: Map) = (keys + next.keys).filterTo(mutableSetOf()) { + this[it] != next[it] +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt index 3f9b680cf40..be96983dfbd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt @@ -33,6 +33,20 @@ object UiStyle { fun component() = com.intellij.util.ui.JBValue.UIInteger("Component.arc", 8).get() } + /** Platform balloon styling used by lightweight contextual overlays. */ + object Balloon { + fun bg(): Color = UIUtil.getPanelBackground() + + fun border(): Color = JBUI.CurrentTheme.Popup.borderColor(true) + + /** New UI parameter-info balloon insets: symmetric vertical padding with wider sides. */ + fun insets() = JBUI.insets(6, 12, 6, 12) + + fun pointer() = JBUI.size(16, 8) + + fun arc() = JBUI.scale(8) + } + /** Theme-aware colors and color math used by multiple UI surfaces. */ object Colors { fun bg(): Color = UIUtil.getPanelBackground() diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/discord.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/discord.svg new file mode 100644 index 00000000000..6b1bcab5732 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/discord.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/discord_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/discord_dark.svg new file mode 100644 index 00000000000..db15066d329 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/discord_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 96303b3c08a..eeca886cc60 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -11,6 +11,11 @@ session.account.switcher=Switch account session.empty.loading=Loading... session.empty.recent=RECENT session.showHistory=Show History +feedback.button=Feedback & Support +feedback.dialog.message=We'd love to hear your feedback or help with any issues you're experiencing. +feedback.dialog.github=Report an issue on GitHub +feedback.dialog.discord=Join our Discord community +feedback.dialog.support=Customer Support session.scroll.bottom=Scroll to bottom session.scroll.question=Scroll to question session.tab.new=New Session diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties index 9cb9ee73187..26ff25a94a3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties @@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code هو مساعد برمجة بالذكاء الا session.empty.loading=جاري التحميل… session.empty.recent=الحديثة session.showHistory=عرض السجل +feedback.button=التغذية الراجعة والدعم +feedback.dialog.message=يسعدنا سماع تعليقاتك أو مساعدتك في حل أي مشكلات تواجهها. +feedback.dialog.github=الإبلاغ عن مشكلة على GitHub +feedback.dialog.discord=الانضمام إلى مجتمع Discord +feedback.dialog.support=دعم العملاء session.scroll.bottom=التمرير إلى الأسفل session.tab.new=جلسة جديدة session.tab.untitled=جلسة بدون عنوان diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties index a310da70e8e..df6bad0d4c5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties @@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code je AI asistent za kodiranje. Zatražite od njega session.empty.loading=Učitavanje… session.empty.recent=NEDAVNO session.showHistory=Prikaži historiju +feedback.button=Povratne informacije i podrška +feedback.dialog.message=Voljeli bismo čuti vaše povratne informacije ili pomoći s problemima koje doživljavate. +feedback.dialog.github=Prijavite problem na GitHubu +feedback.dialog.discord=Pridružite se našoj Discord zajednici +feedback.dialog.support=Korisnička podrška session.scroll.bottom=Skrolaj na dno session.tab.new=Nova sesija session.tab.untitled=Sesija bez naslova diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties index 706e2faf0b6..2ad11f62a95 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties @@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code er en AI-kodningsassistent. Bed den om at bygge session.empty.loading=Indlæser… session.empty.recent=SENESTE session.showHistory=Vis historik +feedback.button=Feedback & support +feedback.dialog.message=Vi vil gerne høre din feedback eller hjælpe med eventuelle problemer, du oplever. +feedback.dialog.github=Rapportér et problem på GitHub +feedback.dialog.discord=Deltag i vores Discord-fællesskab +feedback.dialog.support=Kundesupport session.scroll.bottom=Rul til bunden session.tab.new=Ny session session.tab.untitled=Unavngivet session diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties index f604c18c850..47248c351fe 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties @@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code ist ein KI-Coding-Assistent. Bitten Sie ihn, Fun session.empty.loading=Wird geladen… session.empty.recent=ZULETZT session.showHistory=Verlauf anzeigen +feedback.button=Feedback & Support +feedback.dialog.message=Wir würden uns freuen, Ihr Feedback zu hören oder Ihnen bei Problemen zu helfen. +feedback.dialog.github=Ein Problem auf GitHub melden +feedback.dialog.discord=Unserer Discord-Community beitreten +feedback.dialog.support=Kundensupport session.scroll.bottom=Zum Ende scrollen session.tab.new=Neue Sitzung session.tab.untitled=Unbenannte Sitzung diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties index 92fbad3508f..ce418883385 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties @@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code es un asistente de codificación con IA. Pida qu session.empty.loading=Cargando… session.empty.recent=RECIENTE session.showHistory=Mostrar historial +feedback.button=Comentarios y soporte +feedback.dialog.message=Nos encantaría escuchar tus comentarios o ayudarte con cualquier problema que estés experimentando. +feedback.dialog.github=Reportar un problema en GitHub +feedback.dialog.discord=Unirse a nuestra comunidad de Discord +feedback.dialog.support=Atención al cliente session.scroll.bottom=Desplazarse al final session.tab.new=Nueva sesión session.tab.untitled=Sesión sin título diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties index bf51dd4e426..27bc8f2a241 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties @@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code est un assistant de codage IA. Demandez-lui de c session.empty.loading=Chargement… session.empty.recent=RÉCENT session.showHistory=Afficher l'historique +feedback.button=Commentaires & support +feedback.dialog.message=Nous aimerions recueillir vos commentaires ou vous aider avec les problèmes que vous rencontrez. +feedback.dialog.github=Signaler un problème sur GitHub +feedback.dialog.discord=Rejoindre notre communauté Discord +feedback.dialog.support=Service client session.scroll.bottom=Faire défiler vers le bas session.tab.new=Nouvelle session session.tab.untitled=Session sans titre diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties index c32cc633e9f..8d750daf83e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties @@ -9,6 +9,11 @@ session.empty.welcome=Kilo CodeはAIコーディングアシスタントです session.empty.loading=読み込み中… session.empty.recent=最近 session.showHistory=履歴を表示 +feedback.button=フィードバック & サポート +feedback.dialog.message=フィードバックをお聞かせいただくか、問題がある場合はお気軽にご相談ください。 +feedback.dialog.github=GitHubで問題を報告する +feedback.dialog.discord=Discordコミュニティに参加する +feedback.dialog.support=カスタマーサポート session.scroll.bottom=一番下にスクロール session.tab.new=新しいセッション session.tab.untitled=名前なしのセッション diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties index 893abc95b96..68564e91ac2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties @@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code는 AI 코딩 어시스턴트입니다. 기능 session.empty.loading=로딩 중… session.empty.recent=최근 session.showHistory=기록 보기 +feedback.button=피드백 & 지원 +feedback.dialog.message=피드백을 들려주시거나 겪고 계신 문제에 대해 도움을 드리고 싶습니다. +feedback.dialog.github=GitHub에 이슈 보고하기 +feedback.dialog.discord=Discord 커뮤니티 참여하기 +feedback.dialog.support=고객 지원 session.scroll.bottom=맨 아래로 스크롤 session.tab.new=새 세션 session.tab.untitled=제목 없는 세션 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties index e88d7960997..e7170dd0463 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties @@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code is een AI-codeerassistent. Vraag het om functies session.empty.loading=Laden… session.empty.recent=RECENT session.showHistory=Geschiedenis weergeven +feedback.button=Feedback & Ondersteuning +feedback.dialog.message=We horen graag uw feedback of helpen met eventuele problemen die u ervaart. +feedback.dialog.github=Meld een probleem op GitHub +feedback.dialog.discord=Word lid van onze Discord community +feedback.dialog.support=Klantenservice session.scroll.bottom=Naar beneden scrollen session.tab.new=Nieuwe sessie session.tab.untitled=Naamloze sessie diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties index e7631a21d2c..efef3954315 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties @@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code er en AI-kodingsassistent. Be den om å bygge fu session.empty.loading=Laster… session.empty.recent=NYLIGE session.showHistory=Vis historikk +feedback.button=Tilbakemelding & støtte +feedback.dialog.message=Vi vil gjerne høre tilbakemeldingene dine eller hjelpe med problemer du opplever. +feedback.dialog.github=Rapporter et problem på GitHub +feedback.dialog.discord=Bli med i Discord-fellesskapet vårt +feedback.dialog.support=Kundestøtte session.scroll.bottom=Rull til bunnen session.tab.new=Ny økt session.tab.untitled=Uten tittel diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties index 53ddeb68b15..19efd536fe2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties @@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code to asystent kodowania AI. Poproś go o tworzenie session.empty.loading=Ładowanie… session.empty.recent=OSTATNIE session.showHistory=Pokaż historię +feedback.button=Opinie i wsparcie +feedback.dialog.message=Chętnie poznamy Twoją opinię lub pomożemy w przypadku problemów. +feedback.dialog.github=Zgłoś problem na GitHubie +feedback.dialog.discord=Dołącz do naszej społeczności Discord +feedback.dialog.support=Wsparcie klienta session.scroll.bottom=Przewiń na dół session.tab.new=Nowa sesja session.tab.untitled=Sesja bez tytułu diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties index 27c7eb48436..eb91302e646 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties @@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code é um assistente de codificação com IA. Peça session.empty.loading=Carregando… session.empty.recent=RECENTE session.showHistory=Mostrar histórico +feedback.button=Feedback e suporte +feedback.dialog.message=Adoraríamos ouvir seu feedback ou ajudar com quaisquer problemas que você esteja enfrentando. +feedback.dialog.github=Reportar um problema no GitHub +feedback.dialog.discord=Entrar na nossa comunidade Discord +feedback.dialog.support=Suporte ao cliente session.scroll.bottom=Rolar para o fim session.tab.new=Nova sessão session.tab.untitled=Sessão sem título diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties index e6bdb4d1c62..f7f0f4f0a7d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties @@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code — это AI-ассистент по прогр session.empty.loading=Загрузка… session.empty.recent=НЕДАВНИЕ session.showHistory=Показать историю +feedback.button=Отзывы и поддержка +feedback.dialog.message=Мы будем рады услышать ваши отзывы или помочь с любыми возникающими проблемами. +feedback.dialog.github=Сообщить о проблеме на GitHub +feedback.dialog.discord=Присоединиться к нашему Discord +feedback.dialog.support=Служба поддержки session.scroll.bottom=Прокрутить вниз session.tab.new=Новая сессия session.tab.untitled=Незаголовок сессия diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties index 049b1e1d1ab..d886db273ef 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties @@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code คือผู้ช่วยเขียนโ session.empty.loading=กำลังโหลด… session.empty.recent=ล่าสุด session.showHistory=แสดงประวัติ +feedback.button=ข้อเสนอแนะและการสนับสนุน +feedback.dialog.message=เรายินดีรับฟังข้อเสนอแนะของคุณหรือช่วยแก้ไขปัญหาที่คุณพบ +feedback.dialog.github=รายงานปัญหาบน GitHub +feedback.dialog.discord=เข้าร่วมชุมชน Discord ของเรา +feedback.dialog.support=ฝ่ายสนับสนุนลูกค้า session.scroll.bottom=เลื่อนไปด้านล่าง session.tab.new=เซสชันใหม่ session.tab.untitled=เซสชันไม่มีชื่อ diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties index 096fcb1ee94..b10fe527164 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties @@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code, bir yapay zeka kodlama asistanıdır. Özellik session.empty.loading=Yükleniyor… session.empty.recent=SON session.showHistory=Geçmişi göster +feedback.button=Geri Bildirim ve Destek +feedback.dialog.message=Geri bildiriminizi almaktan veya yaşadığınız sorunlarda yardımcı olmaktan mutluluk duyarız. +feedback.dialog.github=GitHub'da sorun bildirin +feedback.dialog.discord=Discord topluluğumuza katılın +feedback.dialog.support=Müşteri Desteği session.scroll.bottom=En alta kaýr session.tab.new=Yeni oturum session.tab.untitled=Başlıksız oturum diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties index 73d770b3c05..6d870a273f2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties @@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code — це AI-асистент для програ session.empty.loading=Завантаження… session.empty.recent=НЕДАВНІ session.showHistory=Показати історію +feedback.button=Зворотний зв'язок і підтримка +feedback.dialog.message=Ми раді отримати ваш відгук або допомогти з будь-якими проблемами, які у вас виникли. +feedback.dialog.github=Повідомити про проблему на GitHub +feedback.dialog.discord=Приєднатися до нашої спільноти Discord +feedback.dialog.support=Служба підтримки клієнтів session.scroll.bottom=Прокрутити донизу session.tab.new=Нова сесія session.tab.untitled=Сесія без назви diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties index 2288060553f..d7f9efc1326 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties @@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code 是一个 AI 编程助手。可请它构建功 session.empty.loading=加载中… session.empty.recent=最近 session.showHistory=显示历史 +feedback.button=反馈与支持 +feedback.dialog.message=我们很乐意听取您的反馈,或帮助解决您遇到的任何问题。 +feedback.dialog.github=在 GitHub 上报告问题 +feedback.dialog.discord=加入我们的 Discord 社区 +feedback.dialog.support=客户支持 session.scroll.bottom=滚动到底部 session.tab.new=新建会话 session.tab.untitled=无标题会话 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties index 40fb78179b3..ae919bc03d6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties @@ -9,6 +9,11 @@ session.empty.welcome=Kilo Code 是 AI 程式輔助。可請它建置功能、 session.empty.loading=載入中… session.empty.recent=最近 session.showHistory=顯示歷史 +feedback.button=意見回饋與支援 +feedback.dialog.message=我們很樂意聆聽您的意見回饋,或協助解決您遇到的任何問題。 +feedback.dialog.github=在 GitHub 上回報問題 +feedback.dialog.discord=加入我們的 Discord 社群 +feedback.dialog.support=客戶支援 session.scroll.bottom=滾動到底部 session.tab.new=新建工作階段 session.tab.untitled=未命名的工作階段 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt index 52fb937892c..30160c71c5f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt @@ -72,7 +72,7 @@ class SessionUiFactoryTest : BasePlatformTestCase() { val rpc = session("ses_1") val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager, workspaces = workspaces) val controller = controller(ui) - val panel = ai.kilocode.client.session.ui.EmptySessionPanel(testRootDisposable, controller, listOf(rpc)) + val panel = ai.kilocode.client.session.ui.empty.EmptySessionPanel(testRootDisposable, controller, listOf(rpc)) panel.clickRecent(0) @@ -84,7 +84,7 @@ class SessionUiFactoryTest : BasePlatformTestCase() { val manager = FakeManager() val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager, workspaces = workspaces) val controller = controller(ui) - val panel = ai.kilocode.client.session.ui.EmptySessionPanel( + val panel = ai.kilocode.client.session.ui.empty.EmptySessionPanel( testRootDisposable, controller, emptyList(), diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt index 59f5aeb3cd9..fc91e303e1f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -8,7 +8,7 @@ import ai.kilocode.client.session.model.QuestionItem import ai.kilocode.client.session.model.QuestionOption import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.ui.ConnectionPanel -import ai.kilocode.client.session.ui.EmptySessionPanel +import ai.kilocode.client.session.ui.empty.EmptySessionPanel import ai.kilocode.client.session.ui.LoadingPanel import ai.kilocode.client.session.ui.prompt.PromptPanel import ai.kilocode.client.session.ui.account.SessionAccountOverlay diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt index 521e678a67d..1514d0d842a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt @@ -6,11 +6,11 @@ import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.SessionActivityKind -import ai.kilocode.client.session.SessionRef import ai.kilocode.client.session.history.HistoryTime import ai.kilocode.client.session.history.LocalHistoryItem -import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.controller.SessionController +import ai.kilocode.client.session.ui.empty.EmptySessionPanel +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.FilledBadgeIcon import ai.kilocode.client.testing.FakeAppRpcApi import ai.kilocode.client.testing.FakeSessionRpcApi @@ -33,6 +33,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import java.awt.BorderLayout import java.awt.Cursor +import javax.swing.JButton @Suppress("UnstableApiUsage") class EmptySessionPanelTest : BasePlatformTestCase() { @@ -82,10 +83,10 @@ class EmptySessionPanelTest : BasePlatformTestCase() { assertFalse(panel.loadingVisible()) } - fun `test recent section remains visible when empty`() { + fun `test recent section is hidden when empty`() { val panel = panel() - assertTrue(panel.recentVisible()) + assertFalse(panel.recentVisible()) assertEquals(0, panel.recentCount()) } @@ -160,11 +161,20 @@ class EmptySessionPanelTest : BasePlatformTestCase() { assertEquals(ai.kilocode.client.plugin.KiloBundle.message("session.showHistory"), panel.showHistoryText()) } + fun `test feedback button uses localized text and icon`() { + val panel = panel() + + assertEquals(KiloBundle.message("feedback.button"), panel.feedbackText()) + assertNotNull(panel.feedbackIcon()) + } + fun `test action controls use hand cursor and no show history outline`() { val panel = panel() assertFalse(panel.showHistoryBorderPainted()) + assertFalse(panel.feedbackBorderPainted()) assertEquals(Cursor.HAND_CURSOR, panel.showHistoryCursor()) + assertEquals(Cursor.HAND_CURSOR, panel.feedbackCursor()) assertEquals(Cursor.HAND_CURSOR, panel.recentCursor()) } @@ -177,6 +187,36 @@ class EmptySessionPanelTest : BasePlatformTestCase() { assertEquals(1, calls) } + fun `test feedback popup content opens expected destinations`() { + val panel = panel() + val opened = mutableListOf() + val content = panel.feedbackContent { opened.add(it) } + val buttons = UIUtil.uiTraverser(content).filter(JButton::class.java).toList() + + assertEquals( + listOf( + KiloBundle.message("feedback.dialog.github"), + KiloBundle.message("feedback.dialog.discord"), + KiloBundle.message("feedback.dialog.support"), + ), + buttons.map { it.text }, + ) + + buttons.forEach { it.doClick() } + + assertEquals(panel.feedbackUrls(), opened) + } + + fun `test feedback discord action has icon`() { + val panel = panel() + val content = panel.feedbackContent() + val discord = UIUtil.uiTraverser(content) + .filter(JButton::class.java) + .first { it.text == KiloBundle.message("feedback.dialog.discord") } + + assertNotNull(discord.icon) + } + fun `test renderer aligns title center and time east`() { val cell = panel().rendererComponent(session("ses_1")) as BorderLayoutPanel val layout = cell.layout as BorderLayout From d437a4d3b5669940e5de1e4aa428f0217d16448b Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 1 Jun 2026 17:24:50 -0400 Subject: [PATCH 02/14] refactor(jetbrains): refine empty session feedback UI --- .../session/ui/empty/EmptySessionFeedback.kt | 21 ++++++++++++++----- .../session/ui/empty/EmptySessionPanel.kt | 6 ++---- .../client/session/ui/empty/RecentsList.kt | 18 +++++++++------- .../resources/messages/KiloBundle.properties | 2 +- .../session/ui/EmptySessionPanelTest.kt | 2 +- 5 files changed, 30 insertions(+), 19 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionFeedback.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionFeedback.kt index 48ca0fcc3e4..0fb883bd871 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionFeedback.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionFeedback.kt @@ -10,9 +10,11 @@ import com.intellij.icons.AllIcons import com.intellij.openapi.ui.popup.Balloon import com.intellij.openapi.ui.popup.JBPopupFactory import com.intellij.openapi.util.IconLoader +import com.intellij.openapi.util.Disposer import com.intellij.ui.awt.RelativePoint import com.intellij.ui.components.JBLabel import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import com.intellij.xml.util.XmlStringUtil import java.awt.Cursor @@ -25,19 +27,25 @@ import javax.swing.JComponent internal class EmptySessionFeedback( private val browse: (String) -> Unit, ) { + private var balloon: Balloon? = null + val button: JButton = FeedbackButton().apply { addActionListener { popup() } } @RequiresEdt private fun popup() { - lateinit var balloon: Balloon + balloon?.let { + it.hide() + return + } + val content = content { url -> browse(url) - balloon.hide() + balloon?.hide() } - val point = RelativePoint(button, Point(button.width / 2, button.height)) - balloon = JBPopupFactory.getInstance() + val point = RelativePoint(button, Point(button.width / 2, button.height + JBUI.scale(1))) + val popup = JBPopupFactory.getInstance() .createBalloonBuilder(content) .setHideOnClickOutside(true) .setHideOnKeyOutside(true) @@ -50,7 +58,10 @@ internal class EmptySessionFeedback( .setCornerRadius(UiStyle.Balloon.arc()) .createBalloon() - balloon.show(point, Balloon.Position.below) + balloon = popup + popup.setAnimationEnabled(false) + Disposer.register(popup) { balloon = null } + popup.show(point, Balloon.Position.below) } private class FeedbackButton : EmptySessionPanel.ShowHistoryButton(buttonHtml(), AllIcons.Ide.Feedback) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt index bd91413f63a..9246b72fcfd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt @@ -58,7 +58,7 @@ class EmptySessionPanel( private var style = SessionEditorStyle.current() private val timer = Timer(ACTIVITY_MS) { syncActivity() } - private val recent = RecentsList(recents, controller) + internal val recent = RecentsList(recents, controller) private val historyButton = ShowHistoryButton().apply { addActionListener { history() } @@ -162,8 +162,6 @@ class EmptySessionPanel( internal fun showHistoryCursor() = historyButton.cursor.type - internal fun recentCursor() = recent.cursorType() - internal fun recentVisible() = recent.hasSessions() internal fun explanationText() = KiloBundle.message("session.empty.welcome") @@ -251,7 +249,7 @@ class EmptySessionPanel( override fun applyStyle(style: SessionEditorStyle) { this.style = style welcomeLabel.font = style.regularFont - recent.titleFont(style.smallFont) + recent.applyStyle(style) revalidate() repaint() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/RecentsList.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/RecentsList.kt index c3205b69cf8..75380c86918 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/RecentsList.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/RecentsList.kt @@ -9,6 +9,8 @@ import ai.kilocode.client.session.history.HistoryTime import ai.kilocode.client.session.history.LocalHistoryItem import ai.kilocode.client.session.history.itemAt import ai.kilocode.client.session.history.title +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.FilledBadgeIcon import ai.kilocode.client.ui.UiStyle @@ -34,7 +36,7 @@ import javax.swing.ListSelectionModel internal class RecentsList( sessions: List, private val controller: SessionController, -) : BorderLayoutPanel() { +) : BorderLayoutPanel(), SessionEditorStyleTarget { private val model = DefaultListModel() private var hover = -1 private var snapshot = HistoryActivitySnapshot() @@ -43,7 +45,7 @@ internal class RecentsList( foreground = UIUtil.getContextHelpForeground() } - private val list = JBList(model).apply { + internal val list = JBList(model).apply { isOpaque = false selectionMode = ListSelectionModel.SINGLE_SELECTION visibleRowCount = SessionUiStyle.RecentSessions.LIMIT @@ -93,12 +95,6 @@ internal class RecentsList( controller.openSession(SessionRef.Local(model.getElementAt(index).session)) } - fun cursorType() = list.cursor.type - - fun titleFont(font: java.awt.Font) { - title.font = font - } - fun text(session: SessionDto, now: Long = System.currentTimeMillis()) = HistoryTime.relative(LocalHistoryItem(session), now) @@ -122,6 +118,12 @@ internal class RecentsList( repaintRows(changed) } + override fun applyStyle(style: SessionEditorStyle) { + title.font = style.smallFont + revalidate() + repaint() + } + private fun setSessions(sessions: List) { model.clear() sessions.take(SessionUiStyle.RecentSessions.LIMIT).map(::LocalHistoryItem).forEach(model::addElement) diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index eeca886cc60..050eee20982 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -11,7 +11,7 @@ session.account.switcher=Switch account session.empty.loading=Loading... session.empty.recent=RECENT session.showHistory=Show History -feedback.button=Feedback & Support +feedback.button=Feedback and Support feedback.dialog.message=We'd love to hear your feedback or help with any issues you're experiencing. feedback.dialog.github=Report an issue on GitHub feedback.dialog.discord=Join our Discord community diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt index 1514d0d842a..d0c6f0b0eab 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt @@ -175,7 +175,7 @@ class EmptySessionPanelTest : BasePlatformTestCase() { assertFalse(panel.feedbackBorderPainted()) assertEquals(Cursor.HAND_CURSOR, panel.showHistoryCursor()) assertEquals(Cursor.HAND_CURSOR, panel.feedbackCursor()) - assertEquals(Cursor.HAND_CURSOR, panel.recentCursor()) + assertEquals(Cursor.HAND_CURSOR, panel.recent.list.cursor.type) } fun `test clicking show history delegates callback`() { From cb99e77ca027904ca93d00c78deef72544e6025d Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 1 Jun 2026 17:34:09 -0400 Subject: [PATCH 03/14] fix(jetbrains): dispose empty session feedback balloon --- .../client/session/ui/empty/EmptySessionFeedback.kt | 9 +++++++-- .../client/session/ui/empty/EmptySessionPanel.kt | 3 +-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionFeedback.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionFeedback.kt index 0fb883bd871..ad6f79d05bb 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionFeedback.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionFeedback.kt @@ -7,10 +7,11 @@ import ai.kilocode.client.ui.layout.Stack import ai.kilocode.client.ui.layout.VAlign import ai.kilocode.client.ui.layout.align import com.intellij.icons.AllIcons +import com.intellij.openapi.Disposable import com.intellij.openapi.ui.popup.Balloon import com.intellij.openapi.ui.popup.JBPopupFactory -import com.intellij.openapi.util.IconLoader import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.IconLoader import com.intellij.ui.awt.RelativePoint import com.intellij.ui.components.JBLabel import com.intellij.util.concurrency.annotations.RequiresEdt @@ -26,7 +27,7 @@ import javax.swing.JComponent internal class EmptySessionFeedback( private val browse: (String) -> Unit, -) { +) : Disposable { private var balloon: Balloon? = null val button: JButton = FeedbackButton().apply { @@ -66,6 +67,10 @@ internal class EmptySessionFeedback( private class FeedbackButton : EmptySessionPanel.ShowHistoryButton(buttonHtml(), AllIcons.Ide.Feedback) + override fun dispose() { + balloon?.hide() + } + private class ActionButton(text: String, icon: javax.swing.Icon, action: () -> Unit) : JButton(text, icon) { init { cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt index 9246b72fcfd..29ad9df649f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/empty/EmptySessionPanel.kt @@ -56,7 +56,6 @@ class EmptySessionPanel( ) : BorderLayoutPanel(), Disposable, SessionEditorStyleTarget { val view: Align = align(HAlign.CENTER, VAlign.CENTER) - private var style = SessionEditorStyle.current() private val timer = Timer(ACTIVITY_MS) { syncActivity() } internal val recent = RecentsList(recents, controller) @@ -90,6 +89,7 @@ class EmptySessionPanel( init { Disposer.register(parent, this) + Disposer.register(this, feedback) isOpaque = false applyStyle(SessionEditorStyle.current()) addHierarchyListener { e -> @@ -247,7 +247,6 @@ class EmptySessionPanel( } override fun applyStyle(style: SessionEditorStyle) { - this.style = style welcomeLabel.font = style.regularFont recent.applyStyle(style) revalidate() From 8bbefb33001c0df5197be01a159fe5134026d838 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 1 Jun 2026 18:02:30 -0400 Subject: [PATCH 04/14] fix(jetbrains): avoid config update SSE race --- .../kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt index 5a0c14126ea..b37041af362 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt @@ -23,6 +23,7 @@ import com.intellij.openapi.Disposable import com.intellij.openapi.components.Service import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay @@ -343,6 +344,7 @@ class KiloBackendAppService private constructor( sessions.start(connection.api!!, connection.apiClient!!, connection.port, connection.events) chat.start(connection.apiClient!!, connection.port, connection.events) workspaces.start(connection.api!!, connection.apiClient!!, connection.port, connection.events) + startWatchingGlobalSseEvents() setAppReady( AppData( profile = prof, @@ -352,7 +354,6 @@ class KiloBackendAppService private constructor( ) ) log.info("Application started — config, profile, notifications loaded") - startWatchingGlobalSseEvents() } catch (e: TimeoutCancellationException) { val err = LoadError( resource = "app", @@ -592,7 +593,7 @@ class KiloBackendAppService private constructor( synchronized(loadLock) { if (eventWatcher?.isActive == true) return log.info("Started watching global SSE events (config.updated, disposed)") - eventWatcher = cs.launch { + eventWatcher = cs.launch(start = CoroutineStart.UNDISPATCHED) { connection.events.collect { event -> when (event.type) { "global.config.updated" -> { From 837a87509cb323dbf212cbf40af112f218221dd0 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 2 Jun 2026 19:49:24 +0200 Subject: [PATCH 05/14] fix: keep post-compaction replies ordered --- .changeset/fresh-compaction-order.md | 6 ++ .../tests/unit/session-queue.test.ts | 84 +++++++++++++++ .../src/components/chat/MessageList.tsx | 13 ++- .../webview-ui/src/context/session-queue.ts | 84 +++++++++++---- .../webview-ui/src/context/session.tsx | 4 +- .../webview-ui/src/types/messages/parts.ts | 9 +- .../src/kilocode/session/message-order.ts | 66 ++++++++++++ .../opencode/src/kilocode/session/prompt.ts | 21 ++-- packages/opencode/src/session/message-v2.ts | 2 + packages/opencode/src/session/prompt.ts | 33 +++--- .../session-compaction-safety.test.ts | 102 ++++++++++++++++++ 11 files changed, 372 insertions(+), 52 deletions(-) create mode 100644 .changeset/fresh-compaction-order.md create mode 100644 packages/opencode/src/kilocode/session/message-order.ts diff --git a/.changeset/fresh-compaction-order.md b/.changeset/fresh-compaction-order.md new file mode 100644 index 00000000000..7d863475e6b --- /dev/null +++ b/.changeset/fresh-compaction-order.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Keep post-compaction tool calls and follow-up messages ordered after the compaction summary in the CLI and VS Code transcript. diff --git a/packages/kilo-vscode/tests/unit/session-queue.test.ts b/packages/kilo-vscode/tests/unit/session-queue.test.ts index 40a9b282f85..8e8f6934c3a 100644 --- a/packages/kilo-vscode/tests/unit/session-queue.test.ts +++ b/packages/kilo-vscode/tests/unit/session-queue.test.ts @@ -17,6 +17,11 @@ const base = { const user = (id: string): Message => ({ ...base, id, role: "user" }) +const compact = (id: string): Message => ({ + ...user(id), + parts: [{ id: `part_${id}`, sessionID: base.sessionID, messageID: id, type: "compaction", auto: false }], +}) + const assistant = (id: string, parentID: string, opts: Partial = {}): Message => ({ ...base, id, @@ -316,6 +321,42 @@ describe("messageTurns", () => { ]) }) + it("keeps resumed replies after a persisted compaction turn", () => { + const messages = [ + user("message_1"), + assistant("message_2", "message_1"), + compact("message_3"), + assistant("message_4", "message_3", { summary: true, finish: "stop" }), + assistant("message_5", "message_1", { finish: "stop" }), + ] + + expect( + messageTurns(messages).map((turn) => ({ + user: turn.user.id, + assistant: turn.assistant.map((msg) => msg.id), + })), + ).toEqual([ + { user: "message_1", assistant: ["message_2"] }, + { user: "message_3", assistant: ["message_4", "message_5"] }, + ]) + }) + + it("detects persisted compaction parts through the lazy lookup", () => { + const messages = [ + user("message_1"), + assistant("message_2", "message_1"), + user("message_3"), + assistant("message_4", "message_3", { summary: true, finish: "stop" }), + assistant("message_5", "message_1", { finish: "stop" }), + ] + + expect( + visibleMessages(messages, undefined, (msg) => (msg.id === "message_3" ? compact(msg.id).parts : msg.parts)).map( + (msg) => msg.id, + ), + ).toEqual(["message_1", "message_2", "message_3", "message_4", "message_5"]) + }) + it("surfaces leading assistant output as partial turns grouped by parent", () => { const messages = [ assistant("message_2", "message_1"), @@ -437,6 +478,49 @@ describe("activeUserMessageID", () => { expect(activeUserMessageID(messages, { type: "busy" })).toBe("message_1") }) + it("maps resumed post-compaction tool calls to the compaction turn", () => { + const messages = [ + user("message_1"), + assistant("message_2", "message_1"), + compact("message_3"), + assistant("message_4", "message_3", { summary: true, finish: "stop" }), + assistant("message_5", "message_1", { finish: "tool-calls" }), + user("message_6"), + ] + + expect(activeUserMessageID(messages, { type: "busy" })).toBe("message_3") + expectLayout(messages, { type: "busy" }, { virtual: ["message_1"], direct: ["message_3"], queued: ["message_6"] }) + }) + + it("uses lazy compaction parts when mapping resumed tool calls", () => { + const messages = [ + user("message_1"), + assistant("message_2", "message_1"), + user("message_3"), + assistant("message_4", "message_3", { summary: true, finish: "stop" }), + assistant("message_5", "message_1", { finish: "tool-calls" }), + ] + + expect( + activeUserMessageID(messages, { type: "busy" }, (msg) => + msg.id === "message_3" ? compact(msg.id).parts : msg.parts, + ), + ).toBe("message_3") + }) + + it("advances beyond a completed post-compaction reply", () => { + const messages = [ + user("message_1"), + assistant("message_2", "message_1", { finish: "stop" }), + compact("message_3"), + assistant("message_4", "message_3", { summary: true, finish: "stop" }), + assistant("message_5", "message_1", { finish: "stop" }), + user("message_6"), + ] + + expect(activeUserMessageID(messages, { type: "busy" })).toBe("message_6") + }) + it("ignores completed tool-call assistants after the session becomes idle", () => { const messages = [ user("message_1"), diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx index 702bd3a680e..7b2f1241592 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx @@ -92,14 +92,21 @@ export const MessageList: Component = (props) => { const boundary = () => session.revert()?.messageID const turns = createMemo((prev: MessageTurn[] | undefined) => - stableMessageTurns(messageTurns(session.messages(), boundary()), prev), + stableMessageTurns( + messageTurns(session.messages(), boundary(), (msg) => session.getParts(msg.id)), + prev, + ), ) const isEmpty = () => turns().length === 0 && !session.loading() && !boundary() const recent = createMemo(() => recentSessions(session.sessions())) - const activeUserID = createMemo(() => getActiveUserMessageID(session.messages(), session.statusInfo())) - const queuedIDs = createMemo(() => new Set(queuedUserMessageIDs(session.messages(), session.statusInfo()))) + const activeUserID = createMemo(() => + getActiveUserMessageID(session.messages(), session.statusInfo(), (msg) => session.getParts(msg.id)), + ) + const queuedIDs = createMemo( + () => new Set(queuedUserMessageIDs(session.messages(), session.statusInfo(), (msg) => session.getParts(msg.id))), + ) const [held, setHeld] = createSignal<{ sid: string; ids: Set }>() createEffect(() => { const id = activeUserID() diff --git a/packages/kilo-vscode/webview-ui/src/context/session-queue.ts b/packages/kilo-vscode/webview-ui/src/context/session-queue.ts index 18258bacb91..066df583295 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-queue.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-queue.ts @@ -42,27 +42,52 @@ function partials(messages: Message[]): MessageTurn[] { .map(partial) } -export function messageTurns(messages: Message[], boundary?: string): MessageTurn[] { +function isCompact(msg: Message, parts?: (msg: Message) => Message["parts"]) { + return msg.role === "user" && (parts?.(msg) ?? msg.parts)?.some((part) => part.type === "compaction") +} + +function target(messages: Message[], index: number, id: string, parts?: (msg: Message) => Message["parts"]) { + const parent = messages.findIndex((msg) => msg.id === id) + for (let i = index - 1; i > parent; i -= 1) { + const msg = messages[i] + if (msg && isCompact(msg, parts)) return msg.id + } + return id +} + +export function messageTurns( + messages: Message[], + boundary?: string, + parts?: (msg: Message) => Message["parts"], +): MessageTurn[] { const result: MessageTurn[] = [] const lead: Message[] = [] - const by = new Map() + const by = new Map() + let compact: { turn: MessageTurn; index: number } | undefined for (const msg of messages) { if (msg.role === "user") { if (boundary && msg.id >= boundary) break const turn = { id: msg.id, user: msg, assistant: [] } + const item = { turn, index: result.length } result.push(turn) - by.set(msg.id, turn) + by.set(msg.id, item) + if (isCompact(msg, parts)) compact = item continue } if (msg.role !== "assistant") continue - const turn = msg.parentID ? by.get(msg.parentID) : undefined - if (turn) { + const parent = msg.parentID ? by.get(msg.parentID) : undefined + if (parent) { + const turn = compact && parent.index < compact.index ? compact.turn : parent.turn turn.assistant.push(msg) continue } if (msg.parentID) { + if (compact) { + compact.turn.assistant.push(msg) + continue + } lead.push(msg) continue } @@ -78,8 +103,12 @@ export function messageTurns(messages: Message[], boundary?: string): MessageTur return [...partials(lead), ...result] } -export function visibleMessages(messages: Message[], boundary?: string): Message[] { - return messageTurns(messages, boundary).flatMap((turn) => +export function visibleMessages( + messages: Message[], + boundary?: string, + parts?: (msg: Message) => Message["parts"], +): Message[] { + return messageTurns(messages, boundary, parts).flatMap((turn) => turn.partial ? turn.assistant : [turn.user, ...turn.assistant], ) } @@ -106,7 +135,7 @@ export function stableMessageTurns(next: MessageTurn[], prev: MessageTurn[] = [] }) } -function active(messages: Message[], status: SessionStatusInfo) { +function active(messages: Message[], status: SessionStatusInfo, parts?: (msg: Message) => Message["parts"]) { let latest = true for (let i = messages.length - 1; i >= 0; i -= 1) { const msg = messages[i] @@ -118,8 +147,9 @@ function active(messages: Message[], status: SessionStatusInfo) { if (msg.error) continue if (msg.finish && !resumable) continue if (!msg.parentID) break - const parent = messages.find((item) => item.id === msg.parentID) - if (!parent) return msg.parentID + const id = target(messages, i, msg.parentID, parts) + const parent = messages.find((item) => item.id === id) + if (!parent) return id if (parent.role === "user") return parent.id break } @@ -127,20 +157,20 @@ function active(messages: Message[], status: SessionStatusInfo) { return undefined } -function done(messages: Message[]) { +function done(messages: Message[], parts?: (msg: Message) => Message["parts"]) { for (let i = messages.length - 1; i >= 0; i -= 1) { const msg = messages[i] - if (!msg || msg.role !== "assistant") continue - if (typeof msg.time?.completed === "number") return msg.parentID - if (msg.error) return msg.parentID - if (msg.finish && !["tool-calls", "unknown"].includes(msg.finish)) return msg.parentID + if (!msg || msg.role !== "assistant" || !msg.parentID) continue + if (typeof msg.time?.completed === "number") return target(messages, i, msg.parentID, parts) + if (msg.error) return target(messages, i, msg.parentID, parts) + if (msg.finish && !["tool-calls", "unknown"].includes(msg.finish)) return target(messages, i, msg.parentID, parts) } return undefined } -function pending(messages: Message[]) { +function pending(messages: Message[], parts?: (msg: Message) => Message["parts"]) { const users = messages.filter((msg) => msg.role === "user") - const id = done(messages) + const id = done(messages, parts) if (!id) return users[0]?.id const idx = users.findIndex((msg) => msg.id === id) @@ -149,23 +179,31 @@ function pending(messages: Message[]) { // Find the user message whose turn the server is actively processing. // Any user message after this one is "queued" (waiting for its turn). -export function activeUserMessageID(messages: Message[], status: SessionStatusInfo) { - const id = active(messages, status) +export function activeUserMessageID( + messages: Message[], + status: SessionStatusInfo, + parts?: (msg: Message) => Message["parts"], +) { + const id = active(messages, status, parts) if (id) return id if (status.type === "idle") return undefined - return pending(messages) + return pending(messages, parts) } -export function queuedUserMessageIDs(messages: Message[], status: SessionStatusInfo) { +export function queuedUserMessageIDs( + messages: Message[], + status: SessionStatusInfo, + parts?: (msg: Message) => Message["parts"], +) { if (status.type === "idle") return [] const users = messages.filter((msg) => msg.role === "user") - const running = active(messages, status) + const running = active(messages, status, parts) if (running) { const idx = users.findIndex((msg) => msg.id === running) if (idx < 0) return users.map((msg) => msg.id) return users.slice(idx + 1).map((msg) => msg.id) } - const id = pending(messages) + const id = pending(messages, parts) const idx = id ? users.findIndex((msg) => msg.id === id) : -1 if (idx < 0) return [] return users.slice(idx + 1).map((msg) => msg.id) diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index d93c05ffce5..0f564a07d01 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -2304,7 +2304,9 @@ export const SessionProvider: ParentComponent = (props) => { const userMessages = createMemo(() => messages().filter((m) => m.role === "user")) function visible(sessionID: string) { - return filterVisibleMessages(store.messages[sessionID] ?? [], store.sessions[sessionID]?.revert?.messageID) + return filterVisibleMessages(store.messages[sessionID] ?? [], store.sessions[sessionID]?.revert?.messageID, (msg) => + getParts(msg.id), + ) } const revert = createMemo(() => { diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/parts.ts b/packages/kilo-vscode/webview-ui/src/types/messages/parts.ts index cdfb9efd3e6..336458f2cfc 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/parts.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/parts.ts @@ -73,7 +73,14 @@ export interface StepFinishPart extends BasePart { } } -export type Part = TextPart | FilePart | ToolPart | ReasoningPart | StepStartPart | StepFinishPart +export interface CompactionPart extends BasePart { + type: "compaction" + auto: boolean + overflow?: boolean + tail_start_id?: string +} + +export type Part = TextPart | FilePart | ToolPart | ReasoningPart | StepStartPart | StepFinishPart | CompactionPart // Part delta for streaming updates export interface PartDelta { diff --git a/packages/opencode/src/kilocode/session/message-order.ts b/packages/opencode/src/kilocode/session/message-order.ts new file mode 100644 index 00000000000..0d255eb22ce --- /dev/null +++ b/packages/opencode/src/kilocode/session/message-order.ts @@ -0,0 +1,66 @@ +import type { MessageV2 } from "@/session/message-v2" + +const chronology = new WeakMap() + +export namespace KiloSessionMessageOrder { + /** Preserve chronological order before model-facing projections rearrange messages. */ + export function annotate(msgs: MessageV2.WithParts[]) { + for (const [index, msg] of msgs.entries()) chronology.set(msg, index) + return msgs + } + + export function compare(a: MessageV2.WithParts, b: MessageV2.WithParts, indexA = -1, indexB = -1) { + if (a.info.time.created !== b.info.time.created) return a.info.time.created - b.info.time.created + const sequenceA = chronology.get(a) + const sequenceB = chronology.get(b) + if (sequenceA !== undefined && sequenceB !== undefined && sequenceA !== sequenceB) return sequenceA - sequenceB + return indexA - indexB + } + + /** Derive active messages by chronology while keeping queued tasks in model-facing projection order. */ + export function latest(msgs: MessageV2.WithParts[]) { + let user: MessageV2.WithParts | undefined + let assistant: MessageV2.WithParts | undefined + let finished: MessageV2.WithParts | undefined + let userIndex = -1 + let assistantIndex = -1 + let finishedIndex = -1 + + for (const [index, msg] of msgs.entries()) { + const info = msg.info + if (info.role === "user" && (!user || compare(msg, user, index, userIndex) > 0)) { + user = msg + userIndex = index + } + if (info.role === "assistant" && (!assistant || compare(msg, assistant, index, assistantIndex) > 0)) { + assistant = msg + assistantIndex = index + } + if (info.role === "assistant" && info.finish && (!finished || compare(msg, finished, index, finishedIndex) > 0)) { + finished = msg + finishedIndex = index + } + } + + const pivot = msgs.findLastIndex((msg) => msg.info.role === "assistant" && msg.info.finish) + const tasks = msgs + .slice(pivot + 1) + .reverse() + .flatMap((msg) => + msg.parts.filter( + (part): part is MessageV2.CompactionPart | MessageV2.SubtaskPart => + part.type === "compaction" || part.type === "subtask", + ), + ) + + return { + user: user?.info.role === "user" ? user.info : undefined, + assistant: assistant?.info.role === "assistant" ? assistant.info : undefined, + finished: finished?.info.role === "assistant" ? finished.info : undefined, + userMessage: user, + assistantMessage: assistant, + finishedMessage: finished, + tasks, + } + } +} diff --git a/packages/opencode/src/kilocode/session/prompt.ts b/packages/opencode/src/kilocode/session/prompt.ts index c151a8f7f45..7ff0b20d726 100644 --- a/packages/opencode/src/kilocode/session/prompt.ts +++ b/packages/opencode/src/kilocode/session/prompt.ts @@ -12,6 +12,7 @@ import type { SessionStatus } from "@/session/status" import { Flag } from "@opencode-ai/core/flag/flag" import { PlanFollowup } from "@/kilocode/plan-followup" import { KiloSession } from "@/kilocode/session" +import { KiloSessionMessageOrder } from "@/kilocode/session/message-order" import { Permission } from "@/permission" import { environmentDetails, type EditorContext } from "@/kilocode/editor-context" import { Identifier } from "@/id/id" @@ -342,14 +343,18 @@ export namespace KiloSessionPrompt { * `msgs`, `msgs` is returned unchanged. */ export function trimBeforeLastSummary(msgs: MessageV2.WithParts[]): MessageV2.WithParts[] { - for (let i = msgs.length - 1; i >= 0; i--) { - const info = msgs[i].info - if (info.role !== "assistant" || info.summary !== true || !info.finish || info.error) continue - const parentIdx = msgs.findIndex((m) => m.info.id === info.parentID) - if (parentIdx === -1) return msgs - return parentIdx === 0 ? msgs : msgs.slice(parentIdx) - } - return msgs + const summary = msgs.reduce<{ msg: MessageV2.WithParts; index: number } | undefined>((latest, msg, index) => { + const info = msg.info + if (info.role !== "assistant" || info.summary !== true || !info.finish || info.error) return latest + if (!latest || KiloSessionMessageOrder.compare(msg, latest.msg, index, latest.index) > 0) return { msg, index } + return latest + }, undefined) + if (!summary) return msgs + const info = summary.msg.info + if (info.role !== "assistant") return msgs + const parentIdx = msgs.findIndex((m) => m.info.id === info.parentID) + if (parentIdx === -1) return msgs + return parentIdx === 0 ? msgs : msgs.slice(parentIdx) } /** diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 70abefb6909..38be168c2d3 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -24,6 +24,7 @@ import type { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "@/provider/schema" import { SessionNetwork } from "./network" // kilocode_change import { CodexAuthExpiredError } from "@/kilocode/provider/codex-refresh" // kilocode_change +import { KiloSessionMessageOrder } from "@/kilocode/session/message-order" // kilocode_change import { Effect, Schema, Types } from "effect" import { zod, ZodOverride } from "@/util/effect-zod" import { NonNegativeInt, withStatics } from "@/util/schema" @@ -1218,6 +1219,7 @@ export function filterCompacted(msgs: Iterable) { completed.add(msg.info.parentID) } result.reverse() + KiloSessionMessageOrder.annotate(result) // kilocode_change - preserve chronology before retained-tail projection const compactionIndex = result.findLastIndex( (msg) => msg.info.role === "user" && diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f1181bc9bdf..ecdd048bb41 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -2,6 +2,7 @@ import path from "path" import os from "os" import fs from "fs/promises" import { KiloSessionPrompt } from "@/kilocode/session/prompt" // kilocode_change +import { KiloSessionMessageOrder } from "@/kilocode/session/message-order" // kilocode_change import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue" // kilocode_change import { KiloSession } from "@/kilocode/session" // kilocode_change import { KiloCostPropagation } from "@/kilocode/session/cost-propagation" // kilocode_change @@ -1496,19 +1497,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the msgs = KiloSessionPromptQueue.scope(sessionID, msgs) // kilocode_change - hide later queued prompts msgs = KiloSessionPrompt.trimBeforeLastSummary(msgs) // kilocode_change - trim on any completed summary (e.g. manual /compact against a text user) - let lastUser: MessageV2.User | undefined - let lastAssistant: MessageV2.Assistant | undefined - let lastFinished: MessageV2.Assistant | undefined - let tasks: (MessageV2.CompactionPart | MessageV2.SubtaskPart)[] = [] - for (let i = msgs.length - 1; i >= 0; i--) { - const msg = msgs[i] - if (!lastUser && msg.info.role === "user") lastUser = msg.info - if (!lastAssistant && msg.info.role === "assistant") lastAssistant = msg.info - if (!lastFinished && msg.info.role === "assistant" && msg.info.finish) lastFinished = msg.info - if (lastUser && lastFinished) break - const task = msg.parts.filter((part) => part.type === "compaction" || part.type === "subtask") - if (task && !lastFinished) tasks.push(...task) - } + // kilocode_change start - select loop state by chronology after retained-tail projection + const latest = KiloSessionMessageOrder.latest(msgs) + const { user: lastUser, assistant: lastAssistant, finished: lastFinished, tasks } = latest // kilocode_change end if (!lastUser) throw new Error("No user message found in stream. This should never happen.") @@ -1516,6 +1507,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the const lastAssistantMsg = msgs.findLast( (msg) => msg.info.role === "assistant" && msg.info.id === lastAssistant?.id, ) + // kilocode_change start - compare chronology, not generated IDs + const userBeforeAssistant = + latest.userMessage && + latest.assistantMessage && + KiloSessionMessageOrder.compare(latest.userMessage, latest.assistantMessage) < 0 + // kilocode_change end // kilocode_change start - carry local review command marker into LLM telemetry const telemetry = KiloSessionProcessor.extractReviewTelemetry( @@ -1537,7 +1534,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the lastAssistant?.finish && hasToolCalls && lastAssistant.parentID === lastUser.id && - lastUser.id < lastAssistant.id && + userBeforeAssistant && KiloSessionPrompt.shouldAskPlanFollowup({ messages: msgs, abort: AbortSignal.any([]) }) ) { const action = yield* Effect.promise((signal) => @@ -1554,7 +1551,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the !["tool-calls"].includes(lastAssistant.finish) && !hasToolCalls && lastAssistant.parentID === lastUser.id && // kilocode_change - unrelated later assistants do not answer this turn - lastUser.id < lastAssistant.id + userBeforeAssistant // kilocode_change - compare chronology, not generated IDs ) { // kilocode_change start - ask follow-up when plan_exit tool was called const action = yield* Effect.promise((signal) => @@ -1690,7 +1687,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the if (step > 1 && lastFinished) { for (const m of msgs) { - if (m.info.role !== "user" || m.info.id <= lastFinished.id) continue + // kilocode_change start - compare chronology, not generated IDs + const finishedBeforeMessage = + latest.finishedMessage && KiloSessionMessageOrder.compare(latest.finishedMessage, m) < 0 + if (m.info.role !== "user" || !finishedBeforeMessage) continue + // kilocode_change end for (const p of m.parts) { if (p.type !== "text" || p.ignored || p.synthetic) continue if (!p.text.trim()) continue diff --git a/packages/opencode/test/kilocode/session-compaction-safety.test.ts b/packages/opencode/test/kilocode/session-compaction-safety.test.ts index 8254e748a68..9ba3694cbb8 100644 --- a/packages/opencode/test/kilocode/session-compaction-safety.test.ts +++ b/packages/opencode/test/kilocode/session-compaction-safety.test.ts @@ -4,6 +4,7 @@ import { describe, expect, test } from "bun:test" import { KiloSessionPrompt } from "../../src/kilocode/session/prompt" +import { KiloSessionMessageOrder } from "../../src/kilocode/session/message-order" import { MessageV2 } from "../../src/session/message-v2" import { ModelID, ProviderID } from "../../src/provider/schema" import { MessageID, PartID, SessionID } from "../../src/session/schema" @@ -68,6 +69,29 @@ function syntheticTextPart(messageID: string, text: string, partID = "p_syn_" + } } +function compactionPart(messageID: string, tailStartID: string): MessageV2.CompactionPart { + return { + id: PartID.make("p_compact_" + messageID), + sessionID, + messageID: MessageID.make(messageID), + type: "compaction", + auto: false, + tail_start_id: MessageID.make(tailStartID), + } +} + +function subtaskPart(messageID: string): MessageV2.SubtaskPart { + return { + id: PartID.make("p_subtask_" + messageID), + sessionID, + messageID: MessageID.make(messageID), + type: "subtask", + prompt: "continue", + description: "Continue queued task", + agent: "test", + } +} + function filePart( messageID: string, mime: string, @@ -149,6 +173,11 @@ function assistant( return { info: assistantInfo(id, parentID, opts), parts } } +function created(msg: MessageV2.WithParts, time: number) { + msg.info.time.created = time + return msg +} + const apiError = new MessageV2.APIError({ message: "boom", isRetryable: true, @@ -178,6 +207,54 @@ describe("KiloSessionPrompt.hasCompletedSummary", () => { }) }) +describe("MessageV2.latest", () => { + test("selects chronological state after retained pre-compaction tail", () => { + const msgs = MessageV2.filterCompacted([ + created(assistant("msg_summary", "msg_compact", [], { summary: true, finish: "end_turn" }), 4), + created(user("msg_compact", [compactionPart("msg_compact", "msg_tail")]), 3), + created(assistant("msg_tail_reply", "msg_tail", [], { finish: "end_turn" }), 2), + created(user("msg_tail", [textPart("msg_tail", "historical retained prompt")]), 1), + ]) + + expect(msgs.map((m) => m.info.id)).toEqual([ + MessageID.make("msg_compact"), + MessageID.make("msg_summary"), + MessageID.make("msg_tail"), + MessageID.make("msg_tail_reply"), + ]) + + const state = KiloSessionMessageOrder.latest(msgs) + expect(state.user?.id).toBe(MessageID.make("msg_compact")) + expect(state.assistant?.id).toBe(MessageID.make("msg_summary")) + expect(state.finished?.id).toBe(MessageID.make("msg_summary")) + expect(state.tasks).toEqual([]) + }) + + test("keeps queued subtasks moved after a chronologically later assistant", () => { + const part = subtaskPart("msg_queued") + const active = created(user("msg_active"), 1) + const queued = created(user("msg_queued", [part]), 2) + const done = created(assistant("msg_done", "msg_active", [], { finish: "end_turn" }), 3) + KiloSessionMessageOrder.annotate([active, queued, done]) + + const state = KiloSessionMessageOrder.latest([active, done, queued]) + expect(state.user?.id).toBe(MessageID.make("msg_queued")) + expect(state.finished?.id).toBe(MessageID.make("msg_done")) + expect(state.tasks).toEqual([part]) + }) + + test("processes projected tasks in queue order", () => { + const first = subtaskPart("msg_first") + const second = subtaskPart("msg_second") + const msgs = [created(user("msg_first", [first]), 1), created(user("msg_second", [second]), 2)] + + const state = KiloSessionMessageOrder.latest(msgs) + expect(state.user?.id).toBe(MessageID.make("msg_second")) + expect(state.tasks).toEqual([second, first]) + expect(state.tasks.pop()).toBe(first) + }) +}) + describe("KiloSessionPrompt.trimBeforeLastSummary", () => { test("returns input unchanged when no summary present", () => { const msgs = [user("msg_u1"), assistant("msg_a1", "msg_u1", [], { finish: "end_turn" })] @@ -228,6 +305,31 @@ describe("KiloSessionPrompt.trimBeforeLastSummary", () => { ]) }) + test("keeps the newest summary when retained history contains an older summary", () => { + const filtered = MessageV2.filterCompacted([ + created(assistant("msg_s8", "msg_c7", [], { summary: true, finish: "end_turn" }), 8), + created(user("msg_c7", [compactionPart("msg_c7", "msg_u1")]), 7), + created(assistant("msg_a6", "msg_u5", [], { finish: "end_turn" }), 6), + created(user("msg_u5"), 5), + created(assistant("msg_s4", "msg_c3", [], { summary: true, finish: "end_turn" }), 4), + created(user("msg_c3", [compactionPart("msg_c3", "msg_u1")]), 3), + created(assistant("msg_a2", "msg_u1", [], { finish: "end_turn" }), 2), + created(user("msg_u1"), 1), + ]) + + expect(filtered.map((m) => m.info.id)).toEqual([ + MessageID.make("msg_c7"), + MessageID.make("msg_s8"), + MessageID.make("msg_u1"), + MessageID.make("msg_a2"), + MessageID.make("msg_c3"), + MessageID.make("msg_s4"), + MessageID.make("msg_u5"), + MessageID.make("msg_a6"), + ]) + expect(KiloSessionPrompt.trimBeforeLastSummary(filtered)).toBe(filtered) + }) + test("ignores errored and unfinished summaries when choosing boundary", () => { const msgs = [ user("msg_u1"), From 48340fe2ec44d75c5210a0ebeaf18575f5935774 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 3 Jun 2026 11:20:56 +0200 Subject: [PATCH 06/14] fix(vscode): preserve inline review drafts across diff refreshes --- .changeset/steady-review-drafts.md | 5 + .../tests/unit/review-comments.test.ts | 92 ++++++++++++++++++ .../agent-manager/AgentManagerApp.tsx | 5 + .../webview-ui/agent-manager/DiffPanel.tsx | 57 +++++++++-- .../agent-manager/FullScreenDiffView.tsx | 53 ++++++++-- .../review-annotation-speech.tsx | 1 + .../agent-manager/review-annotations.ts | 97 ++++++++++++++++--- 7 files changed, 276 insertions(+), 34 deletions(-) create mode 100644 .changeset/steady-review-drafts.md diff --git a/.changeset/steady-review-drafts.md b/.changeset/steady-review-drafts.md new file mode 100644 index 00000000000..2d7d09f8f01 --- /dev/null +++ b/.changeset/steady-review-drafts.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Preserve unfinished inline review comments while diffs refresh. diff --git a/packages/kilo-vscode/tests/unit/review-comments.test.ts b/packages/kilo-vscode/tests/unit/review-comments.test.ts index f1e75a3bc94..c3602ab1cda 100644 --- a/packages/kilo-vscode/tests/unit/review-comments.test.ts +++ b/packages/kilo-vscode/tests/unit/review-comments.test.ts @@ -9,7 +9,12 @@ import { } from "../../webview-ui/agent-manager/review-comments" import { markdownCommentBlocks } from "../../webview-ui/agent-manager/markdown-comment-ranges" import { + buildFileAnnotations, + clearReviewComposer, + createReviewComposer, reviewAnnotationSpeechKey, + reviewComposerDraft, + reviewComposerEdit, reviewDraftSpeechKey, reviewEditSpeechKey, } from "../../webview-ui/agent-manager/review-annotations" @@ -288,6 +293,93 @@ describe("review annotation speech keys", () => { }) }) +// ── buildFileAnnotations composer metadata ───────────────────────────────── + +describe("buildFileAnnotations composer metadata", () => { + it("preserves unfinished draft text when an annotation is rebuilt", () => { + const draft = { file: "a.ts", side: "additions" as const, line: 2 } + const first = buildFileAnnotations("a.ts", [], null, draft, null, null) + if (!first.draftMeta) throw new Error("expected draft metadata") + first.draftMeta.text = "unfinished draft" + + const next = buildFileAnnotations("a.ts", [], null, draft, first.draftMeta, first.editMeta) + + expect(next.draftMeta).toBe(first.draftMeta) + expect(next.draftMeta?.text).toBe("unfinished draft") + }) + + it("creates fresh draft metadata when the anchor changes", () => { + const draft = { file: "a.ts", side: "additions" as const, line: 2 } + const first = buildFileAnnotations("a.ts", [], null, draft, null, null) + const next = buildFileAnnotations("a.ts", [], null, { ...draft, line: 3 }, first.draftMeta, first.editMeta) + + expect(next.draftMeta).not.toBe(first.draftMeta) + }) + + it("preserves unfinished edits when an annotation is rebuilt", () => { + const current = comment({ file: "a.ts", line: 2 }) + const first = buildFileAnnotations("a.ts", [current], current.id, null, null, null) + if (!first.editMeta) throw new Error("expected edit metadata") + first.editMeta.text = "unfinished edit" + + const next = buildFileAnnotations("a.ts", [current], current.id, null, first.draftMeta, first.editMeta) + + expect(next.editMeta).toBe(first.editMeta) + expect(next.editMeta?.text).toBe("unfinished edit") + }) + + it("creates fresh edit metadata when the edited comment changes", () => { + const firstComment = comment({ file: "a.ts", line: 2 }) + const secondComment = comment({ file: "a.ts", line: 3 }) + const first = buildFileAnnotations("a.ts", [firstComment], firstComment.id, null, null, null) + const next = buildFileAnnotations("a.ts", [secondComment], secondComment.id, null, first.draftMeta, first.editMeta) + + expect(next.editMeta).not.toBe(first.editMeta) + }) + + it("drops unfinished edit metadata after edit mode ends", () => { + const current = comment({ file: "a.ts", line: 2 }) + const first = buildFileAnnotations("a.ts", [current], current.id, null, null, null) + const next = buildFileAnnotations("a.ts", [current], null, null, first.draftMeta, first.editMeta) + + expect(next.editMeta).toBeNull() + }) + + it("hands draft and edit composers between review surfaces", () => { + const current = comment({ file: "a.ts", line: 2 }) + const composer = createReviewComposer() + const draft = { file: "a.ts", side: "additions" as const, line: 3 } + const first = buildFileAnnotations("a.ts", [current], current.id, draft, null, null) + if (!first.draftMeta || !first.editMeta) throw new Error("expected composer metadata") + first.draftMeta.text = "unfinished draft" + first.editMeta.text = "unfinished edit" + composer.draft = first.draftMeta + composer.edit = first.editMeta + + expect(reviewComposerDraft(composer)).toEqual(draft) + expect(reviewComposerEdit(composer)).toBe(current.id) + expect(composer.draft.text).toBe("unfinished draft") + expect(composer.edit.text).toBe("unfinished edit") + }) + + it("clears handed-off composers when the review context changes", () => { + const composer = createReviewComposer() + composer.draft = { type: "draft", comment: null, file: "a.ts", side: "additions", line: 2 } + composer.edit = { + type: "comment", + comment: comment({ file: "a.ts", line: 2 }), + file: "a.ts", + side: "additions", + line: 2, + } + + clearReviewComposer(composer) + + expect(reviewComposerDraft(composer)).toBeNull() + expect(reviewComposerEdit(composer)).toBeNull() + }) +}) + // ── getDirectory / getFilename ────────────────────────────────────────────── describe("getDirectory", () => { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 9dbfaa1b336..37425b8501d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -107,6 +107,7 @@ import { FullScreenDiffView } from "./FullScreenDiffView" import { ApplyDialog } from "./ApplyDialog" import { groupApplyConflicts } from "./apply-conflicts" import type { ReviewComment } from "./review-comments" +import { clearReviewComposer, createReviewComposer } from "./review-annotations" import { CurrentTabsMenu, createCurrentTabItems, focusCurrentTab } from "./CurrentTabsMenu" import { BranchSelect } from "../src/components/shared/BranchSelect" import { WorktreeItem } from "./WorktreeItem" @@ -246,6 +247,7 @@ const AgentManagerContent: Component = () => { const [reviewOpenByContext, setReviewOpenByContext] = createSignal>({}) const [reviewCommentsByContext, setReviewCommentsByContext] = createSignal>({}) + const reviewComposer = createReviewComposer() const [reviewActive, setReviewActive] = createSignal(false) const [reviewDiffStyle, setReviewDiffStyle] = createSignal<"unified" | "split">("unified") const markdown = createMarkdownRender(vscode) @@ -285,6 +287,7 @@ const AgentManagerContent: Component = () => { setPendingDelete(null) } createEffect(on(selection, () => cancelPendingDelete(), { defer: true })) + createEffect(on(selection, () => clearReviewComposer(reviewComposer), { defer: true })) onCleanup(() => clearTimeout(pendingDeleteTimer)) // Per-context tab memory: maps sidebar selection key -> last active session/pending ID @@ -3063,6 +3066,7 @@ const AgentManagerContent: Component = () => { onMarkdownRenderChange={markdown.update} comments={reviewComments()} onCommentsChange={setReviewCommentsForSelection} + composer={reviewComposer} onClose={() => setSidePanel(null)} onExpand={selection() !== null ? openReviewTab : undefined} onRequestDiff={requestDiffFile} @@ -3092,6 +3096,7 @@ const AgentManagerContent: Component = () => { sessionKey={diffSessionKey()} comments={reviewComments()} onCommentsChange={setReviewCommentsForSelection} + composer={reviewComposer} onSendAll={closeReviewTab} diffStyle={reviewDiffStyle()} onDiffStyleChange={setSharedDiffStyle} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx index ffba465c16a..c32ff8cf607 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx @@ -24,10 +24,16 @@ import { getDirectory, getFilename, lineCount, sanitizeReviewComments, type Revi import { buildFileAnnotations, buildReviewAnnotation, + clearReviewComposer, + createReviewComposer, + reviewComposerDraft, + reviewComposerEdit, reviewDraftSpeechKey, reviewEditSpeechKey, type AnnotationLabels, type AnnotationMeta, + type ReviewComposer, + type ReviewDraft, } from "./review-annotations" import { createReviewAnnotationSpeechRenderer } from "./review-annotation-speech" import { @@ -57,6 +63,7 @@ interface DiffPanelProps { onMarkdownRenderChange?: (render: boolean) => void comments: ReviewComment[] onCommentsChange: (comments: ReviewComment[]) => void + composer?: ReviewComposer onSendAll?: () => void onClose: () => void onExpand?: () => void @@ -90,11 +97,11 @@ export const DiffPanel: Component = (props) => { edit: t("common.edit"), delete: t("common.delete"), }) + const localComposer = createReviewComposer() + const composer = () => props.composer ?? localComposer const [open, setOpen] = createSignal([]) - const [draft, setDraft] = createSignal<{ file: string; side: AnnotationSide; line: number; endLine?: number } | null>( - null, - ) - const [editing, setEditing] = createSignal(null) + const [draft, setDraft] = createSignal(reviewComposerDraft(composer())) + const [editing, setEditing] = createSignal(reviewComposerEdit(composer())) const speechKeys = createMemo(() => { const keys = new Set() const current = draft() @@ -127,9 +134,10 @@ export const DiffPanel: Component = (props) => { const setComments = (next: ReviewComment[]) => props.onCommentsChange(next) const updateComments = (updater: (prev: ReviewComment[]) => ReviewComment[]) => setComments(updater(comments())) - // Stable draft metadata ref — avoids recreating the object on every signal read - // so pierre's annotation cache doesn't invalidate and destroy the textarea - let draftMeta: AnnotationMeta | null = null + // Stable composer metadata refs avoid recreating the object on every signal read + // so pierre's annotation cache doesn't invalidate and destroy the textarea. + let draftMeta: AnnotationMeta | null = composer().draft + let editMeta: AnnotationMeta | null = composer().edit // Ref to the scrollable container — used to preserve scroll position when // annotation changes cause pierre to fully re-render diffs @@ -171,6 +179,7 @@ export const DiffPanel: Component = (props) => { preserveScroll(() => { setDraft(null) draftMeta = null + composer().draft = null }) focusRoot() } @@ -214,7 +223,13 @@ export const DiffPanel: Component = (props) => { () => props.sessionKey, () => { requested.clear() + setDraft(null) + draftMeta = null + setEditing(null) + editMeta = null + clearReviewComposer(composer()) }, + { defer: true }, ), ) @@ -250,6 +265,7 @@ export const DiffPanel: Component = (props) => { updateComments((prev) => [...prev, { id, file, side, line, comment: text, selectedText }]) setDraft(null) draftMeta = null + composer().draft = null }) focusRoot() } @@ -258,6 +274,8 @@ export const DiffPanel: Component = (props) => { preserveScroll(() => { updateComments((prev) => prev.map((c) => (c.id === id ? { ...c, comment: text } : c))) setEditing(null) + editMeta = null + composer().edit = null }) focusRoot() } @@ -265,12 +283,20 @@ export const DiffPanel: Component = (props) => { const deleteComment = (id: string) => { preserveScroll(() => { updateComments((prev) => prev.filter((c) => c.id !== id)) - if (editing() === id) setEditing(null) + if (editing() === id) { + setEditing(null) + editMeta = null + composer().edit = null + } }) focusRoot() } const setEditState = (id: string | null) => { + if (editing() !== id) { + editMeta = null + composer().edit = null + } preserveScroll(() => setEditing(id)) if (id === null) focusRoot() } @@ -287,6 +313,8 @@ export const DiffPanel: Component = (props) => { const edit = editing() if (edit && !valid.some((comment) => comment.id === edit)) { setEditing(null) + editMeta = null + composer().edit = null } const currentDraft = draft() @@ -295,6 +323,7 @@ export const DiffPanel: Component = (props) => { if (!diff) { setDraft(null) draftMeta = null + composer().draft = null return } const content = currentDraft.side === "deletions" ? diff.before : diff.after @@ -302,11 +331,13 @@ export const DiffPanel: Component = (props) => { if (currentDraft.line < 1 || currentDraft.line > max) { setDraft(null) draftMeta = null + composer().draft = null return } if (currentDraft.endLine !== undefined && currentDraft.endLine > max) { setDraft(null) draftMeta = null + composer().draft = null } }, ), @@ -325,8 +356,11 @@ export const DiffPanel: Component = (props) => { }) const annotationsForFile = (file: string): DiffLineAnnotation[] => { - const result = buildFileAnnotations(file, commentsByFile().get(file) ?? [], editing(), draft(), draftMeta) + const result = buildFileAnnotations(file, commentsByFile().get(file) ?? [], editing(), draft(), draftMeta, editMeta) draftMeta = result.draftMeta + editMeta = result.editMeta + composer().draft = draft() ? draftMeta : null + composer().edit = editing() ? editMeta : null return result.annotations } @@ -356,7 +390,10 @@ export const DiffPanel: Component = (props) => { if (draft()) return const side: AnnotationSide = range.side === "deletions" ? "deletions" : "additions" preserveScroll(() => { - setDraft({ file, side, line: range.start, endLine: range.end }) + const next = { file, side, line: range.start, endLine: range.end } + draftMeta = { type: "draft", comment: null, ...next } + composer().draft = draftMeta + setDraft(next) }) } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx b/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx index 8920a04850c..f0365924ee2 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx @@ -32,10 +32,16 @@ import { getDirectory, getFilename, lineCount, sanitizeReviewComments, type Revi import { buildFileAnnotations, buildReviewAnnotation, + clearReviewComposer, + createReviewComposer, + reviewComposerDraft, + reviewComposerEdit, reviewDraftSpeechKey, reviewEditSpeechKey, type AnnotationLabels, type AnnotationMeta, + type ReviewComposer, + type ReviewDraft, } from "./review-annotations" import { createReviewAnnotationSpeechRenderer } from "./review-annotation-speech" import { @@ -60,6 +66,7 @@ interface FullScreenDiffViewProps { sessionKey?: string comments: ReviewComment[] onCommentsChange: (comments: ReviewComment[]) => void + composer?: ReviewComposer onSendAll?: () => void diffStyle: DiffStyle onDiffStyleChange: (style: DiffStyle) => void @@ -100,11 +107,11 @@ export const FullScreenDiffView: Component = (props) => edit: t("common.edit"), delete: t("common.delete"), }) + const localComposer = createReviewComposer() + const composer = () => props.composer ?? localComposer const [open, setOpen] = createSignal([]) - const [draft, setDraft] = createSignal<{ file: string; side: AnnotationSide; line: number; endLine?: number } | null>( - null, - ) - const [editing, setEditing] = createSignal(null) + const [draft, setDraft] = createSignal(reviewComposerDraft(composer())) + const [editing, setEditing] = createSignal(reviewComposerEdit(composer())) const speechKeys = createMemo(() => { const keys = new Set() const current = draft() @@ -123,7 +130,8 @@ export const FullScreenDiffView: Component = (props) => const [activeFile, setActiveFile] = createSignal(null) const [treeWidth, setTreeWidth] = createSignal(240) let nextId = 0 - let draftMeta: AnnotationMeta | null = null + let draftMeta: AnnotationMeta | null = composer().draft + let editMeta: AnnotationMeta | null = composer().edit // Tracks the session key for which initial open state has already run. When the // key changes (different worktree) we expand reviewable files. Within the same key, // only pruning happens so the user's manual collapse state is preserved. @@ -174,6 +182,7 @@ export const FullScreenDiffView: Component = (props) => preserveScroll(() => { setDraft(null) draftMeta = null + composer().draft = null }) focusRoot() } @@ -224,7 +233,13 @@ export const FullScreenDiffView: Component = (props) => () => props.sessionKey, () => { requested.clear() + setDraft(null) + draftMeta = null + setEditing(null) + editMeta = null + clearReviewComposer(composer()) }, + { defer: true }, ), ) @@ -260,6 +275,7 @@ export const FullScreenDiffView: Component = (props) => updateComments((prev) => [...prev, { id, file, side, line, comment: text, selectedText }]) setDraft(null) draftMeta = null + composer().draft = null }) focusRoot() } @@ -268,6 +284,8 @@ export const FullScreenDiffView: Component = (props) => preserveScroll(() => { updateComments((prev) => prev.map((c) => (c.id === id ? { ...c, comment: text } : c))) setEditing(null) + editMeta = null + composer().edit = null }) focusRoot() } @@ -275,12 +293,20 @@ export const FullScreenDiffView: Component = (props) => const deleteComment = (id: string) => { preserveScroll(() => { updateComments((prev) => prev.filter((c) => c.id !== id)) - if (editing() === id) setEditing(null) + if (editing() === id) { + setEditing(null) + editMeta = null + composer().edit = null + } }) focusRoot() } const setEditState = (id: string | null) => { + if (editing() !== id) { + editMeta = null + composer().edit = null + } preserveScroll(() => setEditing(id)) if (id === null) focusRoot() } @@ -302,6 +328,8 @@ export const FullScreenDiffView: Component = (props) => const edit = editing() if (edit && !valid.some((comment) => comment.id === edit)) { setEditing(null) + editMeta = null + composer().edit = null } const currentDraft = draft() @@ -310,6 +338,7 @@ export const FullScreenDiffView: Component = (props) => if (!diff) { setDraft(null) draftMeta = null + composer().draft = null return } const content = currentDraft.side === "deletions" ? diff.before : diff.after @@ -317,11 +346,13 @@ export const FullScreenDiffView: Component = (props) => if (currentDraft.line < 1 || currentDraft.line > max) { setDraft(null) draftMeta = null + composer().draft = null return } if (currentDraft.endLine !== undefined && currentDraft.endLine > max) { setDraft(null) draftMeta = null + composer().draft = null } }, ), @@ -340,8 +371,11 @@ export const FullScreenDiffView: Component = (props) => }) const annotationsForFile = (file: string): DiffLineAnnotation[] => { - const result = buildFileAnnotations(file, commentsByFile().get(file) ?? [], editing(), draft(), draftMeta) + const result = buildFileAnnotations(file, commentsByFile().get(file) ?? [], editing(), draft(), draftMeta, editMeta) draftMeta = result.draftMeta + editMeta = result.editMeta + composer().draft = draft() ? draftMeta : null + composer().edit = editing() ? editMeta : null return result.annotations } @@ -365,7 +399,10 @@ export const FullScreenDiffView: Component = (props) => if (draft()) return const side: AnnotationSide = range.side === "deletions" ? "deletions" : "additions" preserveScroll(() => { - setDraft({ file, side, line: range.start, endLine: range.end }) + const next = { file, side, line: range.start, endLine: range.end } + draftMeta = { type: "draft", comment: null, ...next } + composer().draft = draftMeta + setDraft(next) }) } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/review-annotation-speech.tsx b/packages/kilo-vscode/webview-ui/agent-manager/review-annotation-speech.tsx index 48c67f73d28..5ae72b627ed 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/review-annotation-speech.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/review-annotation-speech.tsx @@ -27,6 +27,7 @@ function insertReviewSpeechText(textarea: HTMLTextAreaElement, value: string): v textarea.value = result.text textarea.setSelectionRange(result.pos, result.pos) + textarea.dispatchEvent(new Event("input", { bubbles: true })) textarea.focus() } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/review-annotations.ts b/packages/kilo-vscode/webview-ui/agent-manager/review-annotations.ts index 380a501998e..ee36b917b9f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/review-annotations.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/review-annotations.ts @@ -24,6 +24,35 @@ export interface AnnotationMeta { line: number endLine?: number editing?: boolean + text?: string +} + +export type ReviewDraft = Pick + +export interface ReviewComposer { + draft: AnnotationMeta | null + edit: AnnotationMeta | null +} + +export function createReviewComposer(): ReviewComposer { + return { draft: null, edit: null } +} + +export function clearReviewComposer(composer: ReviewComposer): void { + composer.draft = null + composer.edit = null +} + +export function reviewComposerDraft(composer: ReviewComposer): ReviewDraft | null { + const draft = composer.draft + if (!draft || draft.type !== "draft") return null + return { file: draft.file, side: draft.side, line: draft.line, endLine: draft.endLine } +} + +export function reviewComposerEdit(composer: ReviewComposer): string | null { + const edit = composer.edit + if (!edit || edit.type !== "comment") return null + return edit.comment?.id ?? null } type SpeechDraft = Pick @@ -71,6 +100,14 @@ function focusWhenConnected(el: HTMLTextAreaElement): void { requestAnimationFrame(tick) } +// Keep composer text off the disposable annotation DOM without making each keystroke reactive. +function trackText(meta: AnnotationMeta, textarea: HTMLTextAreaElement, fallback = ""): void { + textarea.value = meta.text ?? fallback + textarea.addEventListener("input", () => { + meta.text = textarea.value + }) +} + function makeIcon(pathData: string): SVGSVGElement { const ns = "http://www.w3.org/2000/svg" const svg = document.createElementNS(ns, "svg") @@ -100,21 +137,48 @@ export function buildFileAnnotations( file: string, fileComments: ReviewComment[], edit: string | null, - draft: { file: string; side: AnnotationSide; line: number; endLine?: number } | null, + draft: ReviewDraft | null, draftMeta: AnnotationMeta | null, -): { annotations: DiffLineAnnotation[]; draftMeta: AnnotationMeta | null } { - const result: DiffLineAnnotation[] = fileComments.map((c) => ({ - side: c.side, - lineNumber: c.line, - metadata: { - type: "comment" as const, - comment: c, - file: c.file, - side: c.side, - line: c.line, - editing: c.id === edit, - }, - })) + editMeta: AnnotationMeta | null, +): { + annotations: DiffLineAnnotation[] + draftMeta: AnnotationMeta | null + editMeta: AnnotationMeta | null +} { + if (!edit) editMeta = null + const result: DiffLineAnnotation[] = fileComments.map((c) => { + if (c.id !== edit) { + return { + side: c.side, + lineNumber: c.line, + metadata: { + type: "comment" as const, + comment: c, + file: c.file, + side: c.side, + line: c.line, + }, + } + } + if ( + !editMeta || + editMeta.comment?.id !== c.id || + editMeta.file !== c.file || + editMeta.side !== c.side || + editMeta.line !== c.line + ) { + editMeta = { + type: "comment", + comment: c, + file: c.file, + side: c.side, + line: c.line, + editing: true, + } + } + editMeta.comment = c + return { side: c.side, lineNumber: c.line, metadata: editMeta } + }) if (draft && draft.file === file) { if ( @@ -135,7 +199,7 @@ export function buildFileAnnotations( } result.push({ side: draft.side, lineNumber: draft.line, metadata: draftMeta }) } - return { annotations: result, draftMeta } + return { annotations: result, draftMeta, editMeta } } export function buildReviewAnnotation( @@ -158,6 +222,7 @@ export function buildReviewAnnotation( textarea.className = "am-annotation-textarea" textarea.rows = 3 textarea.placeholder = handlers.labels.placeholder + trackText(meta, textarea) const actions = document.createElement("div") actions.className = "am-annotation-actions" @@ -226,7 +291,7 @@ export function buildReviewAnnotation( const textarea = document.createElement("textarea") textarea.className = "am-annotation-textarea" textarea.rows = 3 - textarea.value = comment.comment + trackText(meta, textarea, comment.comment) const actions = document.createElement("div") actions.className = "am-annotation-actions" From 2f7f23deac683078a350014ec8a1a946aae46ce4 Mon Sep 17 00:00:00 2001 From: Emilie Lima Schario <14057155+emilieschario@users.noreply.github.com> Date: Wed, 3 Jun 2026 06:05:39 -0400 Subject: [PATCH 07/14] Add scanning (#10799) * Add scanning * ci: adjust CodeQL scanning for the repo * ci: add bun in kotlin setup --------- Co-authored-by: Johnny Amancio --- .github/codeql/codeql-config.yml | 12 ++++ .github/workflows/codeql.yml | 117 +++++++++++++++++++++++++++++++ script/check-workflows.ts | 1 + 3 files changed, 130 insertions(+) create mode 100644 .github/codeql/codeql-config.yml create mode 100644 .github/workflows/codeql.yml diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 00000000000..a7b774a40b1 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,12 @@ +# kilocode_change - new file +name: Critical-only CodeQL config + +query-filters: + - include: + kind: + - problem + - path-problem + - alert + - path-alert + tags contain: security + security-severity: /^(9(\.[0-9])?|10(\.0)?)$/ diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000000..b37055f328e --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,117 @@ +# kilocode_change - new file +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL Advanced" + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: '29 10 * * 5' + workflow_dispatch: + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners (GitHub.com only) + # Consider using larger runners or machines with greater resources for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + - language: java-kotlin + build-mode: manual + - language: javascript-typescript + build-mode: none + # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + # Add any setup steps before running the `github/codeql-action/init` action. + # This includes steps like installing compilers or runtimes (`actions/setup-node` + # or others). This is typically only required for manual builds. + # - name: Setup runtime (example) + # uses: actions/setup-example@v1 + - name: Setup Bun + if: matrix.language == 'java-kotlin' + uses: ./.github/actions/setup-bun + + - name: Setup Java + if: matrix.language == 'java-kotlin' + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - name: Setup Gradle + if: matrix.language == 'java-kotlin' + uses: gradle/actions/setup-gradle@v4 + with: + cache-read-only: ${{ github.ref != 'refs/heads/main' }} + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + config-file: ./.github/codeql/codeql-config.yml + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - name: Build Java/Kotlin + if: matrix.language == 'java-kotlin' + shell: bash + run: ./gradlew typecheck + working-directory: packages/kilo-jetbrains + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{matrix.language}}" diff --git a/script/check-workflows.ts b/script/check-workflows.ts index 77176467e38..35b509e7add 100644 --- a/script/check-workflows.ts +++ b/script/check-workflows.ts @@ -36,6 +36,7 @@ const active = new Set([ "check-org-member.yml", "close-issues.yml", "close-stale-prs.yml", + "codeql.yml", "containers.yml", "docs-build.yml", "docs-check-links.yml", From a6b005dfede302731dcbb00ac74e744333db9104 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 3 Jun 2026 12:50:53 +0200 Subject: [PATCH 08/14] fix: restore cloud session previews --- .changeset/quiet-cloud-session-timeouts.md | 7 ++ packages/kilo-gateway/src/cloud-sessions.ts | 3 + .../kilo-gateway/test/cloud-sessions.test.ts | 48 ++++++++++ packages/kilo-vscode/script/local-bin.ts | 9 +- .../kilo-provider/handlers/cloud-session.ts | 15 ++- .../tests/unit/cloud-session-handler.test.ts | 92 +++++++++++++++++++ .../server/httpapi/groups/kilo-gateway.ts | 64 ++++++++----- .../kilocode/cloud-session-schema.test.ts | 38 ++++++++ packages/sdk/openapi.json | 13 +-- 9 files changed, 249 insertions(+), 40 deletions(-) create mode 100644 .changeset/quiet-cloud-session-timeouts.md create mode 100644 packages/kilo-gateway/test/cloud-sessions.test.ts create mode 100644 packages/kilo-vscode/tests/unit/cloud-session-handler.test.ts create mode 100644 packages/opencode/test/kilocode/cloud-session-schema.test.ts diff --git a/.changeset/quiet-cloud-session-timeouts.md b/.changeset/quiet-cloud-session-timeouts.md new file mode 100644 index 00000000000..eaa5659e08b --- /dev/null +++ b/.changeset/quiet-cloud-session-timeouts.md @@ -0,0 +1,7 @@ +--- +"@kilocode/cli": patch +"@kilocode/kilo-gateway": patch +"kilo-code": patch +--- + +Restore Cloud Agent transcripts in VS Code session previews and stop cloud session previews or continuation from loading indefinitely when a request stalls. diff --git a/packages/kilo-gateway/src/cloud-sessions.ts b/packages/kilo-gateway/src/cloud-sessions.ts index fd5e9d4c991..2752073db19 100644 --- a/packages/kilo-gateway/src/cloud-sessions.ts +++ b/packages/kilo-gateway/src/cloud-sessions.ts @@ -7,6 +7,7 @@ export interface DrizzleDb { } const INGEST_BASE = process.env.KILO_SESSION_INGEST_URL ?? "https://ingest.kilosessions.ai" +const TIMEOUT = 30_000 function exportUrl(sessionId: string) { return UUID_RE.test(sessionId) @@ -18,6 +19,7 @@ export type FetchResult = { ok: true; data: any } | { ok: false; status: number; export async function fetchCloudSession(token: string, sessionId: string): Promise { const response = await fetch(exportUrl(sessionId), { + signal: AbortSignal.timeout(TIMEOUT), headers: { Authorization: `Bearer ${token}`, ...buildKiloHeaders(), @@ -33,6 +35,7 @@ export async function fetchCloudSession(token: string, sessionId: string): Promi export async function fetchCloudSessionForImport(token: string, sessionId: string): Promise { const response = await fetch(exportUrl(sessionId), { + signal: AbortSignal.timeout(TIMEOUT), headers: { Authorization: `Bearer ${token}`, ...buildKiloHeaders(), diff --git a/packages/kilo-gateway/test/cloud-sessions.test.ts b/packages/kilo-gateway/test/cloud-sessions.test.ts new file mode 100644 index 00000000000..2c292bb5ef6 --- /dev/null +++ b/packages/kilo-gateway/test/cloud-sessions.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test" +import { fetchCloudSession, fetchCloudSessionForImport } from "../src/cloud-sessions" + +async function expectStalledFetchToTimeOut(run: () => Promise) { + const fetch = globalThis.fetch + const timeout = AbortSignal.timeout + let delay: number | undefined + + AbortSignal.timeout = (ms) => { + delay = ms + const controller = new AbortController() + queueMicrotask(() => controller.abort(new DOMException("The operation timed out", "TimeoutError"))) + return controller.signal + } + globalThis.fetch = ((_input: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }) + })) as typeof globalThis.fetch + + try { + const outcome = await Promise.race([ + run().then( + () => "resolved" as const, + (err) => { + if (err instanceof DOMException && err.name === "TimeoutError") return "timed-out" as const + throw err + }, + ), + Bun.sleep(50).then(() => "still-pending" as const), + ]) + + expect(outcome).toBe("timed-out") + expect(delay).toBe(30_000) + } finally { + globalThis.fetch = fetch + AbortSignal.timeout = timeout + } +} + +describe("cloud session export requests", () => { + test("times out a stalled preview request", async () => { + await expectStalledFetchToTimeOut(() => fetchCloudSession("token", "session-id")) + }) + + test("times out a stalled import request", async () => { + await expectStalledFetchToTimeOut(() => fetchCloudSessionForImport("token", "session-id")) + }) +}) diff --git a/packages/kilo-vscode/script/local-bin.ts b/packages/kilo-vscode/script/local-bin.ts index 951d0f8ce46..51c50e7c9b3 100644 --- a/packages/kilo-vscode/script/local-bin.ts +++ b/packages/kilo-vscode/script/local-bin.ts @@ -23,6 +23,7 @@ const kiloVscodeDir = join(import.meta.dir, "..") const packagesDir = join(kiloVscodeDir, "..") const opencodeDir = join(packagesDir, "opencode") const coreDir = join(packagesDir, "core") +const gatewayDir = join(packagesDir, "kilo-gateway") const indexingDir = join(packagesDir, "kilo-indexing") const targetBinDir = join(kiloVscodeDir, "bin") @@ -38,8 +39,12 @@ async function cliSourceHash(): Promise { try { const opencodeResult = await $`git log -1 --format=%H -- .`.cwd(opencodeDir).quiet() const coreResult = await $`git log -1 --format=%H -- .`.cwd(coreDir).quiet() + const gatewayResult = await $`git log -1 --format=%H -- .`.cwd(gatewayDir).quiet() const indexingResult = await $`git log -1 --format=%H -- .`.cwd(indexingDir).quiet() - return `${opencodeResult.text().trim()}-${coreResult.text().trim()}-${indexingResult.text().trim()}` || null + return ( + `${opencodeResult.text().trim()}-${coreResult.text().trim()}-${gatewayResult.text().trim()}-${indexingResult.text().trim()}` || + null + ) } catch { return null } @@ -49,10 +54,12 @@ async function isDirty(): Promise { try { const opencodeResult = await $`git status --porcelain -- .`.cwd(opencodeDir).quiet() const coreResult = await $`git status --porcelain -- .`.cwd(coreDir).quiet() + const gatewayResult = await $`git status --porcelain -- .`.cwd(gatewayDir).quiet() const indexingResult = await $`git status --porcelain -- .`.cwd(indexingDir).quiet() return ( opencodeResult.text().trim().length > 0 || coreResult.text().trim().length > 0 || + gatewayResult.text().trim().length > 0 || indexingResult.text().trim().length > 0 ) } catch { diff --git a/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts b/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts index 29ce097953d..f2cab56fab4 100644 --- a/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts +++ b/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts @@ -10,6 +10,8 @@ import type { CloudSessionData, EditorContext } from "../../services/cli-backend import { getErrorMessage, sessionToWebview, mapCloudSessionMessageToWebviewMessage } from "../../kilo-provider-utils" import type { MessageFile } from "../message-files" +const TIMEOUT = 30_000 + export interface CloudSessionContext { readonly client: KiloClient | null currentSession: Session | null @@ -73,7 +75,7 @@ export async function handleRequestCloudSessionData(ctx: CloudSessionContext, se } try { - const result = await ctx.client.kilo.cloud.session.get({ id: sessionId }) + const result = await ctx.client.kilo.cloud.session.get({ id: sessionId }, { signal: AbortSignal.timeout(TIMEOUT) }) const data = result.data as CloudSessionData | undefined if (!data) { ctx.postMessage({ @@ -135,10 +137,13 @@ export async function handleImportAndSend( // Step 1: Import the cloud session with fresh IDs let session: Session | undefined try { - const result = await ctx.client.kilo.cloud.session.import({ - sessionId: cloudSessionId, - directory: dir, - }) + const result = await ctx.client.kilo.cloud.session.import( + { + sessionId: cloudSessionId, + directory: dir, + }, + { signal: AbortSignal.timeout(TIMEOUT) }, + ) session = result.data as Session | undefined } catch (error) { console.error("[Kilo New] KiloProvider: ❌ Cloud session import failed:", error) diff --git a/packages/kilo-vscode/tests/unit/cloud-session-handler.test.ts b/packages/kilo-vscode/tests/unit/cloud-session-handler.test.ts new file mode 100644 index 00000000000..accf86ee0a3 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/cloud-session-handler.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "bun:test" +import { + handleImportAndSend, + handleRequestCloudSessionData, + type CloudSessionContext, +} from "../../src/kilo-provider/handlers/cloud-session" + +function stalled(options?: { signal?: AbortSignal }) { + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener("abort", () => reject(options.signal?.reason), { once: true }) + }) +} + +function context(sent: unknown[]) { + return { + client: { + kilo: { + cloud: { + session: { + get: (_params: { id: string }, options?: { signal?: AbortSignal }) => stalled(options), + import: (_params: { sessionId: string; directory: string }, options?: { signal?: AbortSignal }) => + stalled(options), + }, + }, + }, + }, + currentSession: null, + trackedSessionIds: new Set(), + connectionService: { recordMessageSessionId: () => undefined }, + postMessage: (message: unknown) => sent.push(message), + getWorkspaceDirectory: () => "/repo", + gatherEditorContext: async () => ({}), + } as unknown as CloudSessionContext +} + +describe("cloud session preview handler", () => { + it("reports a failure when the CLI preview request stalls", async () => { + const timeout = AbortSignal.timeout + AbortSignal.timeout = () => { + const controller = new AbortController() + queueMicrotask(() => controller.abort(new DOMException("The operation timed out", "TimeoutError"))) + return controller.signal + } + + try { + const sent: unknown[] = [] + const outcome = await Promise.race([ + handleRequestCloudSessionData(context(sent), "cloud-session").then(() => "resolved" as const), + Bun.sleep(50).then(() => "still-pending" as const), + ]) + + expect(outcome).toBe("resolved") + expect(sent).toEqual([ + { + type: "cloudSessionImportFailed", + cloudSessionId: "cloud-session", + error: "The operation timed out", + }, + ]) + } finally { + AbortSignal.timeout = timeout + } + }) + + it("reports a failure when the CLI import request stalls", async () => { + const timeout = AbortSignal.timeout + AbortSignal.timeout = () => { + const controller = new AbortController() + queueMicrotask(() => controller.abort(new DOMException("The operation timed out", "TimeoutError"))) + return controller.signal + } + + try { + const sent: unknown[] = [] + const outcome = await Promise.race([ + handleImportAndSend(context(sent), "cloud-session", "Continue").then(() => "resolved" as const), + Bun.sleep(50).then(() => "still-pending" as const), + ]) + + expect(outcome).toBe("resolved") + expect(sent).toEqual([ + { + type: "cloudSessionImportFailed", + cloudSessionId: "cloud-session", + error: "The operation timed out", + }, + ]) + } finally { + AbortSignal.timeout = timeout + } + }) +}) diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts b/packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts index 79112d93b27..92012557e81 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts @@ -188,35 +188,49 @@ export const TranscriptionResponse = Schema.Struct({ usage: Schema.optional(Schema.Unknown), }) -export const CloudMessage = Schema.Struct({ - info: Schema.Struct({ - id: Schema.String, - sessionID: Schema.String, - role: Schema.Literals(["user", "assistant"]), - time: Schema.Struct({ - created: Schema.Finite, - completed: Schema.optional(Schema.Finite), - }), +const UnknownRecord = Schema.Record(Schema.String, Schema.Unknown) + +export const CloudMessage = Schema.StructWithRest( + Schema.Struct({ + info: Schema.StructWithRest( + Schema.Struct({ + id: Schema.String, + sessionID: Schema.String, + role: Schema.Literals(["user", "assistant"]), + time: Schema.Struct({ + created: Schema.Finite, + completed: Schema.optional(Schema.Finite), + }), + }), + [UnknownRecord], + ), + parts: Schema.Array( + Schema.StructWithRest( + Schema.Struct({ + id: Schema.String, + sessionID: Schema.String, + messageID: Schema.String, + type: Schema.String, + }), + [UnknownRecord], + ), + ), }), - parts: Schema.Array( - Schema.Struct({ - id: Schema.String, - sessionID: Schema.String, - messageID: Schema.String, - type: Schema.String, - }), - ), -}) + [UnknownRecord], +) export const CloudSessionData = Schema.Struct({ - info: Schema.Struct({ - id: Schema.String, - title: Schema.String, - time: Schema.Struct({ - created: Schema.Finite, - updated: Schema.Finite, + info: Schema.StructWithRest( + Schema.Struct({ + id: Schema.String, + title: Schema.String, + time: Schema.Struct({ + created: Schema.Finite, + updated: Schema.Finite, + }), }), - }), + [UnknownRecord], + ), messages: Schema.Array(CloudMessage), }) diff --git a/packages/opencode/test/kilocode/cloud-session-schema.test.ts b/packages/opencode/test/kilocode/cloud-session-schema.test.ts new file mode 100644 index 00000000000..c45642af9e7 --- /dev/null +++ b/packages/opencode/test/kilocode/cloud-session-schema.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { CloudSessionData } from "../../src/kilocode/server/httpapi/groups/kilo-gateway" + +describe("cloud session HTTP schema", () => { + test("preserves transcript fields needed by the VS Code preview", () => { + const input = { + info: { + id: "ses_cloud", + title: "Cloud transcript", + slug: "cloud-transcript", + time: { created: 1, updated: 2 }, + }, + messages: [ + { + info: { + id: "msg_user", + sessionID: "ses_cloud", + role: "user" as const, + agent: "code", + time: { created: 3 }, + }, + parts: [ + { + id: "prt_text", + sessionID: "ses_cloud", + messageID: "msg_user", + type: "text", + text: "Show this cloud message", + }, + ], + }, + ], + } + + expect(Schema.encodeUnknownSync(CloudSessionData)(input)).toEqual(input) + }) +}) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 6f845c28f5c..66750773bfb 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -12025,8 +12025,7 @@ "additionalProperties": false } }, - "required": ["id", "title", "time"], - "additionalProperties": false + "required": ["id", "title", "time"] }, "messages": { "type": "array", @@ -12060,8 +12059,7 @@ "additionalProperties": false } }, - "required": ["id", "sessionID", "role", "time"], - "additionalProperties": false + "required": ["id", "sessionID", "role", "time"] }, "parts": { "type": "array", @@ -12081,13 +12079,11 @@ "type": "string" } }, - "required": ["id", "sessionID", "messageID", "type"], - "additionalProperties": false + "required": ["id", "sessionID", "messageID", "type"] } } }, - "required": ["info", "parts"], - "additionalProperties": false + "required": ["info", "parts"] } } }, @@ -12170,7 +12166,6 @@ } }, "required": ["id", "title", "time"], - "additionalProperties": false, "description": "Imported session info" } } From 99407ef83bf2c247578a5b9bcf625bedcec2cfa7 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 3 Jun 2026 13:18:18 +0200 Subject: [PATCH 09/14] fix(ci): cap stalled unit jobs at 45 minutes --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a90022ec66e..c9301c042d3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,6 +32,7 @@ jobs: - name: windows host: blacksmith-4vcpu-windows-2025 # kilocode_change runs-on: ${{ matrix.settings.host }} + timeout-minutes: 45 # kilocode_change defaults: run: shell: bash From cdf46c97354630e2f1b392092ee0ffcc18b19640 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Wed, 3 Jun 2026 13:19:51 +0200 Subject: [PATCH 10/14] fix(vscode): use brain circuit for data disclosure (#10847) --- .changeset/brain-circuit-data-disclosure.md | 6 ++++++ .../client/session/ui/model/ModelPicker.kt | 3 +-- .../client/session/ui/model/ModelPickerIcons.kt | 8 ++++++++ .../session/ui/model/ModelPickerRenderer.kt | 2 +- .../src/main/resources/icons/brain-circuit.svg | 15 +++++++++++++++ .../main/resources/icons/brain-circuit_dark.svg | 15 +++++++++++++++ .../client/session/ui/model/ModelPickerTest.kt | 2 +- packages/kilo-ui/src/components/icon.tsx | 4 ++++ .../tests/unit/model-preview-data-line.test.ts | 13 ++++++++++++- .../agent-manager/MultiModelSelector.tsx | 2 +- .../src/components/shared/ModelPreview.tsx | 4 ++-- .../src/components/shared/ModelSelector.tsx | 4 ++-- 12 files changed, 68 insertions(+), 10 deletions(-) create mode 100644 .changeset/brain-circuit-data-disclosure.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerIcons.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/brain-circuit.svg create mode 100644 packages/kilo-jetbrains/frontend/src/main/resources/icons/brain-circuit_dark.svg diff --git a/.changeset/brain-circuit-data-disclosure.md b/.changeset/brain-circuit-data-disclosure.md new file mode 100644 index 00000000000..672b15b754c --- /dev/null +++ b/.changeset/brain-circuit-data-disclosure.md @@ -0,0 +1,6 @@ +--- +"kilo-code": patch +"@kilocode/kilo-ui": patch +--- + +Use a brain circuit icon for free-model data collection disclosures. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt index fbfa26ae729..42f02472629 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt @@ -3,7 +3,6 @@ package ai.kilocode.client.session.ui.model import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.ui.PickerButton import ai.kilocode.rpc.dto.ModelSelectionDto -import com.intellij.icons.AllIcons import com.intellij.openapi.ui.popup.JBPopup import com.intellij.openapi.ui.popup.JBPopupFactory import com.intellij.openapi.ui.popup.PopupShowOptions @@ -109,7 +108,7 @@ class ModelPicker : PickerButton() { val item = selected ?: items.firstOrNull() val display = item?.display ?: "" text = "${ModelText.sanitize(display)} ▴" - icon = if (item?.let(ModelText::collectsData) == true) AllIcons.General.Warning else null + icon = if (item?.let(ModelText::collectsData) == true) ModelPickerIcons.DATA_COLLECTED else null horizontalTextPosition = SwingConstants.LEFT iconTextGap = JBUI.CurrentTheme.ActionsList.elementIconGap() toolTipText = if (item?.let(ModelText::collectsData) == true) ModelText.dataCollected() else KiloBundle.message("model.picker.tooltip") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerIcons.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerIcons.kt new file mode 100644 index 00000000000..4b4e247b149 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerIcons.kt @@ -0,0 +1,8 @@ +package ai.kilocode.client.session.ui.model + +import com.intellij.openapi.util.IconLoader +import javax.swing.Icon + +internal object ModelPickerIcons { + val DATA_COLLECTED: Icon = IconLoader.getIcon("/icons/brain-circuit.svg", ModelPickerIcons::class.java) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRenderer.kt index e6d52020820..0cef64e7b22 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRenderer.kt @@ -69,7 +69,7 @@ internal class ModelPickerRenderer( ModelText.freeBg(), JBColor.namedColor("Kilo.ModelPicker.freeBadgeForeground", JBColor.WHITE), ) - private val warn = JBLabel(AllIcons.General.Warning).apply { + private val warn = JBLabel(ModelPickerIcons.DATA_COLLECTED).apply { toolTipText = ModelText.dataCollected() } private val provider = JBLabel() diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/brain-circuit.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/brain-circuit.svg new file mode 100644 index 00000000000..f9d4f1ea8a9 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/brain-circuit.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/brain-circuit_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/brain-circuit_dark.svg new file mode 100644 index 00000000000..361fedadf0a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/brain-circuit_dark.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/model/ModelPickerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/model/ModelPickerTest.kt index f5e063ee5e7..03b24724ae0 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/model/ModelPickerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/model/ModelPickerTest.kt @@ -159,7 +159,7 @@ class ModelPickerTest : BasePlatformTestCase() { picker.setItems(listOf(item("auto", "Auto Free", "kilo", "Kilo", free = true))) assertFalse(picker.text.contains("Data collected")) - assertSame(AllIcons.General.Warning, picker.icon) + assertSame(ModelPickerIcons.DATA_COLLECTED, picker.icon) assertEquals("Data collected", picker.toolTipText) } diff --git a/packages/kilo-ui/src/components/icon.tsx b/packages/kilo-ui/src/components/icon.tsx index ff3335bea0a..69c1dd7f546 100644 --- a/packages/kilo-ui/src/components/icon.tsx +++ b/packages/kilo-ui/src/components/icon.tsx @@ -2,6 +2,10 @@ import { Icon as Upstream, type IconProps as Props } from "@opencode-ai/ui/icon" import { splitProps } from "solid-js" const icons: Record = { + "brain-circuit": { + viewBox: "0 0 24 24", + path: ``, + }, "circuit-board": { viewBox: "0 0 16 16", path: ``, diff --git a/packages/kilo-vscode/tests/unit/model-preview-data-line.test.ts b/packages/kilo-vscode/tests/unit/model-preview-data-line.test.ts index 115a906d62b..f470bf32920 100644 --- a/packages/kilo-vscode/tests/unit/model-preview-data-line.test.ts +++ b/packages/kilo-vscode/tests/unit/model-preview-data-line.test.ts @@ -4,6 +4,9 @@ import path from "node:path" const root = path.resolve(import.meta.dir, "../..") const preview = fs.readFileSync(path.join(root, "webview-ui/src/components/shared/ModelPreview.tsx"), "utf8") +const selector = fs.readFileSync(path.join(root, "webview-ui/src/components/shared/ModelSelector.tsx"), "utf8") +const agent = fs.readFileSync(path.join(root, "webview-ui/agent-manager/MultiModelSelector.tsx"), "utf8") +const icons = fs.readFileSync(path.join(root, "../kilo-ui/src/components/icon.tsx"), "utf8") const styles = fs.readFileSync(path.join(root, "webview-ui/src/styles/model-selector.css"), "utf8") describe("model preview data collection line", () => { @@ -13,9 +16,17 @@ describe("model preview data collection line", () => { expect(data).toBeGreaterThanOrEqual(0) expect(context).toBeGreaterThan(data) - expect(preview).toContain('Icon name="warning"') + expect(preview).toContain('Icon name="brain-circuit"') expect(preview).toContain("isDataCollectedModel(model())") expect(preview).toContain('language.t("model.tag.dataCollected")') expect(styles).toContain(".model-preview-data-line") }) + + it("uses the brain circuit icon for all webview model data disclosures", () => { + expect(selector).toContain('Icon name="brain-circuit"') + expect(selector).not.toContain('Icon name="warning"') + expect(agent).toContain('Icon name="brain-circuit"') + expect(agent).not.toContain('Icon name="warning"') + expect(icons).toContain('"brain-circuit"') + }) }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/MultiModelSelector.tsx b/packages/kilo-vscode/webview-ui/agent-manager/MultiModelSelector.tsx index 7f9bd0fece8..19ba53c7e6f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/MultiModelSelector.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/MultiModelSelector.tsx @@ -121,7 +121,7 @@ export const MultiModelSelector: Component<{ - + diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModelPreview.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModelPreview.tsx index f09dcbab6cd..6f8838122a3 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModelPreview.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModelPreview.tsx @@ -96,7 +96,7 @@ export const ModelPreview: Component = (props) => { - + @@ -132,7 +132,7 @@ export const ModelPreview: Component = (props) => { - + - {dataLabel()} diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx index 72d2def4ec8..7a22b1ddab5 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx @@ -636,7 +636,7 @@ export const ModelSelectorBase: Component = (props) => { - + @@ -836,7 +836,7 @@ export const ModelSelectorBase: Component = (props) => { - + From 02f226d418f4b926b066a648cfc098054af0672d Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 3 Jun 2026 13:23:24 +0200 Subject: [PATCH 11/14] chore(ci): remove workflow change marker --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c9301c042d3..1d1ba42008e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,7 +32,7 @@ jobs: - name: windows host: blacksmith-4vcpu-windows-2025 # kilocode_change runs-on: ${{ matrix.settings.host }} - timeout-minutes: 45 # kilocode_change + timeout-minutes: 45 defaults: run: shell: bash From a0c91eb07ab4d29aa170148c714756ee9286dc86 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 3 Jun 2026 13:25:36 +0200 Subject: [PATCH 12/14] fix(ci): restore workflow change annotation --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1d1ba42008e..c9301c042d3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,7 +32,7 @@ jobs: - name: windows host: blacksmith-4vcpu-windows-2025 # kilocode_change runs-on: ${{ matrix.settings.host }} - timeout-minutes: 45 + timeout-minutes: 45 # kilocode_change defaults: run: shell: bash From 9c561074b624925d14ee0e7d9e64d0a6f5958531 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Wed, 3 Jun 2026 08:58:17 -0300 Subject: [PATCH 13/14] feat(cli): add animated console loading screens --- .changeset/console-loading-logo.md | 5 ++ bun.lock | 3 ++ packages/kilo-console/package.json | 1 + packages/kilo-console/public/logo.lottie | Bin 0 -> 32127 bytes .../src/components/LoadingLogo.tsx | 34 +++++++++++++ .../src/components/LoadingScreen.tsx | 20 ++++++++ .../kilo-console/src/layouts/ConfigLayout.tsx | 5 +- .../routes/projects/ProjectConsoleRoute.tsx | 9 ++-- .../src/routes/projects/ProjectsRoute.tsx | 9 ++-- packages/kilo-console/src/styles.css | 1 + packages/kilo-console/src/styles/loading.css | 46 ++++++++++++++++++ 11 files changed, 118 insertions(+), 15 deletions(-) create mode 100644 .changeset/console-loading-logo.md create mode 100644 packages/kilo-console/public/logo.lottie create mode 100644 packages/kilo-console/src/components/LoadingLogo.tsx create mode 100644 packages/kilo-console/src/components/LoadingScreen.tsx create mode 100644 packages/kilo-console/src/styles/loading.css diff --git a/.changeset/console-loading-logo.md b/.changeset/console-loading-logo.md new file mode 100644 index 00000000000..d39b44da157 --- /dev/null +++ b/.changeset/console-loading-logo.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Show the animated Kilo logo while the console and dashboard finish loading. diff --git a/bun.lock b/bun.lock index 3938c57547f..a921861a7d9 100644 --- a/bun.lock +++ b/bun.lock @@ -85,6 +85,7 @@ "dependencies": { "@kilocode/kilo-web-ui": "workspace:*", "@kilocode/sdk": "workspace:*", + "@lottiefiles/dotlottie-web": "0.74.0", "@opencode-ai/ui": "workspace:*", "@solidjs/router": "catalog:", "ghostty-web": "0.4.0", @@ -1354,6 +1355,8 @@ "@leichtgewicht/ip-codec": ["@leichtgewicht/ip-codec@2.0.5", "", {}, "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw=="], + "@lottiefiles/dotlottie-web": ["@lottiefiles/dotlottie-web@0.74.0", "", {}, "sha512-rG12+dJSVhQDdleGr9epR7zU/UJ6hh8jzBDHAurClHP5t5dSrOh3eP3UCFxolpVPcobaRVYsBnYg4HQokQewpg=="], + "@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="], "@lydell/node-pty": ["@lydell/node-pty@1.2.0-beta.10", "", { "optionalDependencies": { "@lydell/node-pty-darwin-arm64": "1.2.0-beta.10", "@lydell/node-pty-darwin-x64": "1.2.0-beta.10", "@lydell/node-pty-linux-arm64": "1.2.0-beta.10", "@lydell/node-pty-linux-x64": "1.2.0-beta.10", "@lydell/node-pty-win32-arm64": "1.2.0-beta.10", "@lydell/node-pty-win32-x64": "1.2.0-beta.10" } }, "sha512-Fv+A3+MZVA8qhkBIZsM1E6dCdHNMyXXz22mAYiMWd03LlyK///F3OH6CKPX9mj4id7LUlxpr45yPzyBVy9aDPw=="], diff --git a/packages/kilo-console/package.json b/packages/kilo-console/package.json index 45539070c38..74cefc973bc 100644 --- a/packages/kilo-console/package.json +++ b/packages/kilo-console/package.json @@ -13,6 +13,7 @@ "dependencies": { "@kilocode/kilo-web-ui": "workspace:*", "@kilocode/sdk": "workspace:*", + "@lottiefiles/dotlottie-web": "0.74.0", "@opencode-ai/ui": "workspace:*", "@solidjs/router": "catalog:", "ghostty-web": "0.4.0", diff --git a/packages/kilo-console/public/logo.lottie b/packages/kilo-console/public/logo.lottie new file mode 100644 index 0000000000000000000000000000000000000000..d16c02faf118d5acfd49a1537a4a48dbe3552d38 GIT binary patch literal 32127 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 literal 0 HcmV?d00001 diff --git a/packages/kilo-console/src/components/LoadingLogo.tsx b/packages/kilo-console/src/components/LoadingLogo.tsx new file mode 100644 index 00000000000..6e261e69fab --- /dev/null +++ b/packages/kilo-console/src/components/LoadingLogo.tsx @@ -0,0 +1,34 @@ +import { DotLottie } from "@lottiefiles/dotlottie-web" +import { onCleanup, onMount } from "solid-js" + +const src = `${import.meta.env.BASE_URL}logo.lottie` + +export function LoadingLogo(props: { class?: string }) { + let canvas: HTMLCanvasElement | undefined + + onMount(() => { + if (!canvas) return + + const motion = !window.matchMedia("(prefers-reduced-motion: reduce)").matches + const player = new DotLottie({ + autoplay: motion, + canvas, + loop: motion, + src, + renderConfig: { + autoResize: true, + }, + }) + + onCleanup(() => player.destroy()) + }) + + return ( + (canvas = node)} + class={`console-loading-logo${props.class ? ` ${props.class}` : ""}`} + role="img" + aria-label="Kilo loading animation" + /> + ) +} diff --git a/packages/kilo-console/src/components/LoadingScreen.tsx b/packages/kilo-console/src/components/LoadingScreen.tsx new file mode 100644 index 00000000000..e1ffc181d0f --- /dev/null +++ b/packages/kilo-console/src/components/LoadingScreen.tsx @@ -0,0 +1,20 @@ +import { LoadingLogo } from "./LoadingLogo" + +type Variant = "fullscreen" | "content" + +export function LoadingScreen(props: { variant: Variant }) { + return ( +
+ +
+ ) +} diff --git a/packages/kilo-console/src/layouts/ConfigLayout.tsx b/packages/kilo-console/src/layouts/ConfigLayout.tsx index 09ce8141bd8..8f779b93283 100644 --- a/packages/kilo-console/src/layouts/ConfigLayout.tsx +++ b/packages/kilo-console/src/layouts/ConfigLayout.tsx @@ -1,6 +1,7 @@ import { Show } from "solid-js" import type { JSX } from "solid-js" import { Card } from "@kilocode/kilo-web-ui/card" +import { LoadingScreen } from "../components/LoadingScreen" import { ConfigProvider } from "../context/ConfigProvider" import { useConfig } from "../context/config" import { ConfigSidebar } from "../routes/config/ConfigSidebar" @@ -43,9 +44,7 @@ function ConfigContent(props: { children?: JSX.Element }) { - + {props.children} diff --git a/packages/kilo-console/src/routes/projects/ProjectConsoleRoute.tsx b/packages/kilo-console/src/routes/projects/ProjectConsoleRoute.tsx index 294b1ca2e07..7f576e7fb60 100644 --- a/packages/kilo-console/src/routes/projects/ProjectConsoleRoute.tsx +++ b/packages/kilo-console/src/routes/projects/ProjectConsoleRoute.tsx @@ -2,6 +2,7 @@ import { A, useLocation, useParams } from "@solidjs/router" import { createEffect, createMemo, createResource, createSignal, For, onCleanup, Show } from "solid-js" import { Card } from "@kilocode/kilo-web-ui/card" import { Icon } from "@kilocode/kilo-web-ui/icon" +import { LoadingScreen } from "../../components/LoadingScreen" import { createProjectPty, createProjectWorktree, @@ -677,14 +678,10 @@ export function ProjectConsoleRoute() {
- + - +