feat: improve chat history ui/ux
Some checks are pending
Build / Build (push) Waiting to run
Build / Verify Plugin (push) Blocked by required conditions

This commit is contained in:
Carl-Robert Linnupuu 2025-07-20 23:17:42 +01:00
parent 4122b46e1d
commit a6ff38e52c
8 changed files with 912 additions and 202 deletions

View file

@ -66,6 +66,7 @@ public final class ConversationService {
public void saveMessage(@NotNull Conversation conversation, @NotNull Message message) {
conversation.setUpdatedOn(LocalDateTime.now());
conversation.addMessage(message);
saveConversation(conversation);
}
public void saveConversation(Conversation conversation) {

View file

@ -1,114 +0,0 @@
package ee.carlrobert.codegpt.toolwindow.conversations;
import com.intellij.openapi.project.Project;
import com.intellij.ui.JBColor;
import com.intellij.ui.components.JBLabel;
import com.intellij.util.ui.JBFont;
import com.intellij.util.ui.JBUI;
import ee.carlrobert.codegpt.actions.toolwindow.DeleteConversationAction;
import ee.carlrobert.codegpt.conversations.Conversation;
import ee.carlrobert.codegpt.conversations.ConversationsState;
import ee.carlrobert.codegpt.toolwindow.chat.ChatToolWindowContentManager;
import ee.carlrobert.codegpt.ui.IconActionButton;
import java.awt.BorderLayout;
import java.awt.Cursor;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.time.format.DateTimeFormatter;
import javax.swing.JLabel;
import javax.swing.JPanel;
import org.jetbrains.annotations.NotNull;
class ConversationPanel extends JPanel {
ConversationPanel(
@NotNull Project project,
@NotNull Conversation conversation,
@NotNull Runnable onDelete) {
super(new BorderLayout());
var toolWindowContentManager = project.getService(ChatToolWindowContentManager.class);
init(toolWindowContentManager, conversation, onDelete);
}
private void init(
ChatToolWindowContentManager toolWindowContentManager,
Conversation conversation,
Runnable onDelete) {
setBackground(JBColor.background());
addMouseListener(new MouseAdapter() {
@Override
public void mouseClicked(MouseEvent e) {
toolWindowContentManager.displayConversation(conversation);
}
});
addStyles(isSelected(conversation));
addTextPanel(conversation, onDelete);
setCursor(new Cursor(Cursor.HAND_CURSOR));
}
private boolean isSelected(Conversation conversation) {
var currentConversation = ConversationsState.getCurrentConversation();
return currentConversation != null && currentConversation.getId().equals(conversation.getId());
}
private void addStyles(boolean isSelected) {
var border = isSelected
? JBUI.Borders.customLine(JBUI.CurrentTheme.ActionButton.focusedBorder(), 2, 2, 2, 2)
: JBUI.Borders.customLine(JBColor.border(), 1, 0, 1, 0);
setBorder(JBUI.Borders.compound(border, JBUI.Borders.empty(8)));
setLayout(new GridBagLayout());
setCursor(new Cursor(Cursor.HAND_CURSOR));
}
private void addTextPanel(Conversation conversation, Runnable onDelete) {
var constraints = new GridBagConstraints();
constraints.gridx = 1;
constraints.weightx = 1.0;
constraints.fill = GridBagConstraints.HORIZONTAL;
add(createTextPanel(conversation, onDelete), constraints);
}
private JPanel createTextPanel(Conversation conversation, Runnable onDelete) {
var headerPanel = new JPanel(new GridBagLayout());
headerPanel.setBorder(JBUI.Borders.emptyBottom(12));
var gbc = new GridBagConstraints();
gbc.fill = GridBagConstraints.HORIZONTAL;
gbc.weightx = 1.0;
gbc.gridx = 0;
headerPanel.add(new JBLabel(getConversationDisplayTitle(conversation))
.withFont(JBFont.label().asBold()), gbc);
gbc.gridx = 1;
gbc.weightx = 0;
headerPanel.add(new IconActionButton(new DeleteConversationAction(onDelete), "DELETE"), gbc);
var bottomPanel = new JPanel(new BorderLayout());
bottomPanel.add(new JLabel(conversation.getUpdatedOn()
.format(DateTimeFormatter.ofPattern("M/d/yyyy, h:mm:ss a"))), BorderLayout.WEST);
var textPanel = new JPanel(new BorderLayout());
textPanel.add(headerPanel, BorderLayout.NORTH);
textPanel.add(bottomPanel, BorderLayout.SOUTH);
return textPanel;
}
private String getConversationDisplayTitle(Conversation conversation) {
String title = conversation.getTitle();
if (title != null && !title.trim().isEmpty()) {
return title;
}
return getFirstPrompt(conversation);
}
private String getFirstPrompt(Conversation conversation) {
var messages = conversation.getMessages();
if (messages.isEmpty()) {
return "";
}
return messages.get(0).getPrompt();
}
}

View file

@ -1,83 +0,0 @@
package ee.carlrobert.codegpt.toolwindow.conversations;
import com.intellij.openapi.actionSystem.ActionManager;
import com.intellij.openapi.actionSystem.DefaultActionGroup;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.roots.ui.componentsList.components.ScrollablePanel;
import com.intellij.openapi.ui.SimpleToolWindowPanel;
import com.intellij.ui.components.JBScrollPane;
import com.intellij.util.ui.JBFont;
import com.intellij.util.ui.JBUI;
import ee.carlrobert.codegpt.actions.toolwindow.DeleteAllConversationsAction;
import ee.carlrobert.codegpt.actions.toolwindow.MoveDownAction;
import ee.carlrobert.codegpt.actions.toolwindow.MoveUpAction;
import ee.carlrobert.codegpt.conversations.ConversationService;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.ScrollPaneConstants;
import org.jetbrains.annotations.NotNull;
public class ConversationsToolWindow extends JPanel {
private final Project project;
private final ConversationService conversationService;
private final ScrollablePanel scrollablePanel;
private final JScrollPane scrollPane;
public ConversationsToolWindow(@NotNull Project project) {
this.project = project;
this.conversationService = ConversationService.getInstance();
scrollablePanel = new ScrollablePanel();
scrollablePanel.setLayout(new BoxLayout(scrollablePanel, BoxLayout.Y_AXIS));
scrollPane = new JBScrollPane();
scrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
scrollPane.setViewportView(scrollablePanel);
scrollPane.setBorder(null);
scrollPane.setViewportBorder(null);
refresh();
}
public JPanel getContent() {
SimpleToolWindowPanel panel = new SimpleToolWindowPanel(true);
panel.setContent(scrollPane);
var actionGroup = new DefaultActionGroup("TOOLBAR_ACTION_GROUP", false);
actionGroup.add(new MoveDownAction(this::refresh));
actionGroup.add(new MoveUpAction(this::refresh));
actionGroup.addSeparator();
actionGroup.add(new DeleteAllConversationsAction(this::refresh));
var toolbar = ActionManager.getInstance()
.createActionToolbar("NAVIGATION_BAR_TOOLBAR", actionGroup, true);
toolbar.setTargetComponent(panel);
panel.setToolbar(toolbar.getComponent());
return panel;
}
public void refresh() {
scrollablePanel.removeAll();
var sortedConversations = conversationService.getSortedConversations();
if (sortedConversations.isEmpty()) {
var emptyLabel = new JLabel("No conversations exist.");
emptyLabel.setFont(JBFont.h2());
emptyLabel.setBorder(JBUI.Borders.empty(8));
scrollablePanel.add(emptyLabel);
} else {
sortedConversations.forEach(conversation -> {
scrollablePanel.add(Box.createVerticalStrut(8));
scrollablePanel.add(new ConversationPanel(project, conversation, () -> {
ConversationService.getInstance().deleteConversation(conversation);
refresh();
}));
});
}
scrollablePanel.revalidate();
scrollablePanel.repaint();
}
}

View file

@ -5,9 +5,9 @@ import com.intellij.openapi.project.Project
import com.intellij.openapi.wm.ToolWindow
import com.intellij.openapi.wm.ToolWindowFactory
import com.intellij.ui.content.ContentManagerEvent
import com.intellij.ui.content.ContentManagerListener;
import com.intellij.ui.content.ContentManagerListener
import ee.carlrobert.codegpt.toolwindow.chat.ChatToolWindowPanel
import ee.carlrobert.codegpt.toolwindow.conversations.ConversationsToolWindow
import ee.carlrobert.codegpt.toolwindow.history.ChatHistoryToolWindow
import javax.swing.JComponent
class ProxyAIToolWindowFactory : ToolWindowFactory, DumbAware {
@ -17,14 +17,15 @@ class ProxyAIToolWindowFactory : ToolWindowFactory, DumbAware {
toolWindow: ToolWindow
) {
var chatToolWindowPanel = ChatToolWindowPanel(project, toolWindow.disposable)
var conversationsToolWindow = ConversationsToolWindow(project)
var chatHistoryToolWindow = ChatHistoryToolWindow(project)
addContent(toolWindow, chatToolWindowPanel, "Chat")
addContent(toolWindow, conversationsToolWindow.getContent(), "Chat History")
addContent(toolWindow, chatHistoryToolWindow.getContent(), "Chat History")
toolWindow.addContentManagerListener(object : ContentManagerListener {
override fun selectionChanged(event: ContentManagerEvent) {
if ("Chat History" == event.content.tabName && event.content.isSelected) {
conversationsToolWindow.refresh()
chatHistoryToolWindow.refresh()
}
}
})

View file

@ -0,0 +1,271 @@
package ee.carlrobert.codegpt.toolwindow.history
import com.intellij.icons.AllIcons
import com.intellij.ui.JBColor
import com.intellij.ui.components.JBLabel
import com.intellij.ui.dsl.builder.*
import com.intellij.util.ui.JBFont
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import com.intellij.util.ui.components.BorderLayoutPanel
import ee.carlrobert.codegpt.CodeGPTBundle
import ee.carlrobert.codegpt.conversations.Conversation
import java.awt.Color
import java.awt.Cursor
import java.awt.Dimension
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.time.format.DateTimeFormatter
import javax.swing.JButton
import javax.swing.JPanel
import javax.swing.SwingUtilities
import kotlin.math.max
class ChatHistoryItemPanel(
private val conversation: Conversation,
private val onClicked: () -> Unit,
private val onDoubleClicked: () -> Unit,
private val onDeleteClicked: () -> Unit,
private var isSelected: Boolean = false
) : BorderLayoutPanel() {
companion object {
private val DATE_FORMATTER = DateTimeFormatter.ofPattern("MMM d, h:mm a")
private const val TITLE_MAX_LENGTH = 60
private const val PREVIEW_MAX_LENGTH = 100
private const val MIN_PANEL_HEIGHT = 80
private const val BUTTON_SIZE = 20
}
private val deleteButton: JButton = createDeleteButton()
private var isHovered = false
init {
setupUI()
setupMouseListeners()
}
fun setSelected(selected: Boolean) {
if (isSelected != selected) {
isSelected = selected
setupBackground()
repaint()
}
}
override fun getPreferredSize(): Dimension {
val size = super.getPreferredSize()
return Dimension(size.width, max(size.height, MIN_PANEL_HEIGHT))
}
private fun setupUI() {
cursor = Cursor(Cursor.HAND_CURSOR)
border = JBUI.Borders.customLine(JBColor.border(), 0, 0, 1, 0)
setupBackground()
addToCenter(createMainPanel())
}
private fun setupMouseListeners() {
addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
when (e.clickCount) {
1 -> onClicked()
2 -> onDoubleClicked()
}
}
override fun mouseEntered(e: MouseEvent) {
isHovered = true
setupBackground()
repaint()
deleteButton.icon = AllIcons.Actions.Close
deleteButton.rolloverIcon = AllIcons.Actions.CloseHovered
}
override fun mouseExited(e: MouseEvent) {
val point =
SwingUtilities.convertPoint(e.component, e.point, this@ChatHistoryItemPanel)
if (!contains(point)) {
isHovered = false
setupBackground()
repaint()
deleteButton.icon = null
deleteButton.rolloverIcon = null
}
}
})
}
private fun setupBackground() {
background = when {
isSelected -> UIUtil.getListSelectionBackground(true)
isHovered -> UIUtil.getListBackground().darker()
else -> UIUtil.getListBackground()
}
}
private fun createDeleteButton(): JButton {
return JButton().apply {
isOpaque = false
isContentAreaFilled = false
isBorderPainted = false
isFocusPainted = false
toolTipText = CodeGPTBundle.get("conversation.deleteButton.tooltip")
preferredSize = Dimension(BUTTON_SIZE, BUTTON_SIZE)
minimumSize = Dimension(BUTTON_SIZE, BUTTON_SIZE)
maximumSize = Dimension(BUTTON_SIZE, BUTTON_SIZE)
icon = null
addActionListener {
onDeleteClicked()
}
isVisible = true
addMouseListener(object : MouseAdapter() {
override fun mouseEntered(e: MouseEvent) {
isHovered = true
setupBackground()
this@ChatHistoryItemPanel.repaint()
}
override fun mouseExited(e: MouseEvent) {
val parentPoint =
SwingUtilities.convertPoint(e.component, e.point, this@ChatHistoryItemPanel)
if (!this@ChatHistoryItemPanel.contains(parentPoint)) {
isHovered = false
setupBackground()
this@ChatHistoryItemPanel.repaint()
icon = null
rolloverIcon = null
}
}
})
}
}
private fun createMainPanel(): JPanel {
return panel {
row {
cell(createTitlePanel())
.align(Align.FILL)
}.resizableRow()
val previewText = getPreviewText()
if (previewText.isNotEmpty()) {
row {
cell(createPreviewLabel(previewText))
.align(Align.FILL)
}.topGap(TopGap.NONE)
}
row {
cell(createMetadataPanel())
.align(Align.FILL)
}.topGap(TopGap.NONE)
}.apply {
isOpaque = false
border = JBUI.Borders.empty(4, 8)
}
}
private fun createTitlePanel(): JPanel {
return panel {
row {
cell(createTitleLabel())
.align(AlignX.FILL)
.resizableColumn()
cell(deleteButton)
.align(AlignY.CENTER)
}
}.apply {
isOpaque = false
border = JBUI.Borders.empty(2, 0, 2, 0)
}
}
private fun createPreviewLabel(previewText: String): JBLabel {
return JBLabel(previewText).apply {
font = JBFont.regular().deriveFont(12f)
foreground = getPreviewColor()
maximumSize = Dimension(Int.MAX_VALUE, preferredSize.height * 2)
}
}
private fun createMetadataPanel(): JPanel {
return panel {
row {
label(formatDate())
.applyToComponent {
font = JBFont.regular().deriveFont(11f)
foreground = getMetadataColor()
}
.align(AlignX.LEFT)
.resizableColumn()
val messageCount = conversation.messages.size
if (messageCount > 0) {
val text = if (messageCount == 1) {
CodeGPTBundle.get("conversation.messageCount.singular", messageCount)
} else {
CodeGPTBundle.get("conversation.messageCount.plural", messageCount)
}
label(text)
.applyToComponent {
font = JBFont.regular().deriveFont(11f)
foreground = getMetadataColor()
}
.align(AlignX.RIGHT)
}
}
}.apply {
isOpaque = false
}
}
private fun createTitleLabel(): JBLabel {
return JBLabel(getConversationDisplayTitle()).apply {
font = JBFont.label().deriveFont(14f).asBold()
foreground = UIUtil.getLabelForeground()
}
}
private fun getPreviewColor(): Color {
return JBColor(Color(128, 128, 128), Color(169, 169, 169))
}
private fun getMetadataColor(): Color {
return JBColor(Color(150, 150, 150), Color(130, 130, 130))
}
private fun getConversationDisplayTitle(): String {
val title = conversation.title?.takeIf { it.isNotBlank() } ?: getFirstPrompt()
return if (title.length > TITLE_MAX_LENGTH) {
title.substring(0, TITLE_MAX_LENGTH - 3) + "..."
} else {
title
}
}
private fun getFirstPrompt(): String {
return conversation.messages.firstOrNull()?.prompt?.trim()
?: CodeGPTBundle.get("conversation.defaultTitle")
}
private fun getPreviewText(): String {
val lastMessage = conversation.messages.lastOrNull() ?: return ""
val text = lastMessage.response?.trim() ?: lastMessage.prompt?.trim() ?: ""
val cleanedText = text.replace("\n", " ").replace(Regex("\\s+"), " ").trim()
return if (cleanedText.length > PREVIEW_MAX_LENGTH) {
cleanedText.substring(0, PREVIEW_MAX_LENGTH - 3) + "..."
} else {
cleanedText
}
}
private fun formatDate(): String {
return conversation.updatedOn.format(DATE_FORMATTER)
}
}

View file

@ -0,0 +1,196 @@
package ee.carlrobert.codegpt.toolwindow.history
import com.intellij.ui.components.JBScrollPane
import com.intellij.ui.dsl.builder.panel
import com.intellij.util.ui.JBFont
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import com.intellij.util.ui.components.BorderLayoutPanel
import ee.carlrobert.codegpt.CodeGPTBundle
import ee.carlrobert.codegpt.conversations.Conversation
import java.awt.BorderLayout
import java.awt.Rectangle
import java.awt.event.KeyAdapter
import java.awt.event.KeyEvent
import java.util.*
import javax.swing.BoxLayout
import javax.swing.JPanel
import javax.swing.ScrollPaneConstants
class ChatHistoryListPanel : BorderLayoutPanel() {
private val itemsPanel = JPanel()
private var scrollPane = JBScrollPane(itemsPanel)
private var conversations = listOf<Conversation>()
private var selectedConversation: Conversation? = null
private var onConversationSelected: ((Conversation?) -> Unit)? = null
private var onConversationDoubleClicked: ((Conversation) -> Unit)? = null
private var onConversationDeleted: ((Conversation) -> Unit)? = null
private val conversationPanels = mutableMapOf<UUID, ChatHistoryItemPanel>()
init {
setupItemsPanel()
setupScrollPane()
setupKeyboardSupport()
}
fun setConversations(newConversations: List<Conversation>) {
val previousSelection = selectedConversation
conversations = newConversations
rebuildItems()
previousSelection?.let { selected ->
conversations.find { it.id == selected.id }?.let { setSelectedConversation(it) }
}
}
fun setSelectedConversation(conversation: Conversation?) {
if (selectedConversation != conversation) {
selectedConversation?.let { prev ->
conversationPanels[prev.id]?.setSelected(false)
}
selectedConversation = conversation
conversation?.let { conv ->
conversationPanels[conv.id]?.setSelected(true)
}
onConversationSelected?.invoke(conversation)
}
}
fun setOnConversationSelected(callback: (Conversation?) -> Unit) {
onConversationSelected = callback
}
fun setOnConversationDoubleClicked(callback: (Conversation) -> Unit) {
onConversationDoubleClicked = callback
}
fun setOnConversationDeleted(callback: (Conversation) -> Unit) {
onConversationDeleted = callback
}
private fun setupItemsPanel() {
itemsPanel.layout = BoxLayout(itemsPanel, BoxLayout.Y_AXIS)
itemsPanel.background = UIUtil.getListBackground()
}
private fun setupScrollPane() {
scrollPane.apply {
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED
border = null
viewportBorder = null
background = UIUtil.getListBackground()
}
addToCenter(scrollPane)
}
private fun setupKeyboardSupport() {
isFocusable = true
addKeyListener(object : KeyAdapter() {
override fun keyPressed(e: KeyEvent) {
when (e.keyCode) {
KeyEvent.VK_UP -> selectPrevious()
KeyEvent.VK_DOWN -> selectNext()
KeyEvent.VK_ENTER -> selectedConversation?.let {
onConversationDoubleClicked?.invoke(
it
)
}
KeyEvent.VK_DELETE, KeyEvent.VK_BACK_SPACE -> {
selectedConversation?.let { onConversationDeleted?.invoke(it) }
}
}
}
})
}
private fun selectPrevious() {
val currentIndex = selectedConversation?.let { conversations.indexOf(it) } ?: 0
if (currentIndex > 0) {
setSelectedConversation(conversations[currentIndex - 1])
scrollToSelected()
}
}
private fun selectNext() {
val currentIndex = selectedConversation?.let { conversations.indexOf(it) } ?: -1
if (currentIndex < conversations.size - 1) {
setSelectedConversation(conversations[currentIndex + 1])
scrollToSelected()
}
}
private fun scrollToSelected() {
selectedConversation?.let { selected ->
val index = conversations.indexOf(selected)
if (index >= 0 && index < itemsPanel.componentCount) {
val component = itemsPanel.getComponent(index)
scrollPane.viewport.scrollRectToVisible(
Rectangle(0, component.y, component.width, component.height)
)
}
}
}
private fun rebuildItems() {
itemsPanel.removeAll()
if (conversations.isEmpty()) {
addEmptyState()
} else {
addConversationItems()
}
itemsPanel.revalidate()
itemsPanel.repaint()
}
private fun addEmptyState() {
val emptyPanel = panel {
row {
label(CodeGPTBundle.get("conversation.emptyState"))
.applyToComponent {
font = JBFont.regular().deriveFont(14f)
foreground = UIUtil.getInactiveTextColor()
}
}
}.apply {
border = JBUI.Borders.empty(40)
isOpaque = false
}
itemsPanel.layout = BorderLayout()
itemsPanel.add(emptyPanel, BorderLayout.CENTER)
}
private fun addConversationItems() {
itemsPanel.layout = BoxLayout(itemsPanel, BoxLayout.Y_AXIS)
conversationPanels.clear()
conversations.forEach { conversation ->
val isCurrentlySelected = selectedConversation?.id == conversation.id
val itemPanel = ChatHistoryItemPanel(
conversation = conversation,
onClicked = {
setSelectedConversation(conversation)
onConversationDoubleClicked?.invoke(conversation)
},
onDoubleClicked = { onConversationDoubleClicked?.invoke(conversation) },
onDeleteClicked = { onConversationDeleted?.invoke(conversation) },
isSelected = isCurrentlySelected
)
itemPanel.alignmentX = LEFT_ALIGNMENT
itemPanel.maximumSize =
java.awt.Dimension(Integer.MAX_VALUE, itemPanel.preferredSize.height)
itemsPanel.add(itemPanel)
conversationPanels[conversation.id] = itemPanel
}
}
}

View file

@ -0,0 +1,413 @@
package ee.carlrobert.codegpt.toolwindow.history
import com.intellij.icons.AllIcons
import com.intellij.openapi.actionSystem.*
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.SimpleToolWindowPanel
import com.intellij.openapi.ui.popup.JBPopupFactory
import com.intellij.openapi.util.Key
import com.intellij.ui.DocumentAdapter
import com.intellij.ui.SearchTextField
import com.intellij.ui.components.JBLabel
import com.intellij.ui.dsl.builder.AlignX
import com.intellij.ui.dsl.builder.panel
import com.intellij.util.ui.JBFont
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import com.intellij.util.ui.components.BorderLayoutPanel
import ee.carlrobert.codegpt.CodeGPTBundle
import ee.carlrobert.codegpt.actions.toolwindow.DeleteAllConversationsAction
import ee.carlrobert.codegpt.conversations.Conversation
import ee.carlrobert.codegpt.conversations.ConversationService
import ee.carlrobert.codegpt.conversations.ConversationsState
import ee.carlrobert.codegpt.toolwindow.chat.ChatToolWindowContentManager
import javax.swing.JOptionPane
import javax.swing.JPanel
import javax.swing.SwingUtilities
import javax.swing.Timer
import javax.swing.event.DocumentEvent
import kotlin.concurrent.thread
class ChatHistoryToolWindow(private val project: Project) : BorderLayoutPanel() {
companion object {
private val KEY: Key<Boolean> = Key.create("SELECTED_STATE")
private const val SEARCH_DEBOUNCE_MS = 300
private const val MAX_MESSAGES_TO_SEARCH = 5
}
private val conversationService = ConversationService.getInstance()
private val chatHistoryListPanel = ChatHistoryListPanel()
private val searchField = SearchTextField()
private var allConversations = mutableListOf<Conversation>()
private var sortOption = SortOption.UPDATED_DATE_DESC
private val statusLabel = JBLabel().apply {
font = JBFont.small()
foreground = UIUtil.getContextHelpForeground()
border = JBUI.Borders.empty(2, 8)
}
private var lastSearchText = ""
private var lastFilteredConversations: List<Conversation>? = null
private var isDataLoaded = false
enum class SortOption(val propertyKey: String) {
UPDATED_DATE_DESC("conversation.sortOption.recentlyUpdated"),
UPDATED_DATE_ASC("conversation.sortOption.oldestFirst"),
TITLE_ASC("conversation.sortOption.titleAscending"),
TITLE_DESC("conversation.sortOption.titleDescending"),
MESSAGE_COUNT_DESC("conversation.sortOption.mostMessages"),
MESSAGE_COUNT_ASC("conversation.sortOption.leastMessages");
val displayName: String
get() = CodeGPTBundle.get(propertyKey)
}
init {
setupUI()
loadConversationsAsync()
setupListeners()
}
private fun setupUI() {
chatHistoryListPanel.apply {
setOnConversationSelected { conversation ->
ConversationsState.getInstance().setCurrentConversation(conversation)
}
setOnConversationDoubleClicked { conversation ->
project.getService(ChatToolWindowContentManager::class.java)
.displayConversation(conversation)
}
setOnConversationDeleted { conversation ->
deleteConversation(conversation)
}
}
val searchPanel = createSearchPanel()
val topPanel = panel {
row {
cell(searchPanel)
.align(AlignX.FILL)
}.resizableRow()
row {
cell(statusLabel)
.align(AlignX.FILL)
.applyToComponent {
border = JBUI.Borders.empty(2, 8)
}
}
separator()
}.apply {
background = UIUtil.getPanelBackground()
}
addToTop(topPanel)
addToCenter(chatHistoryListPanel)
}
private fun createSearchPanel(): JPanel {
searchField.apply {
textEditor.emptyText.text = CodeGPTBundle.get("conversation.searchField.placeholder")
textEditor.border = JBUI.Borders.empty(2)
textEditor.background = UIUtil.getPanelBackground().darker()
}
return panel {
row {
cell(searchField)
.align(AlignX.FILL)
.resizableColumn()
}
}.apply {
border = JBUI.Borders.empty(4, 8, 4, 8)
background = UIUtil.getPanelBackground()
}
}
private fun updateSearchFieldState() {
SwingUtilities.invokeLater {
val hasText = searchField.text.isNotBlank()
if (hasText && allConversations.isNotEmpty()) {
val matchCount = countMatchingConversations(searchField.text)
searchField.textEditor.emptyText.text = formatSearchResultMessage(matchCount)
} else {
searchField.textEditor.emptyText.text =
CodeGPTBundle.get("conversation.searchField.placeholder")
}
}
}
private fun countMatchingConversations(searchText: String): Int {
return allConversations.count { conversation ->
matchesSearchText(conversation, searchText)
}
}
private fun formatSearchResultMessage(count: Int): String {
return if (count == 1) {
CodeGPTBundle.get("conversation.searchResult.singular", count)
} else {
CodeGPTBundle.get("conversation.searchResult.plural", count)
}
}
private fun filterConversations(searchText: String): List<Conversation> {
return if (searchText.isBlank()) {
lastSearchText = ""
lastFilteredConversations = null
allConversations
} else if (searchText == lastSearchText && lastFilteredConversations != null) {
lastFilteredConversations!!
} else {
val startList = getOptimizedSearchStartList(searchText)
val filtered = startList.filter { conversation ->
matchesSearchText(conversation, searchText)
}
cacheFilterResults(searchText, filtered)
filtered
}
}
private fun getOptimizedSearchStartList(searchText: String): List<Conversation> {
return if (lastSearchText.isNotEmpty() && searchText.startsWith(lastSearchText) && lastFilteredConversations != null) {
lastFilteredConversations!!
} else {
allConversations
}
}
private fun cacheFilterResults(searchText: String, filtered: List<Conversation>) {
lastSearchText = searchText
lastFilteredConversations = filtered
}
private fun getSortIcon(sortOption: SortOption) = when (sortOption) {
SortOption.UPDATED_DATE_DESC -> AllIcons.ObjectBrowser.SortByType
SortOption.UPDATED_DATE_ASC -> AllIcons.ObjectBrowser.SortByType
SortOption.TITLE_ASC -> AllIcons.ObjectBrowser.Sorted
SortOption.TITLE_DESC -> AllIcons.ObjectBrowser.Sorted
SortOption.MESSAGE_COUNT_DESC -> AllIcons.Actions.ListFiles
SortOption.MESSAGE_COUNT_ASC -> AllIcons.Actions.ListFiles
}
private fun createSortAction(): AnAction {
val sortAction = object : AnAction(
CodeGPTBundle.get("conversation.sortAction.title", sortOption.displayName),
CodeGPTBundle.get("conversation.sortAction.description", sortOption.displayName),
AllIcons.ObjectBrowser.Sorted
) {
override fun actionPerformed(e: AnActionEvent) {
val actionGroup = DefaultActionGroup().apply {
SortOption.entries.forEach { option ->
add(object : AnAction(option.displayName, null, getSortIcon(option)) {
override fun actionPerformed(e: AnActionEvent) {
sortOption = option
sortAndFilterConversations()
}
override fun update(e: AnActionEvent) {
e.presentation.putClientProperty(KEY, sortOption == option)
}
override fun getActionUpdateThread(): ActionUpdateThread {
return ActionUpdateThread.BGT
}
})
}
}
val popup = JBPopupFactory.getInstance()
.createActionGroupPopup(
CodeGPTBundle.get("conversation.sortPopup.title"),
actionGroup,
e.dataContext,
JBPopupFactory.ActionSelectionAid.SPEEDSEARCH,
true
)
val component = e.inputEvent?.component
if (component != null) {
popup.showUnderneathOf(component)
} else {
popup.showInBestPositionFor(e.dataContext)
}
}
override fun update(e: AnActionEvent) {
e.presentation.text =
CodeGPTBundle.get("conversation.sortAction.title", sortOption.displayName)
}
override fun getActionUpdateThread(): ActionUpdateThread {
return ActionUpdateThread.BGT
}
}
return sortAction
}
private fun setupListeners() {
var searchTimer: Timer? = null
searchField.addDocumentListener(object : DocumentAdapter() {
override fun textChanged(e: DocumentEvent) {
updateSearchFieldState()
searchTimer?.stop()
searchTimer = Timer(SEARCH_DEBOUNCE_MS) {
SwingUtilities.invokeLater {
sortAndFilterConversations()
}
}
searchTimer.isRepeats = false
searchTimer.start()
}
})
}
fun getContent(): JPanel {
val panel = SimpleToolWindowPanel(true)
panel.setContent(this)
val actionGroup = DefaultActionGroup("TOOLBAR_ACTION_GROUP", false).apply {
add(object : AnAction(
CodeGPTBundle.get("conversation.refreshAction.title"),
CodeGPTBundle.get("conversation.refreshAction.description"),
AllIcons.Actions.Refresh
) {
override fun actionPerformed(e: AnActionEvent) {
refresh()
}
override fun getActionUpdateThread(): ActionUpdateThread {
return ActionUpdateThread.BGT
}
})
add(createSortAction())
addSeparator()
add(DeleteAllConversationsAction { refresh() })
}
val toolbar = ActionManager.getInstance()
.createActionToolbar("NAVIGATION_BAR_TOOLBAR", actionGroup, true)
toolbar.targetComponent = panel
panel.toolbar = toolbar.component
return panel
}
fun refresh() {
loadConversationsAsync()
}
private fun loadConversationsAsync() {
thread {
val conversations = conversationService.sortedConversations
.filter { it.messages.isNotEmpty() }
.filter { !(it.messages.size == 1 && it.messages[0].response.isNullOrBlank()) }
.toMutableList()
SwingUtilities.invokeLater {
allConversations = conversations
lastSearchText = ""
lastFilteredConversations = null
isDataLoaded = true
sortAndFilterConversations()
}
}
}
private fun sortAndFilterConversations() {
val searchText = searchField.text
val filteredConversations = filterConversations(searchText)
val sortedConversations = applySorting(filteredConversations)
updateList(sortedConversations)
}
private fun applySorting(conversations: List<Conversation>): List<Conversation> {
return when (sortOption) {
SortOption.UPDATED_DATE_DESC -> conversations.sortedByDescending { it.updatedOn }
SortOption.UPDATED_DATE_ASC -> conversations.sortedBy { it.updatedOn }
SortOption.TITLE_ASC -> conversations.sortedBy { getConversationDisplayTitle(it).lowercase() }
SortOption.TITLE_DESC -> conversations.sortedByDescending {
getConversationDisplayTitle(
it
).lowercase()
}
SortOption.MESSAGE_COUNT_DESC -> conversations.sortedByDescending { it.messages.size }
SortOption.MESSAGE_COUNT_ASC -> conversations.sortedBy { it.messages.size }
}
}
private fun getConversationDisplayTitle(conversation: Conversation): String {
return conversation.title?.takeIf { it.isNotBlank() }
?: conversation.messages.firstOrNull()?.prompt?.take(50)
?: CodeGPTBundle.get("conversation.defaultTitle")
}
private fun matchesSearchText(conversation: Conversation, searchText: String): Boolean {
val searchLower = searchText.lowercase()
return conversation.title?.lowercase()?.contains(searchLower) == true ||
conversation.messages.take(MAX_MESSAGES_TO_SEARCH).any { message ->
message.prompt?.lowercase()?.contains(searchLower) == true ||
message.response?.lowercase()?.contains(searchLower) == true
}
}
private fun updateList(conversations: List<Conversation>) {
SwingUtilities.invokeLater {
chatHistoryListPanel.setConversations(conversations)
statusLabel.text = createStatusMessage(conversations)
updateSelectedConversation(conversations)
}
}
private fun createStatusMessage(conversations: List<Conversation>): String {
val searchText = searchField.text
return buildString {
append(getConversationCountMessage(conversations, searchText))
append("")
append(CodeGPTBundle.get("conversation.status.sortedBy", sortOption.displayName))
}
}
private fun getConversationCountMessage(
conversations: List<Conversation>,
searchText: String
): String {
return if (searchText.isNotBlank()) {
CodeGPTBundle.get(
"conversation.status.searchResult",
conversations.size,
allConversations.size
)
} else {
if (conversations.size == 1) {
CodeGPTBundle.get("conversation.status.count.singular", conversations.size)
} else {
CodeGPTBundle.get("conversation.status.count.plural", conversations.size)
}
}
}
private fun updateSelectedConversation(conversations: List<Conversation>) {
ConversationsState.getCurrentConversation()?.let { current ->
conversations.find { it.id == current.id }?.let { conversation ->
chatHistoryListPanel.setSelectedConversation(conversation)
}
}
}
private fun deleteConversation(conversation: Conversation) {
val result = JOptionPane.showConfirmDialog(
this,
CodeGPTBundle.get("conversation.deleteConfirmation.message"),
CodeGPTBundle.get("conversation.deleteConfirmation.title"),
JOptionPane.YES_NO_OPTION,
JOptionPane.WARNING_MESSAGE
)
if (result == JOptionPane.YES_OPTION) {
conversationService.deleteConversation(conversation)
refresh()
}
}
}

View file

@ -370,3 +370,28 @@ settings.models.chat.section.title=Chat
settings.models.chat.section.description=Models for conversations, code edits, auto applies, commits, and naming suggestions. <a href="https://docs.tryproxy.io/editor/chat/overview">Learn more</a>
settings.models.tab.section.title=Tab
settings.models.tab.section.description=Models for autocomplete and multi-line next edit suggestions. <a href="https://docs.tryproxy.io/editor/tab/overview">Learn more</a>
conversation.deleteButton.tooltip=Delete conversation
conversation.messageCount.singular={0} message
conversation.messageCount.plural={0} messages
conversation.defaultTitle=New Conversation
conversation.emptyState=Your conversations will be saved here for easy access.
conversation.searchField.placeholder=Search in titles and messages...
conversation.searchResult.singular={0} match found
conversation.searchResult.plural={0} matches found
conversation.sortOption.recentlyUpdated=Recently Updated
conversation.sortOption.oldestFirst=Oldest First
conversation.sortOption.titleAscending=Title (A-Z)
conversation.sortOption.titleDescending=Title (Z-A)
conversation.sortOption.mostMessages=Most Messages
conversation.sortOption.leastMessages=Least Messages
conversation.sortAction.title=Sort: {0}
conversation.sortAction.description=Sort conversations by {0}
conversation.sortPopup.title=Sort By
conversation.refreshAction.title=Refresh
conversation.refreshAction.description=Refresh conversation list
conversation.status.searchResult=Found {0} of {1} conversations
conversation.status.count.singular={0} conversation
conversation.status.count.plural={0} conversations
conversation.status.sortedBy=Sorted by: {0}
conversation.deleteConfirmation.message=Are you sure you want to delete this conversation?
conversation.deleteConfirmation.title=Delete Conversation