feat(jetbrains): add native session history

This commit is contained in:
kirillk
2026-05-06 13:04:18 -04:00
parent 88563fa672
commit e8f570cc75
15 changed files with 552 additions and 89 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Add a native session history panel to the JetBrains plugin.
@@ -1,5 +1,6 @@
package ai.kilocode.client
import ai.kilocode.client.actions.HistoryAction
import ai.kilocode.client.actions.NewSessionAction
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.app.Workspace
@@ -65,7 +66,7 @@ class KiloToolWindowFactory : ToolWindowFactory, DumbAware {
manager.newSession()
ActionManager.getInstance().getAction("Kilo.Settings")?.let { settings ->
toolWindow.setTitleActions(listOf(NewSessionAction(), settings))
toolWindow.setTitleActions(listOf(NewSessionAction(), HistoryAction(), settings))
}
} catch (e: Exception) {
LOG.error("Failed to set up Kilo tool window content", e)
@@ -0,0 +1,22 @@
package ai.kilocode.client.actions
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.SessionManager
import com.intellij.icons.AllIcons
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.project.DumbAware
class HistoryAction : AnAction(
KiloBundle.message("action.Kilo.History.text"),
KiloBundle.message("action.Kilo.History.description"),
AllIcons.Vcs.History,
), DumbAware {
override fun actionPerformed(e: AnActionEvent) {
e.getData(SessionManager.KEY)?.showHistory()
}
override fun update(e: AnActionEvent) {
e.presentation.isEnabled = e.getData(SessionManager.KEY) != null
}
}
@@ -10,5 +10,7 @@ interface SessionManager {
fun newSession()
fun showHistory()
fun openSession(session: SessionDto)
}
@@ -1,13 +1,17 @@
package ai.kilocode.client.session
import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.app.Workspace
import ai.kilocode.client.session.history.HistoryController
import ai.kilocode.client.session.history.HistoryPanel
import ai.kilocode.rpc.dto.SessionDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.actionSystem.DataProvider
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import kotlinx.coroutines.cancel
import java.awt.BorderLayout
import javax.swing.JComponent
import javax.swing.JPanel
@@ -19,6 +23,7 @@ class SessionSidePanelManager(
service<SessionUiFactory>().create(project, workspace, manager, id, loading)
},
private val resolve: (String) -> Workspace = { dir -> service<KiloWorkspaceService>().workspace(dir) },
private val history: ((Disposable, (SessionDto) -> Unit, (String) -> Unit) -> JComponent)? = null,
) : SessionManager, Disposable {
val component: JPanel = object : JPanel(BorderLayout()), DataProvider {
override fun getData(dataId: String): Any? {
@@ -30,6 +35,7 @@ class SessionSidePanelManager(
private val opened = mutableMapOf<String, SessionUi>()
private val all = mutableSetOf<SessionUi>()
private var current: SessionUi? = null
private var panel: JComponent? = null
val defaultFocusedComponent: JComponent? get() = current?.defaultFocusedComponent
@@ -50,6 +56,41 @@ class SessionSidePanelManager(
show(ui)
}
override fun showHistory() {
register(current)
release(current)
val view = panel ?: createHistory().also { panel = it }
if (current == null && component.componentCount == 1 && component.getComponent(0) === view) return
current = null
component.removeAll()
component.add(view, BorderLayout.CENTER)
component.revalidate()
component.repaint()
}
private fun createHistory(): JComponent {
val custom = history
if (custom != null) return custom(this, this::openSession, this::removeSession)
val factory = service<SessionUiFactory>()
val cs = factory.scope()
val controller = HistoryController(
sessions = project.service<KiloSessionService>(),
workspace = root,
cs = cs,
open = { item -> item.local?.let(this::openSession) },
deleted = this::removeSession,
)
Disposer.register(this) { cs.cancel() }
return HistoryPanel(this, controller).component
}
private fun removeSession(id: String) {
val ui = opened.remove(id) ?: return
all.remove(ui)
if (current === ui) current = null
Disposer.dispose(ui)
}
private fun show(ui: SessionUi) {
all.add(ui)
if (current === ui) return
@@ -31,7 +31,7 @@ class SessionUiFactory(
open = manager::openSession,
)
private fun scope(): CoroutineScope {
fun scope(): CoroutineScope {
val parent = cs.coroutineContext[Job]
return CoroutineScope(cs.coroutineContext + SupervisorJob(parent))
}
@@ -14,6 +14,7 @@ class HistoryController(
private val workspace: Workspace,
private val cs: CoroutineScope,
private val open: (HistoryItem) -> Unit = {},
private val deleted: (String) -> Unit = {},
) {
companion object {
const val CLOUD_LIMIT = 50
@@ -28,7 +29,7 @@ class HistoryController(
cs.launch {
try {
val result = sessions.list(workspace.directory)
val items = result.sessions.map(::localItem)
val items = HistoryTime.sorted(result.sessions.map(::localItem))
edt { model.setLocal(items) }
} catch (e: Exception) {
edt { model.error(HistorySource.LOCAL, e.message ?: KiloBundle.message("history.error.local")) }
@@ -37,13 +38,14 @@ class HistoryController(
}
fun loadCloud(reset: Boolean = true, gitUrl: String? = null) {
if (reset) this.gitUrl = gitUrl
val cursor = if (reset) null else model.cursor
val url = if (reset) gitUrl else this.gitUrl
if (reset) this.gitUrl = gitUrl
edt { model.startCloud(reset) }
cs.launch {
try {
val result = sessions.cloudSessions(workspace.directory, cursor, CLOUD_LIMIT, this@HistoryController.gitUrl)
val items = result.sessions.map(::cloudItem)
val result = sessions.cloudSessions(workspace.directory, cursor, CLOUD_LIMIT, url)
val items = HistoryTime.sorted(result.sessions.map(::cloudItem))
edt { model.setCloud(items, result.nextCursor, append = !reset) }
} catch (e: Exception) {
edt { model.error(HistorySource.CLOUD, e.message ?: KiloBundle.message("history.error.cloud")) }
@@ -69,7 +71,10 @@ class HistoryController(
cs.launch {
try {
sessions.deleteSession(item.id, item.directory ?: workspace.directory)
edt { model.deleted(item.id) }
edt {
model.deleted(item.id)
deleted(item.id)
}
} catch (e: Exception) {
edt { model.error(HistorySource.LOCAL, e.message ?: KiloBundle.message("history.error.local.delete")) }
}
@@ -77,6 +82,7 @@ class HistoryController(
}
fun open(item: HistoryItem) {
if (item.source != HistorySource.LOCAL) return
edt { open(item) }
}
}
@@ -1,23 +1,89 @@
package ai.kilocode.client.session.history
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.ui.PickerRow
import ai.kilocode.client.ui.UiStyle
import com.intellij.icons.AllIcons
import com.intellij.ui.CollectionListModel
import com.intellij.ui.GroupHeaderSeparator
import com.intellij.ui.SimpleColoredComponent
import com.intellij.ui.SimpleTextAttributes
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.EmptyIcon
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.Point
import java.awt.Rectangle
import javax.swing.Icon
import javax.swing.JList
import javax.swing.JPanel
import javax.swing.ListCellRenderer
import javax.swing.SwingConstants
class HistoryListRenderer : BorderLayoutPanel(), ListCellRenderer<HistoryItem> {
private val title = JBLabel()
private val meta = JBLabel()
private const val DELETE_CLICK_AREA_WIDTH = 32
internal class HistoryListRenderer(
private val model: CollectionListModel<HistoryItem>,
private val source: () -> HistorySource,
private val deletable: Boolean,
) : JPanel(BorderLayout()), ListCellRenderer<HistoryItem> {
companion object {
private val icon: Icon = AllIcons.General.Remove
private val empty: Icon = EmptyIcon.create(icon)
fun isDeleteClick(list: JList<*>, bounds: Rectangle, point: Point): Boolean {
val width = JBUI.scale(DELETE_CLICK_AREA_WIDTH)
if (list.componentOrientation.isLeftToRight) {
val right = bounds.x + bounds.width
return point.x in (right - width)..right
}
return point.x in bounds.x..(bounds.x + width)
}
fun section(items: List<HistoryItem>, index: Int): String? {
val item = items.getOrNull(index) ?: return null
val current = HistoryTime.section(item)
val previous = items.getOrNull(index - 1)?.let(HistoryTime::section)
if (current == previous) return null
return HistoryTime.title(current)
}
}
private val sep = GroupHeaderSeparator(JBUI.CurrentTheme.Popup.separatorLabelInsets())
private val top = JPanel(BorderLayout()).apply {
border = JBUI.Borders.empty()
add(sep, BorderLayout.NORTH)
}
private val title = SimpleColoredComponent()
private val time = JBLabel()
private val del = JBLabel().apply {
horizontalAlignment = SwingConstants.CENTER
verticalAlignment = SwingConstants.CENTER
}
private val main = JPanel(BorderLayout()).apply {
add(title, BorderLayout.CENTER)
add(time, BorderLayout.EAST)
}
private val row = JPanel(BorderLayout()).apply {
add(main, BorderLayout.CENTER)
add(del, BorderLayout.EAST)
}
private val wrap = PickerRow()
init {
border = JBUI.Borders.empty(8, 12, 8, 12)
add(title, BorderLayout.CENTER)
add(meta, BorderLayout.EAST)
isOpaque = true
top.isOpaque = true
row.border = JBUI.Borders.empty(UiStyle.Space.LG, UiStyle.Space.LG, UiStyle.Space.LG, UiStyle.Space.LG)
UiStyle.Components.transparent(row)
UiStyle.Components.transparent(main)
UiStyle.Components.transparent(title)
UiStyle.Components.transparent(time)
UiStyle.Components.transparent(del)
wrap.setContent(row)
add(top, BorderLayout.NORTH)
add(wrap, BorderLayout.CENTER)
}
override fun getListCellRendererComponent(
@@ -26,13 +92,31 @@ class HistoryListRenderer : BorderLayoutPanel(), ListCellRenderer<HistoryItem> {
index: Int,
selected: Boolean,
focus: Boolean,
): Component {
isOpaque = selected
background = if (selected) list.selectionBackground else list.background
title.foreground = if (selected) list.selectionForeground else UIUtil.getLabelForeground()
meta.foreground = if (selected) list.selectionForeground else UIUtil.getContextHelpForeground()
title.text = value?.title?.takeIf { it.isNotBlank() } ?: KiloBundle.message("history.untitled")
meta.text = value?.updatedAt.orEmpty()
): JPanel {
val focused = selected || list.hasFocus() || focus
val fg = UIUtil.getListForeground(selected, focused)
val weak = if (selected) fg else UIUtil.getContextHelpForeground()
val section = if (source() == HistorySource.LOCAL) section(model.items, index) else null
background = list.background
top.background = list.background
wrap.update(list, selected, focused)
sep.caption = section
sep.setHideLine(index == 0)
top.isVisible = section != null
title.clear()
title.append(
value?.title?.takeIf { it.isNotBlank() } ?: KiloBundle.message("history.untitled"),
SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, fg),
)
time.text = value?.let(HistoryTime::relative).orEmpty()
time.foreground = weak
del.icon = if (deletable && selected) icon else empty
top.invalidate()
return this
}
fun deleteVisible(): Boolean = del.icon === icon
}
@@ -22,6 +22,9 @@ class HistoryModel {
private set
var cursor: String? = null
private set
private val deleting = mutableSetOf<String>()
fun deleting(id: String): Boolean = id in deleting
fun addListener(parent: Disposable, listener: HistoryModelEvent.Listener) {
listeners.add(listener)
@@ -60,7 +63,7 @@ class HistoryModel {
}
fun setCloud(items: List<HistoryItem>, next: String?, append: Boolean) {
cloud = if (append) cloud + items else items
cloud = HistoryTime.sorted(if (append) cloud + items else items)
cursor = next
cloudLoading = false
cloudError = null
@@ -68,15 +71,18 @@ class HistoryModel {
}
fun startDelete(id: String) {
deleting.add(id)
fire(HistoryModelEvent.DeleteStarted(id))
}
fun deleted(id: String) {
deleting.remove(id)
local = local.filterNot { it.id == id }
fire(HistoryModelEvent.Deleted(id))
}
fun error(source: HistorySource, message: String) {
if (source == HistorySource.LOCAL) deleting.clear()
if (source == HistorySource.LOCAL) {
localLoading = false
localError = message
@@ -1,23 +1,28 @@
package ai.kilocode.client.session.history
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.ui.UiStyle
import com.intellij.openapi.Disposable
import com.intellij.openapi.ui.Messages
import com.intellij.openapi.util.Disposer
import com.intellij.ui.CollectionListModel
import com.intellij.ui.DocumentAdapter
import com.intellij.ui.SearchTextField
import com.intellij.ui.components.JBCheckBox
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBList
import com.intellij.ui.components.JBScrollPane
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.FlowLayout
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.Box
import javax.swing.BoxLayout
import javax.swing.JButton
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.JToggleButton
import javax.swing.ListSelectionModel
import javax.swing.event.DocumentEvent
@@ -26,35 +31,29 @@ class HistoryPanel(
private val controller: HistoryController,
private val gitUrl: () -> String? = { null },
) : BorderLayoutPanel(), Disposable {
private val rows = CollectionListModel<HistoryItem>()
private val search = SearchTextField(false).apply {
textEditor.emptyText.text = KiloBundle.message("history.search.placeholder")
}
private val list = JBList(rows).apply {
selectionMode = ListSelectionModel.SINGLE_SELECTION
cellRenderer = HistoryListRenderer()
emptyText.text = KiloBundle.message("history.empty")
addListSelectionListener { sync() }
addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (e.clickCount < 2) return
selectedValue?.let(controller::open)
}
})
}
private val local = JButton(KiloBundle.message("history.tab.local"))
private val cloud = JButton(KiloBundle.message("history.tab.cloud"))
private val repo = JBCheckBox(KiloBundle.message("history.cloud.repo.only"))
private val delete = JButton(KiloBundle.message("history.delete.text"))
private val more = JButton(KiloBundle.message("history.cloud.load.more"))
private val local = Tab(HistorySource.LOCAL)
private val cloud = Tab(HistorySource.CLOUD)
private val status = JBLabel()
private val body = BorderLayoutPanel()
private val localRows = CollectionListModel<HistoryItem>()
private val cloudRows = CollectionListModel<HistoryItem>()
private val localRenderer = HistoryListRenderer(localRows, source = { controller.model.source }, deletable = true)
private val cloudRenderer = HistoryListRenderer(cloudRows, source = { controller.model.source }, deletable = false)
private val localSearch = search()
private val cloudSearch = search()
private val localList = list(localRows, HistorySource.LOCAL, localRenderer)
private val cloudList = list(cloudRows, HistorySource.CLOUD, cloudRenderer)
private val localPanel = panel(localSearch, localList)
private val more = JButton(KiloBundle.message("history.cloud.load.more"))
private val cloudPanel = panel(cloudSearch, cloudList, more)
private var loadedCloud = false
init {
Disposer.register(parent, this)
border = JBUI.Borders.empty(8)
border = JBUI.Borders.empty(UiStyle.Space.LG)
add(header(), BorderLayout.NORTH)
add(JBScrollPane(list), BorderLayout.CENTER)
add(footer(), BorderLayout.SOUTH)
add(body, BorderLayout.CENTER)
add(status, BorderLayout.SOUTH)
bind(parent)
sync()
controller.loadLocal()
@@ -65,43 +64,61 @@ class HistoryPanel(
private fun header(): JComponent {
local.addActionListener {
controller.selectSource(HistorySource.LOCAL)
controller.loadLocal()
}
cloud.addActionListener {
controller.selectSource(HistorySource.CLOUD)
controller.loadCloud(gitUrl = if (repo.isSelected) gitUrl() else null)
}
repo.addActionListener {
if (controller.model.source == HistorySource.CLOUD) {
controller.loadCloud(gitUrl = if (repo.isSelected) gitUrl() else null)
if (!loadedCloud) {
loadedCloud = true
controller.loadCloud(gitUrl = gitUrl())
}
}
search.textEditor.document.addDocumentListener(object : DocumentAdapter() {
more.addActionListener { controller.loadMoreCloud() }
return BorderLayoutPanel().apply {
border = JBUI.Borders.emptyBottom(UiStyle.Space.LG)
add(JPanel().apply {
layout = BoxLayout(this, BoxLayout.X_AXIS)
add(local)
add(Box.createHorizontalStrut(JBUI.scale(UiStyle.Space.SM)))
add(cloud)
}, BorderLayout.WEST)
}
}
private fun search() = SearchTextField(false).apply {
textEditor.emptyText.text = KiloBundle.message("history.search.placeholder")
textEditor.document.addDocumentListener(object : DocumentAdapter() {
override fun textChanged(e: DocumentEvent) {
sync()
}
})
}
private fun panel(search: SearchTextField, list: JBList<HistoryItem>, footer: JComponent? = null): JComponent {
return BorderLayoutPanel().apply {
add(BorderLayoutPanel().apply {
add(local, BorderLayout.WEST)
add(cloud, BorderLayout.CENTER)
add(repo, BorderLayout.EAST)
}, BorderLayout.NORTH)
add(search, BorderLayout.CENTER)
add(search, BorderLayout.NORTH)
add(JBScrollPane(list), BorderLayout.CENTER)
footer?.let {
it.border = JBUI.Borders.emptyTop(UiStyle.Space.LG)
add(it, BorderLayout.SOUTH)
}
}
}
private fun footer(): JComponent {
delete.addActionListener { list.selectedValue?.let(controller::delete) }
more.addActionListener { controller.loadMoreCloud() }
return BorderLayoutPanel().apply {
add(status, BorderLayout.CENTER)
add(BorderLayoutPanel().apply {
layout = FlowLayout(FlowLayout.RIGHT, JBUI.scale(4), 0)
add(delete)
add(more)
}, BorderLayout.EAST)
}
private fun list(rows: CollectionListModel<HistoryItem>, source: HistorySource, renderer: HistoryListRenderer) = JBList(rows).apply {
selectionMode = ListSelectionModel.SINGLE_SELECTION
cellRenderer = renderer
emptyText.text = KiloBundle.message("history.empty")
addMouseListener(object : MouseAdapter() {
override fun mouseReleased(e: MouseEvent) {
val item = clicked(this@apply, e) ?: return
if (source == HistorySource.LOCAL && UIUtil.isActionClick(e, MouseEvent.MOUSE_RELEASED, true) && deleteClick(this@apply, e)) {
confirm(item)
e.consume()
return
}
if (UIUtil.isActionClick(e, MouseEvent.MOUSE_RELEASED, true)) controller.open(item)
}
})
}
private fun bind(parent: Disposable) {
@@ -114,24 +131,39 @@ class HistoryPanel(
is HistoryModelEvent.Deleted,
is HistoryModelEvent.Error,
is HistoryModelEvent.SourceChanged -> sync()
is HistoryModelEvent.DeleteStarted -> Unit
is HistoryModelEvent.DeleteStarted -> sync()
}
}
}
private fun sync() {
val query = search.text.trim().lowercase()
val items = controller.model.items().filter { item ->
query.isEmpty() || item.title.lowercase().contains(query) || item.id.lowercase().contains(query)
syncRows(localRows, controller.model.local, localSearch.text)
syncRows(cloudRows, controller.model.cloud, cloudSearch.text)
local.isSelected = controller.model.source == HistorySource.LOCAL
cloud.isSelected = controller.model.source == HistorySource.CLOUD
val target = if (controller.model.source == HistorySource.LOCAL) localPanel else cloudPanel
if (body.componentCount != 1 || body.getComponent(0) !== target) {
body.removeAll()
body.add(target, BorderLayout.CENTER)
}
rows.replaceAll(items)
local.isEnabled = controller.model.source != HistorySource.LOCAL
cloud.isEnabled = controller.model.source != HistorySource.CLOUD
repo.isVisible = controller.model.source == HistorySource.CLOUD
delete.isEnabled = controller.model.source == HistorySource.LOCAL && list.selectedValue != null
more.isVisible = controller.model.source == HistorySource.CLOUD
more.isEnabled = controller.model.cursor != null && !controller.model.cloudLoading
more.isVisible = controller.model.cursor != null || controller.model.cloudLoading
status.text = statusText()
revalidate()
repaint()
}
private fun syncRows(model: CollectionListModel<HistoryItem>, items: List<HistoryItem>, value: String) {
val query = value.trim().lowercase()
val selected = if (model === localRows) localList.selectedValue?.id else cloudList.selectedValue?.id
val next = HistoryTime.sorted(items).filter { item ->
query.isEmpty() || item.title.lowercase().contains(query) || item.id.lowercase().contains(query) || item.directory?.lowercase()?.contains(query) == true
}
model.replaceAll(next)
val idx = next.indexOfFirst { it.id == selected }
if (idx >= 0) {
if (model === localRows) localList.selectedIndex = idx else cloudList.selectedIndex = idx
}
}
private fun statusText(): String {
@@ -142,33 +174,99 @@ class HistoryPanel(
return ""
}
internal fun itemCount() = rows.size
private fun deleteClick(list: JBList<HistoryItem>, e: MouseEvent): Boolean {
val row = list.locationToIndex(e.point)
val box = row.takeIf { it >= 0 }?.let { list.getCellBounds(it, it) } ?: return false
if (!box.contains(e.point)) return false
return HistoryListRenderer.isDeleteClick(list, box, e.point)
}
private fun clicked(list: JBList<HistoryItem>, e: MouseEvent): HistoryItem? {
val row = list.locationToIndex(e.point)
val box = row.takeIf { it >= 0 }?.let { list.getCellBounds(it, it) } ?: return null
if (!box.contains(e.point)) return null
list.selectedIndex = row
return list.model.getElementAt(row)
}
private fun confirm(item: HistoryItem) {
if (controller.model.deleting(item.id)) return
val result = Messages.showYesNoDialog(
this,
KiloBundle.message("history.delete.confirm.message", item.title.takeIf { it.isNotBlank() } ?: KiloBundle.message("history.untitled")),
KiloBundle.message("history.delete.confirm.title"),
Messages.getWarningIcon(),
)
if (result != Messages.YES) return
controller.delete(item)
}
internal fun itemCount() = if (controller.model.source == HistorySource.LOCAL) localRows.size else cloudRows.size
internal fun selectedSource() = controller.model.source
internal fun select(index: Int) {
list.selectedIndex = index
activeList().selectedIndex = index
sync()
}
internal fun clickDelete() {
delete.doClick()
activeList().selectedValue?.let(controller::delete)
}
internal fun clickCloud() {
cloud.doClick()
}
internal fun clickLocal() {
local.doClick()
}
internal fun clickMore() {
more.doClick()
}
internal fun setSearch(value: String) {
search.text = value
if (controller.model.source == HistorySource.LOCAL) localSearch.text = value else cloudSearch.text = value
sync()
}
internal fun groupTitles(): List<String> {
val model = if (controller.model.source == HistorySource.LOCAL) localRows else cloudRows
return model.items.indices.mapNotNull { HistoryListRenderer.section(model.items, it) }
}
internal fun deleteVisible(index: Int, selected: Boolean = true): Boolean {
val item = localRows.getElementAt(index)
val view = localList.cellRenderer.getListCellRendererComponent(localList, item, index, selected, false)
return view is HistoryListRenderer && view.deleteVisible()
}
internal fun cloudDeleteVisible(index: Int, selected: Boolean = true): Boolean {
val item = cloudRows.getElementAt(index)
val view = cloudList.cellRenderer.getListCellRendererComponent(cloudList, item, index, selected, false)
return view is HistoryListRenderer && view.deleteVisible()
}
private fun activeList() = if (controller.model.source == HistorySource.LOCAL) localList else cloudList
override fun dispose() {
// no-op
}
private class Tab(private val source: HistorySource) : JToggleButton(
when (source) {
HistorySource.LOCAL -> KiloBundle.message("history.tab.local")
HistorySource.CLOUD -> KiloBundle.message("history.tab.cloud")
},
) {
init {
isFocusable = false
}
override fun updateUI() {
super.updateUI()
border = JBUI.Borders.empty(UiStyle.Space.SM, UiStyle.Space.LG)
}
}
}
@@ -0,0 +1,71 @@
package ai.kilocode.client.session.history
import ai.kilocode.client.plugin.KiloBundle
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.temporal.ChronoUnit
import kotlin.math.abs
private const val SECOND_MS_LIMIT = 10_000_000_000L
private const val MINUTE = 60_000L
private const val HOUR = 60 * MINUTE
private const val DAY = 24 * HOUR
enum class HistorySection {
TODAY,
YESTERDAY,
WEEK,
MONTH,
OLDER,
}
internal object HistoryTime {
fun millis(item: HistoryItem): Long? {
if (item.source == HistorySource.CLOUD) return runCatching { Instant.parse(item.updatedAt).toEpochMilli() }.getOrNull()
val raw = item.updatedAt.toDoubleOrNull()?.toLong() ?: return null
if (abs(raw) < SECOND_MS_LIMIT) return raw * 1000
return raw
}
fun section(item: HistoryItem, now: Long = System.currentTimeMillis()): HistorySection {
val ms = millis(item) ?: return HistorySection.OLDER
val zone = ZoneId.systemDefault()
val date = Instant.ofEpochMilli(ms).atZone(zone).toLocalDate()
val today = Instant.ofEpochMilli(now).atZone(zone).toLocalDate()
if (date == today) return HistorySection.TODAY
if (date == today.minusDays(1)) return HistorySection.YESTERDAY
if (date.isAfter(today.minusDays(7)) && date.isBefore(today)) return HistorySection.WEEK
if (date.year == today.year && date.month == today.month) return HistorySection.MONTH
return HistorySection.OLDER
}
fun title(section: HistorySection): String = when (section) {
HistorySection.TODAY -> KiloBundle.message("history.group.today")
HistorySection.YESTERDAY -> KiloBundle.message("history.group.yesterday")
HistorySection.WEEK -> KiloBundle.message("history.group.week")
HistorySection.MONTH -> KiloBundle.message("history.group.month")
HistorySection.OLDER -> KiloBundle.message("history.group.older")
}
fun relative(item: HistoryItem, now: Long = System.currentTimeMillis()): String {
val ms = millis(item) ?: return item.updatedAt
val diff = (now - ms).coerceAtLeast(0)
if (diff < MINUTE) return KiloBundle.message("history.time.moments")
if (diff < HOUR) return KiloBundle.message("history.time.minutes", (diff / MINUTE).coerceAtLeast(1))
if (diff < DAY) return KiloBundle.message("history.time.hours", (diff / HOUR).coerceAtLeast(1))
if (diff < 7 * DAY) return KiloBundle.message("history.time.days", (diff / DAY).coerceAtLeast(1))
val date = LocalDate.ofInstant(Instant.ofEpochMilli(ms), ZoneId.systemDefault())
val today = LocalDate.ofInstant(Instant.ofEpochMilli(now), ZoneId.systemDefault())
val months = ChronoUnit.MONTHS.between(date.withDayOfMonth(1), today.withDayOfMonth(1))
if (months < 1) return KiloBundle.message("history.time.days", (diff / DAY).coerceAtLeast(1))
if (months < 12) return KiloBundle.message("history.time.months", months)
return KiloBundle.message("history.time.years", months / 12)
}
fun sorted(items: List<HistoryItem>): List<HistoryItem> = items.sortedWith(
compareByDescending<HistoryItem> { millis(it) ?: Long.MIN_VALUE }
.thenBy { it.title.lowercase() }
.thenBy { it.id },
)
}
@@ -40,6 +40,9 @@
class="ai.kilocode.client.actions.KiloSettingsAction"
icon="AllIcons.General.GearPlain"/>
<action id="Kilo.History"
class="ai.kilocode.client.actions.HistoryAction"/>
<action id="Kilo.SendPrompt"
class="ai.kilocode.client.actions.SendPromptAction"
text="Send Prompt"
@@ -73,8 +73,21 @@ history.empty=No sessions
history.loading=Loading...
history.untitled=Untitled
history.delete.text=Delete
history.delete.confirm.title=Delete session?
history.delete.confirm.message=Delete "{0}" from local history?
history.cloud.load.more=Load more
history.cloud.repo.only=Only this repository
history.group.today=Today
history.group.yesterday=Yesterday
history.group.week=This Week
history.group.month=This Month
history.group.older=Older
history.time.moments=Moments ago
history.time.minutes={0} min ago
history.time.hours={0}h ago
history.time.days={0}d ago
history.time.months={0}mo ago
history.time.years={0}y ago
history.error.local=Failed to load local history
history.error.cloud=Failed to load cloud history
history.error.local.delete=Failed to delete session
@@ -84,6 +97,8 @@ action.Kilo.Settings.text=Settings
action.Kilo.Settings.description=Kilo Code settings
action.Kilo.NewSession.text=New Session
action.Kilo.NewSession.description=Start a new Kilo session
action.Kilo.History.text=History
action.Kilo.History.description=Show session history
action.Kilo.SendPrompt.text=Send Prompt
action.Kilo.SendPrompt.description=Send the current Kilo prompt
action.Kilo.StopSession.text=Stop Session
@@ -19,6 +19,8 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import javax.swing.JLabel
import javax.swing.JComponent
import javax.swing.JPanel
@Suppress("UnstableApiUsage")
@@ -184,7 +186,64 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
assertEquals(0, manager.component.componentCount)
}
private fun manager(): SessionSidePanelManager {
fun `test show history swaps active component`() {
val history = JLabel("History")
val manager = manager(history = { _, _, _ -> history })
manager.newSession()
manager.showHistory()
assertSame(history, manager.component.getComponent(0))
assertNull(manager.defaultFocusedComponent)
}
fun `test opening local history item shows session ui`() {
lateinit var open: (SessionDto) -> Unit
val history = JLabel("History")
val manager = manager(history = { _, fn, _ ->
open = fn
history
})
manager.showHistory()
open(session("ses_1"))
assertTrue(active(manager) is SessionUi)
assertEquals(listOf("/test" to "ses_1"), created)
}
fun `test new session from history shows blank session`() {
val history = JLabel("History")
val manager = manager(history = { _, _, _ -> history })
manager.showHistory()
manager.newSession()
assertTrue(active(manager) is SessionUi)
assertEquals(listOf("/test" to null), created)
}
fun `test deleted cached history session is not reused`() {
lateinit var deleted: (String) -> Unit
val manager = manager(history = { _, _, fn ->
deleted = fn
JLabel("History")
})
val session = session("ses_1")
manager.openSession(session)
val first = active(manager)
manager.showHistory()
deleted("ses_1")
manager.openSession(session)
assertNotSame(first, active(manager))
assertEquals(listOf("/test" to "ses_1", "/test" to "ses_1"), created)
}
private fun manager(
history: ((com.intellij.openapi.Disposable, (SessionDto) -> Unit, (String) -> Unit) -> JComponent)? = null,
): SessionSidePanelManager {
val manager = SessionSidePanelManager(
project = project,
root = workspace,
@@ -197,6 +256,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
}
},
resolve = { workspaces.workspace(it) },
history = history,
)
managers.add(manager)
return manager
@@ -20,6 +20,8 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import java.time.Instant
import java.time.temporal.ChronoUnit
@Suppress("UnstableApiUsage")
class HistoryControllerTest : BasePlatformTestCase() {
@@ -132,6 +134,53 @@ class HistoryControllerTest : BasePlatformTestCase() {
assertEquals(1, panel.itemCount())
}
fun `test panel preserves independent search per source`() {
rpc.listed += session("ses_1", "Alpha")
rpc.listed += session("ses_2", "Beta")
rpc.cloud += cloud("cloud_1", "Cloud Alpha")
rpc.cloud += cloud("cloud_2", "Cloud Beta")
val panel = HistoryPanel(parent, controller())
flush()
panel.setSearch("alp")
assertEquals(1, panel.itemCount())
panel.clickCloud()
flush()
assertEquals(2, panel.itemCount())
panel.setSearch("beta")
assertEquals(1, panel.itemCount())
panel.clickLocal()
assertEquals(1, panel.itemCount())
}
fun `test panel groups sessions by date`() {
val now = Instant.now()
rpc.listed += session("ses_today", "Today", now.toEpochMilli().toDouble())
rpc.listed += session("ses_yesterday", "Yesterday", now.minus(1, ChronoUnit.DAYS).toEpochMilli().toDouble())
rpc.listed += session("ses_week", "Week", now.minus(3, ChronoUnit.DAYS).toEpochMilli().toDouble())
rpc.listed += session("ses_month", "Month", now.minus(10, ChronoUnit.DAYS).toEpochMilli().toDouble())
rpc.listed += session("ses_older", "Older", now.minus(60, ChronoUnit.DAYS).toEpochMilli().toDouble())
val panel = HistoryPanel(parent, controller())
flush()
assertTrue(panel.groupTitles().containsAll(listOf("Today", "Yesterday", "This Week", "Older")))
}
fun `test local renderer exposes delete and cloud renderer hides it`() {
rpc.listed += session("ses_1", "Local")
rpc.cloud += cloud("cloud_1", "Cloud")
val panel = HistoryPanel(parent, controller())
flush()
assertTrue(panel.deleteVisible(0))
panel.clickCloud()
flush()
assertFalse(panel.cloudDeleteVisible(0))
}
private fun controller() = HistoryController(sessions, workspace, scope)
private fun collect(controller: HistoryController): MutableList<HistoryModelEvent> {
@@ -150,13 +199,13 @@ class HistoryControllerTest : BasePlatformTestCase() {
}
}
private fun session(id: String, title: String) = SessionDto(
private fun session(id: String, title: String, updated: Double = 2.0) = SessionDto(
id = id,
projectID = "prj",
directory = "/test",
title = title,
version = "1",
time = SessionTimeDto(created = 1.0, updated = 2.0),
time = SessionTimeDto(created = 1.0, updated = updated),
)
private fun cloud(id: String, title: String) = CloudSessionDto(