fix: defer toolwindow tab creation and allow closing first/last tab (relates #865)

This commit is contained in:
Carl-Robert Linnupuu 2026-03-10 14:30:49 +00:00
parent 00775d38da
commit 0fffa2eac2
15 changed files with 426 additions and 184 deletions

View file

@ -9,7 +9,7 @@ public class RenameSessionAction {
private static final int MAX_NAME_LENGTH = 50;
public static void renameSession(ChatToolWindowTabbedPane tabbedPane, int tabIndex) {
if (tabIndex <= 0) {
if (tabIndex < 0) {
return;
}
@ -44,4 +44,4 @@ public class RenameSessionAction {
return checkInput(inputString);
}
}
}
}

View file

@ -15,8 +15,6 @@ import ee.carlrobert.codegpt.CodeGPTBundle;
import ee.carlrobert.codegpt.Icons;
import ee.carlrobert.codegpt.completions.ConversationType;
import ee.carlrobert.codegpt.conversations.Conversation;
import ee.carlrobert.codegpt.conversations.ConversationService;
import ee.carlrobert.codegpt.conversations.ConversationsState;
import ee.carlrobert.codegpt.conversations.message.Message;
import ee.carlrobert.codegpt.settings.prompts.PromptsSettings;
import java.util.Arrays;
@ -48,13 +46,12 @@ public final class ChatToolWindowContentManager {
.getState()
.getChatActions()
.getStartInNewWindow();
if (startInNewWindow || ConversationsState.getCurrentConversation() == null) {
if (startInNewWindow) {
createNewTabPanel().sendMessage(message, conversationType);
return;
}
tryFindChatTabbedPane()
.map(tabbedPane -> tabbedPane.tryFindActiveTabPanel().orElseGet(this::createNewTabPanel))
tryFindActiveChatTabPanel()
.orElseGet(this::createNewTabPanel)
.sendMessage(message, conversationType);
}
@ -65,23 +62,18 @@ public final class ChatToolWindowContentManager {
public void displayConversation(@NotNull Conversation conversation) {
displayChatTab();
tryFindChatTabbedPane()
.ifPresent(tabbedPane -> tabbedPane.tryFindTabTitle(conversation.getId())
.ifPresentOrElse(
title -> tabbedPane.setSelectedIndex(tabbedPane.indexOfTab(title)),
() -> tabbedPane.addNewTab(new ChatToolWindowTabPanel(project, conversation))));
tryFindChatToolWindowPanel().ifPresent(chatPanel -> chatPanel.getChatTabbedPane()
.tryFindTabTitle(conversation.getId())
.ifPresentOrElse(
title -> chatPanel.getChatTabbedPane()
.setSelectedIndex(chatPanel.getChatTabbedPane().indexOfTab(title)),
() -> chatPanel.createAndSelectConversationTab(conversation)));
}
public ChatToolWindowTabPanel createNewTabPanel() {
displayChatTab();
return tryFindChatTabbedPane()
.map(item -> {
var panel = new ChatToolWindowTabPanel(
project,
ConversationService.getInstance().startConversation(project));
item.addNewTab(panel);
return panel;
})
return tryFindChatToolWindowPanel()
.map(ChatToolWindowPanel::createAndSelectNewTabPanel)
.orElseThrow();
}
@ -113,12 +105,7 @@ public final class ChatToolWindowContentManager {
}
public void resetAll() {
tryFindChatTabbedPane().ifPresent(tabbedPane -> {
tabbedPane.clearAll();
tabbedPane.addNewTab(new ChatToolWindowTabPanel(
project,
ConversationService.getInstance().startConversation(project)));
});
tryFindChatTabbedPane().ifPresent(ChatToolWindowTabbedPane::clearAll);
}
public @NotNull ToolWindow getToolWindow() {
@ -142,4 +129,4 @@ public final class ChatToolWindowContentManager {
public void clearAllTags() {
tryFindActiveChatTabPanel().ifPresent(ChatToolWindowTabPanel::clearAllTags);
}
}
}

View file

@ -22,9 +22,11 @@ import ee.carlrobert.codegpt.CodeGPTKeys;
import ee.carlrobert.codegpt.actions.toolwindow.ClearChatWindowAction;
import ee.carlrobert.codegpt.actions.toolwindow.CreateNewConversationAction;
import ee.carlrobert.codegpt.actions.toolwindow.OpenInEditorAction;
import ee.carlrobert.codegpt.completions.ConversationType;
import ee.carlrobert.codegpt.conversations.Conversation;
import ee.carlrobert.codegpt.conversations.ConversationService;
import ee.carlrobert.codegpt.conversations.ConversationsState;
import ee.carlrobert.codegpt.conversations.message.Message;
import ee.carlrobert.codegpt.psistructure.models.ClassStructure;
import ee.carlrobert.codegpt.settings.service.FeatureType;
import ee.carlrobert.codegpt.settings.models.ModelSettings;
import ee.carlrobert.codegpt.settings.prompts.PersonaPromptDetailsState;
@ -35,16 +37,25 @@ import ee.carlrobert.codegpt.settings.service.ServiceType;
import ee.carlrobert.codegpt.settings.service.codegpt.CodeGPTUserDetailsNotifier;
import ee.carlrobert.codegpt.toolwindow.chat.ui.ToolWindowFooterNotification;
import ee.carlrobert.codegpt.toolwindow.chat.ui.textarea.AttachImageNotifier;
import java.awt.CardLayout;
import java.nio.file.Path;
import java.util.Set;
import javax.swing.JComponent;
import javax.swing.JPanel;
import org.jetbrains.annotations.NotNull;
public class ChatToolWindowPanel extends SimpleToolWindowPanel {
private static final String LANDING_CARD = "LANDING";
private static final String TABS_CARD = "TABS";
private final ToolWindowFooterNotification imageFileAttachmentNotification;
private final ActionLink upgradePlanLink;
private final ChatToolWindowTabbedPane tabbedPane;
private final JPanel centerPanel;
private final CardLayout centerLayout;
private final Project project;
private ChatToolWindowTabPanel landingPanel;
public ChatToolWindowPanel(
@NotNull Project project,
@ -60,22 +71,16 @@ public class ChatToolWindowPanel extends SimpleToolWindowPanel {
upgradePlanLink.setExternalLinkIcon();
upgradePlanLink.setVisible(false);
var tabPanel = new ChatToolWindowTabPanel(project, getConversation());
tabbedPane = new ChatToolWindowTabbedPane(parentDisposable);
tabbedPane.addNewTab(tabPanel);
tabbedPane.setTabLifecycleCallbacks(this::showTabsView, this::showLandingView);
centerLayout = new CardLayout();
centerPanel = new JPanel(centerLayout);
centerPanel.add(tabbedPane, TABS_CARD);
initToolWindowPanel(project);
initializeEventListeners(project);
Disposer.register(parentDisposable, tabPanel);
}
private Conversation getConversation() {
var conversation = ConversationsState.getCurrentConversation();
if (conversation == null) {
return ConversationService.getInstance().startConversation(project);
}
return conversation;
showLandingView();
Disposer.register(parentDisposable, this::disposeLandingPanel);
}
private void initializeEventListeners(Project project) {
@ -107,6 +112,63 @@ public class ChatToolWindowPanel extends SimpleToolWindowPanel {
return tabbedPane;
}
public ChatToolWindowTabPanel createAndSelectNewTabPanel() {
return createAndSelectConversationTab(ConversationService.getInstance().startConversation(project));
}
public ChatToolWindowTabPanel createAndSelectConversationTab(Conversation conversation) {
var panel = new ChatToolWindowTabPanel(project, conversation);
tabbedPane.addNewTab(panel);
showTabsView();
return panel;
}
public void showTabsView() {
centerLayout.show(centerPanel, TABS_CARD);
}
public void requestFocusForInput() {
tabbedPane.tryFindActiveTabPanel()
.ifPresentOrElse(
ChatToolWindowTabPanel::requestFocusForTextArea,
() -> {
if (landingPanel != null) {
landingPanel.requestFocusForTextArea();
}
});
}
public void showLandingView() {
disposeLandingPanel();
landingPanel = createLandingPanel();
centerPanel.add(landingPanel.getContent(), LANDING_CARD);
centerLayout.show(centerPanel, LANDING_CARD);
landingPanel.requestFocusForTextArea();
centerPanel.revalidate();
centerPanel.repaint();
}
private ChatToolWindowTabPanel createLandingPanel() {
var conversation = ConversationService.getInstance().createConversation();
conversation.setProjectPath(project.getBasePath());
return new ChatToolWindowTabPanel(project, conversation, this::promoteLandingDraftToTab);
}
private void promoteLandingDraftToTab(Message message, Set<ClassStructure> psiStructure) {
var tabPanel = createAndSelectNewTabPanel();
tabPanel.sendMessage(message, ConversationType.DEFAULT, psiStructure);
}
private void disposeLandingPanel() {
if (landingPanel == null) {
return;
}
centerPanel.remove(landingPanel.getContent());
Disposer.dispose(landingPanel);
landingPanel = null;
}
public void clearImageNotifications(Project project) {
imageFileAttachmentNotification.hideNotification();
@ -115,9 +177,7 @@ public class ChatToolWindowPanel extends SimpleToolWindowPanel {
private void initToolWindowPanel(Project project) {
Runnable onAddNewTab = () -> {
tabbedPane.addNewTab(new ChatToolWindowTabPanel(
project,
ConversationService.getInstance().startConversation(project)));
createAndSelectNewTabPanel();
repaint();
revalidate();
};
@ -127,7 +187,7 @@ public class ChatToolWindowPanel extends SimpleToolWindowPanel {
.addToLeft(createActionToolbar(project, tabbedPane, onAddNewTab).getComponent())
.addToRight(upgradePlanLink));
setContent(new BorderLayoutPanel()
.addToCenter(tabbedPane)
.addToCenter(centerPanel)
.addToBottom(imageFileAttachmentNotification));
});
}

View file

@ -99,13 +99,23 @@ public class ChatToolWindowTabPanel implements Disposable {
private final PsiStructureRepository psiStructureRepository;
private final TagManager tagManager;
private final JPanel mcpApprovalContainer;
private final DraftSubmitHandler draftSubmitHandler;
private @Nullable ToolwindowChatCompletionRequestHandler requestHandler;
private final JBLabel loadingLabel;
private final JPanel queuedMessageContainer;
public ChatToolWindowTabPanel(@NotNull Project project, @NotNull Conversation conversation) {
this(project, conversation, null);
}
public ChatToolWindowTabPanel(
@NotNull Project project,
@NotNull Conversation conversation,
@Nullable DraftSubmitHandler draftSubmitHandler
) {
this.project = project;
this.conversation = conversation;
this.draftSubmitHandler = draftSubmitHandler;
this.chatSession = new ChatSession();
conversationService = ConversationService.getInstance();
toolWindowScrollablePanel = new ChatToolWindowScrollablePanel();
@ -614,7 +624,12 @@ public class ChatToolWindowTabPanel implements Disposable {
}
application.invokeLater(() -> {
sendMessage(messageBuilder.build(), ConversationType.DEFAULT, psiStructure);
var message = messageBuilder.build();
if (draftSubmitHandler != null) {
draftSubmitHandler.onDraftSubmit(message, psiStructure);
} else {
sendMessage(message, ConversationType.DEFAULT, psiStructure);
}
});
});
return Unit.INSTANCE;
@ -772,4 +787,10 @@ public class ChatToolWindowTabPanel implements Disposable {
rootPanel.add(createSouthPanel(createUserPromptPanel()), BorderLayout.SOUTH);
return rootPanel;
}
@FunctionalInterface
public interface DraftSubmitHandler {
void onDraftSubmit(Message message, Set<ClassStructure> psiStructure);
}
}

View file

@ -47,6 +47,10 @@ public class ChatToolWindowTabbedPane extends JBTabbedPane {
return o1.compareToIgnoreCase(o2);
});
private final Disposable parentDisposable;
private Runnable onTabsOpened = () -> {
};
private Runnable onAllTabsClosed = () -> {
};
public ChatToolWindowTabbedPane(Disposable parentDisposable) {
this.parentDisposable = parentDisposable;
@ -59,7 +63,13 @@ public class ChatToolWindowTabbedPane extends JBTabbedPane {
return activeTabMapping;
}
public void setTabLifecycleCallbacks(Runnable onTabsOpened, Runnable onAllTabsClosed) {
this.onTabsOpened = onTabsOpened;
this.onAllTabsClosed = onAllTabsClosed;
}
public void addNewTab(ChatToolWindowTabPanel toolWindowPanel) {
var wasEmpty = activeTabMapping.isEmpty();
var tabIndices = activeTabMapping.keySet().toArray(new String[0]);
var nextIndex = 0;
for (String title : tabIndices) {
@ -79,10 +89,11 @@ public class ChatToolWindowTabbedPane extends JBTabbedPane {
super.insertTab(title, null, toolWindowPanel.getContent(), null, nextIndex);
activeTabMapping.put(title, toolWindowPanel);
super.setSelectedIndex(nextIndex);
setTabComponentAt(nextIndex, createCloseableTabButtonPanel(title));
toolWindowPanel.requestFocusForTextArea();
if (nextIndex > 0) {
setTabComponentAt(nextIndex, createCloseableTabButtonPanel(title));
toolWindowPanel.requestFocusForTextArea();
if (wasEmpty) {
onTabsOpened.run();
}
Disposer.register(parentDisposable, toolWindowPanel);
@ -122,8 +133,14 @@ public class ChatToolWindowTabbedPane extends JBTabbedPane {
}
public void clearAll() {
if (activeTabMapping.isEmpty()) {
return;
}
activeTabMapping.values().forEach(Disposer::dispose);
removeAll();
activeTabMapping.clear();
onAllTabsClosed.run();
}
public void renameTab(int tabIndex, String newName) {
@ -142,9 +159,7 @@ public class ChatToolWindowTabbedPane extends JBTabbedPane {
setTitleAt(tabIndex, uniqueName);
if (tabIndex > 0) {
setTabComponentAt(tabIndex, createCloseableTabButtonPanel(uniqueName));
}
setTabComponentAt(tabIndex, createCloseableTabButtonPanel(uniqueName));
activeTabMapping.remove(oldTitle);
activeTabMapping.put(uniqueName, panel);
@ -187,9 +202,7 @@ public class ChatToolWindowTabbedPane extends JBTabbedPane {
public void resetCurrentlyActiveTabPanel(Project project) {
tryFindActiveTabPanel().ifPresent(tabPanel -> {
Disposer.dispose(tabPanel);
activeTabMapping.remove(getTitleAt(getSelectedIndex()));
removeTabAt(getSelectedIndex());
closeTabAt(getSelectedIndex());
addNewTab(new ChatToolWindowTabPanel(
project,
ConversationService.getInstance().startConversation(project)));
@ -225,9 +238,7 @@ public class ChatToolWindowTabbedPane extends JBTabbedPane {
public void actionPerformed(ActionEvent evt) {
var tabIndex = indexOfTab(title);
if (tabIndex >= 0) {
Disposer.dispose(activeTabMapping.get(title));
removeTabAt(tabIndex);
activeTabMapping.remove(title);
closeTabAt(tabIndex);
}
}
}
@ -238,22 +249,34 @@ public class ChatToolWindowTabbedPane extends JBTabbedPane {
TabPopupMenu() {
add(createPopupMenuItem("Rename Title", e -> {
if (selectedPopupTabIndex > 0) {
if (selectedPopupTabIndex >= 0) {
RenameSessionAction.renameSession(ChatToolWindowTabbedPane.this, selectedPopupTabIndex);
}
}));
addSeparator();
add(createPopupMenuItem("Close", e -> {
if (selectedPopupTabIndex > 0) {
activeTabMapping.remove(getTitleAt(selectedPopupTabIndex));
removeTabAt(selectedPopupTabIndex);
if (selectedPopupTabIndex >= 0) {
closeTabAt(selectedPopupTabIndex);
}
}));
add(createPopupMenuItem("Close Other Tabs", e -> {
if (selectedPopupTabIndex < 0) {
return;
}
var selectedPopupTabTitle = getTitleAt(selectedPopupTabIndex);
var tabPanel = activeTabMapping.get(selectedPopupTabTitle);
if (tabPanel == null) {
return;
}
clearAll();
activeTabMapping.entrySet().stream()
.filter(entry -> !entry.getKey().equals(selectedPopupTabTitle))
.map(Map.Entry::getValue)
.forEach(Disposer::dispose);
removeAll();
activeTabMapping.clear();
addNewTab(tabPanel);
}));
}
@ -262,7 +285,7 @@ public class ChatToolWindowTabbedPane extends JBTabbedPane {
public void show(Component invoker, int x, int y) {
selectedPopupTabIndex = ChatToolWindowTabbedPane.this.getUI()
.tabForCoordinate(ChatToolWindowTabbedPane.this, x, y);
if (selectedPopupTabIndex > 0) {
if (selectedPopupTabIndex >= 0) {
super.show(invoker, x, y);
}
}
@ -273,4 +296,22 @@ public class ChatToolWindowTabbedPane extends JBTabbedPane {
return menuItem;
}
}
private void closeTabAt(int tabIndex) {
if (tabIndex < 0 || tabIndex >= getTabCount()) {
return;
}
var title = getTitleAt(tabIndex);
var panel = activeTabMapping.remove(title);
if (panel != null) {
Disposer.dispose(panel);
}
removeTabAt(tabIndex);
if (activeTabMapping.isEmpty()) {
onAllTabsClosed.run();
}
}
}

View file

@ -1,21 +1,8 @@
package ee.carlrobert.codegpt.toolwindow.chat.ui;
import static javax.swing.event.HyperlinkEvent.EventType.ACTIVATED;
import com.intellij.openapi.options.ShowSettingsUtil;
import com.intellij.openapi.roots.ui.componentsList.components.ScrollablePanel;
import com.intellij.openapi.roots.ui.componentsList.layout.VerticalStackLayout;
import com.intellij.ui.JBColor;
import com.intellij.util.ui.JBUI;
import ee.carlrobert.codegpt.credentials.CredentialsStore;
import ee.carlrobert.codegpt.credentials.CredentialsStore.CredentialKey;
import ee.carlrobert.codegpt.settings.service.FeatureType;
import ee.carlrobert.codegpt.settings.models.ModelSettings;
import ee.carlrobert.codegpt.settings.service.ServiceType;
import ee.carlrobert.codegpt.settings.service.codegpt.CodeGPTServiceConfigurable;
import ee.carlrobert.codegpt.toolwindow.ui.ResponseMessagePanel;
import ee.carlrobert.codegpt.ui.UIUtil;
import ee.carlrobert.codegpt.util.ApplicationUtil;
import java.awt.Rectangle;
import java.util.Arrays;
import java.util.HashMap;
@ -38,34 +25,6 @@ public class ChatToolWindowScrollablePanel extends ScrollablePanel {
clearAll();
add(landingView);
landingViewVisible = true;
if (ModelSettings.getInstance().getServiceForFeature(FeatureType.CHAT)
== ServiceType.PROXYAI
&& !CredentialsStore.INSTANCE.isCredentialSet(CredentialKey.CodeGptApiKey.INSTANCE)) {
var panel = new ResponseMessagePanel();
panel.addContent(UIUtil.createTextPane("""
<html>
<p style="margin-top: 4px; margin-bottom: 4px;">
It looks like you haven't configured your API key yet. Visit <a href="#OPEN_SETTINGS">ProxyAI settings</a> to do so.
</p>
<p style="margin-top: 4px; margin-bottom: 4px;">
Don't have an account? <a href="https://tryproxy.io/signin">Sign up</a> to get started.
</p>
</html>""",
false,
event -> {
if (ACTIVATED.equals(event.getEventType())
&& "#OPEN_SETTINGS".equals(event.getDescription())) {
ShowSettingsUtil.getInstance().showSettingsDialog(
ApplicationUtil.findCurrentProject(),
CodeGPTServiceConfigurable.class);
} else {
UIUtil.handleHyperlinkClicked(event);
}
}));
panel.setBorder(JBUI.Borders.customLine(JBColor.border(), 1, 0, 0, 0));
add(panel);
}
}
public ResponseMessagePanel getResponseMessagePanel(UUID messageId) {

View file

@ -260,7 +260,6 @@ val Opus_4_6: LLModel = LLModel(
maxOutputTokens = 64_000,
)
private val GPT5_4: LLModel = LLModel(
provider = LLMProvider.OpenAI,
id = "gpt-5.4",

View file

@ -28,8 +28,6 @@ class AgentToolWindowContentManager(private val project: Project) : Disposable {
}
})
createNewAgentTab()
return tabbedPane
}

View file

@ -7,20 +7,38 @@ import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.SimpleToolWindowPanel
import com.intellij.openapi.util.Disposer
import ee.carlrobert.codegpt.conversations.Conversation
import com.intellij.util.ui.components.BorderLayoutPanel
import ee.carlrobert.codegpt.toolwindow.agent.ui.AgentCreditsToolbarLabel
import java.awt.CardLayout
import java.util.UUID
import javax.swing.JComponent
import javax.swing.JPanel
class AgentToolWindowPanel(
private val project: Project
) : SimpleToolWindowPanel(true), Disposable {
companion object {
private const val LANDING_CARD = "LANDING"
private const val TABS_CARD = "TABS"
}
private val contentManager = project.service<AgentToolWindowContentManager>()
private val tabbedPane = contentManager.initializeTabbedPane()
private val centerLayout = CardLayout()
private val centerPanel = JPanel(centerLayout)
private var landingPanel: AgentToolWindowTabPanel? = null
init {
tabbedPane.setTabLifecycleCallbacks(
onTabsOpened = { showTabsView() },
onAllTabsClosed = { showLandingView() }
)
centerPanel.add(tabbedPane, TABS_CARD)
toolbar = createToolbar()
setContent(tabbedPane)
setContent(centerPanel)
showLandingView()
}
private fun createToolbar(): JComponent {
@ -66,7 +84,45 @@ class AgentToolWindowPanel(
fun getTabbedPane(): AgentToolWindowTabbedPane = tabbedPane
private fun showTabsView() {
centerLayout.show(centerPanel, TABS_CARD)
}
private fun showLandingView() {
disposeLandingPanel()
landingPanel = createLandingPanel()
centerPanel.add(landingPanel, LANDING_CARD)
centerLayout.show(centerPanel, LANDING_CARD)
landingPanel?.requestFocusForTextArea()
centerPanel.revalidate()
centerPanel.repaint()
}
private fun createLandingPanel(): AgentToolWindowTabPanel {
val draftSession = AgentSession(
sessionId = UUID.randomUUID().toString(),
conversation = Conversation()
)
return AgentToolWindowTabPanel(
project = project,
agentSession = draftSession,
draftSubmitHandler = { message ->
val panel = contentManager.createNewAgentTab()
panel.submitMessage(message)
}
)
}
private fun disposeLandingPanel() {
val current = landingPanel ?: return
centerPanel.remove(current)
Disposer.dispose(current)
landingPanel = null
}
override fun dispose() {
tabbedPane.setTabLifecycleCallbacks(onTabsOpened = {}, onAllTabsClosed = {})
disposeLandingPanel()
tabbedPane.dispose()
}
}

View file

@ -57,7 +57,8 @@ import javax.swing.JPanel
class AgentToolWindowTabPanel(
private val project: Project,
private val agentSession: AgentSession
private val agentSession: AgentSession,
private val draftSubmitHandler: ((MessageWithContext) -> Unit)? = null
) : BorderLayoutPanel(), Disposable {
companion object {
private const val RECOVERED_CONVERSATION_RENDER_BATCH_SIZE = 6
@ -274,6 +275,16 @@ class AgentToolWindowTabPanel(
private fun handleSubmit(text: String) {
if (text.isBlank()) return
val message = MessageWithContext(text, userInputPanel.getSelectedTags())
if (draftSubmitHandler != null) {
draftSubmitHandler.invoke(message)
return
}
submitMessage(message)
}
fun submitMessage(message: MessageWithContext) {
if (message.text.isBlank()) return
disposeLandingPanelIfPresent()
scrollablePanel.clearLandingViewIfVisible()
agentSession.serviceType =
@ -282,16 +293,12 @@ class AgentToolWindowTabPanel(
val agentService = project.service<AgentService>()
if (agentService.isSessionRunning(sessionId)) {
addQueuedMessage(text)
addQueuedMessage(message.text)
userInputPanel.clearText()
userInputPanel.setSubmitEnabled(true)
userInputPanel.setStopEnabled(true)
agentService.submitMessage(
MessageWithContext(text, userInputPanel.getSelectedTags()),
eventHandler,
sessionId
)
agentService.submitMessage(message, eventHandler, sessionId)
return
}
@ -303,11 +310,10 @@ class AgentToolWindowTabPanel(
val rollbackRunId = rollbackService.startSession(sessionId)
rollbackPanel.refreshOperations()
val message = MessageWithContext(text, userInputPanel.getSelectedTags())
val messagePanel = scrollablePanel.addMessage(message.id)
val userPanel = UserMessagePanel(
project,
MessageBuilder(project, text).withTags(userInputPanel.getSelectedTags()).build(),
MessageBuilder(project, message.text).withTags(message.tags).build(),
this
)
val responsePanel = ResponseMessagePanel()
@ -322,7 +328,7 @@ class AgentToolWindowTabPanel(
)
responsePanel.setResponseContent(responseBody)
userPanel.addCopyAction { CopyAction.copyToClipboard(text) }
userPanel.addCopyAction { CopyAction.copyToClipboard(message.text) }
messagePanel.add(userPanel)
messagePanel.add(responsePanel)
scrollablePanel.update()
@ -331,7 +337,7 @@ class AgentToolWindowTabPanel(
runMessageId = message.id,
rollbackRunId = rollbackRunId,
responsePanel = responsePanel,
prompt = text
prompt = message.text
)
eventHandler.resetForNewSubmission()

View file

@ -42,6 +42,8 @@ class AgentToolWindowTabbedPane(private val project: Project) : JBTabbedPane(),
)
private var isTabActive = true
private var onTabsOpened: () -> Unit = {}
private var onAllTabsClosed: () -> Unit = {}
init {
tabComponentInsets = null
@ -63,6 +65,11 @@ class AgentToolWindowTabbedPane(private val project: Project) : JBTabbedPane(),
private val sessionStates = mutableMapOf<String, TabState>()
fun setTabLifecycleCallbacks(onTabsOpened: () -> Unit, onAllTabsClosed: () -> Unit) {
this.onTabsOpened = onTabsOpened
this.onAllTabsClosed = onAllTabsClosed
}
fun updateStatusForSession(sessionId: String, status: TabStatus) {
val state = sessionStates.getOrPut(sessionId) { TabState() }
state.status = status
@ -129,6 +136,7 @@ class AgentToolWindowTabbedPane(private val project: Project) : JBTabbedPane(),
}
fun addNewTab(toolWindowPanel: AgentToolWindowTabPanel, select: Boolean) {
val wasEmpty = activeTabMapping.isEmpty()
val tabIndices = activeTabMapping.keys.toTypedArray()
var nextIndex = 0
@ -156,9 +164,13 @@ class AgentToolWindowTabbedPane(private val project: Project) : JBTabbedPane(),
sessionStates[sessionId] = TabState(status = TabStatus.STOPPED, unseen = false)
setTabComponentAt(nextIndex, createTabButtonPanel(title, nextIndex > 0, TabStatus.STOPPED))
setTabComponentAt(nextIndex, createTabButtonPanel(title, TabStatus.STOPPED))
toolWindowPanel.requestFocusForTextArea()
if (wasEmpty) {
onTabsOpened()
}
Disposer.register(this, toolWindowPanel)
}
@ -190,8 +202,17 @@ class AgentToolWindowTabbedPane(private val project: Project) : JBTabbedPane(),
}
fun clearAll() {
if (activeTabMapping.isEmpty()) {
return
}
activeTabMapping.values.forEach {
project.service<AgentToolWindowContentManager>().removeSession(it.getSessionId())
Disposer.dispose(it)
}
sessionStates.clear()
removeAll()
activeTabMapping.clear()
onAllTabsClosed()
}
fun renameTab(tabIndex: Int, newName: String) {
@ -213,7 +234,7 @@ class AgentToolWindowTabbedPane(private val project: Project) : JBTabbedPane(),
TabStatus.STOPPED
}
setTabComponentAt(tabIndex, createTabButtonPanel(uniqueName, tabIndex > 0, currentStatus))
setTabComponentAt(tabIndex, createTabButtonPanel(uniqueName, currentStatus))
activeTabMapping.remove(oldTitle)
activeTabMapping[uniqueName] = panel
@ -271,7 +292,7 @@ class AgentToolWindowTabbedPane(private val project: Project) : JBTabbedPane(),
}
private fun renameAgentSession(tabIndex: Int) {
if (tabIndex <= 0) {
if (tabIndex < 0) {
return
}
@ -309,14 +330,8 @@ class AgentToolWindowTabbedPane(private val project: Project) : JBTabbedPane(),
fun resetCurrentlyActiveTabPanel() {
tryFindActiveTabPanel().ifPresent { tabPanel ->
val oldSessionId = tabPanel.getSessionId()
val oldDisplayName = tabPanel.getAgentSession().displayName
Disposer.dispose(tabPanel)
activeTabMapping.remove(getTitleAt(selectedIndex))
removeTabAt(selectedIndex)
sessionStates.remove(oldSessionId)
project.service<AgentToolWindowContentManager>().removeSession(oldSessionId)
closeTabAt(selectedIndex)
val newSession = AgentSession(
UUID.randomUUID().toString(),
Conversation(),
@ -330,7 +345,6 @@ class AgentToolWindowTabbedPane(private val project: Project) : JBTabbedPane(),
private fun createTabButtonPanel(
title: String,
closeable: Boolean,
status: TabStatus = TabStatus.STOPPED
): JPanel {
val titleLabel = JBLabel(title).apply {
@ -341,18 +355,16 @@ class AgentToolWindowTabbedPane(private val project: Project) : JBTabbedPane(),
val panel = JBUI.Panels.simplePanel(4, 0)
.addToLeft(titleLabel)
if (closeable) {
val closeIcon = AllIcons.Actions.Close
val button = JButton(closeIcon).apply {
addActionListener(CloseActionListener(title))
preferredSize = Dimension(closeIcon.iconWidth, closeIcon.iconHeight)
border = BorderFactory.createEmptyBorder()
isContentAreaFilled = false
toolTipText = "Close Agent"
rolloverIcon = AllIcons.Actions.CloseHovered
}
panel.addToRight(button)
val closeIcon = AllIcons.Actions.Close
val button = JButton(closeIcon).apply {
addActionListener(CloseActionListener(title))
preferredSize = Dimension(closeIcon.iconWidth, closeIcon.iconHeight)
border = BorderFactory.createEmptyBorder()
isContentAreaFilled = false
toolTipText = "Close Agent"
rolloverIcon = AllIcons.Actions.CloseHovered
}
panel.addToRight(button)
return panel.andTransparent()
}
@ -375,13 +387,7 @@ class AgentToolWindowTabbedPane(private val project: Project) : JBTabbedPane(),
override fun actionPerformed(evt: ActionEvent) {
val tabIndex = indexOfTab(title)
if (tabIndex >= 0) {
activeTabMapping[title]?.let { panel ->
sessionStates.remove(panel.getSessionId())
project.service<AgentToolWindowContentManager>().removeSession(panel.getSessionId())
Disposer.dispose(panel)
}
removeTabAt(tabIndex)
activeTabMapping.remove(title)
closeTabAt(tabIndex)
}
}
}
@ -391,44 +397,46 @@ class AgentToolWindowTabbedPane(private val project: Project) : JBTabbedPane(),
init {
add(createPopupMenuItem("Rename Title") {
if (selectedPopupTabIndex > 0) {
if (selectedPopupTabIndex >= 0) {
renameAgentSession(selectedPopupTabIndex)
}
})
addSeparator()
add(createPopupMenuItem("Close") {
if (selectedPopupTabIndex > 0) {
val title = getTitleAt(selectedPopupTabIndex)
activeTabMapping[title]?.let { panel ->
sessionStates.remove(panel.getSessionId())
project.service<AgentToolWindowContentManager>().removeSession(panel.getSessionId())
Disposer.dispose(panel)
}
removeTabAt(selectedPopupTabIndex)
activeTabMapping.remove(title)
if (selectedPopupTabIndex >= 0) {
closeTabAt(selectedPopupTabIndex)
}
})
add(createPopupMenuItem("Close Other Tabs") {
if (selectedPopupTabIndex < 0) {
return@createPopupMenuItem
}
val selectedPopupTabTitle = getTitleAt(selectedPopupTabIndex)
val tabPanel = activeTabMapping[selectedPopupTabTitle]
val keepSessionId = tabPanel?.getSessionId()
sessionStates.keys
if (tabPanel == null) {
return@createPopupMenuItem
}
val keepSessionId = tabPanel.getSessionId()
sessionStates.keys.toList()
.filter { it != keepSessionId }
.forEach { sessionStates.remove(it) }
activeTabMapping.values
.map { it.getSessionId() }
.filter { it != keepSessionId }
.forEach { project.service<AgentToolWindowContentManager>().removeSession(it) }
activeTabMapping.entries
.filter { it.key != selectedPopupTabTitle }
.forEach { entry ->
project.service<AgentToolWindowContentManager>().removeSession(entry.value.getSessionId())
Disposer.dispose(entry.value)
}
clearAll()
tabPanel?.let { addNewTab(it) }
removeAll()
activeTabMapping.clear()
addNewTab(tabPanel)
})
}
override fun show(invoker: Component, x: Int, y: Int) {
selectedPopupTabIndex = this@AgentToolWindowTabbedPane.getUI()
.tabForCoordinate(this@AgentToolWindowTabbedPane, x, y)
if (selectedPopupTabIndex > 0) {
if (selectedPopupTabIndex >= 0) {
super.show(invoker, x, y)
}
}
@ -443,4 +451,23 @@ class AgentToolWindowTabbedPane(private val project: Project) : JBTabbedPane(),
override fun dispose() {
clearAll()
}
private fun closeTabAt(tabIndex: Int) {
if (tabIndex !in 0 until tabCount) {
return
}
val title = getTitleAt(tabIndex)
val panel = activeTabMapping.remove(title)
if (panel != null) {
sessionStates.remove(panel.getSessionId())
project.service<AgentToolWindowContentManager>().removeSession(panel.getSessionId())
Disposer.dispose(panel)
}
removeTabAt(tabIndex)
if (activeTabMapping.isEmpty()) {
onAllTabsClosed()
}
}
}

View file

@ -43,7 +43,6 @@ import ee.carlrobert.codegpt.ui.UIUtil.createTextPane
import kotlinx.coroutines.Dispatchers
import ee.carlrobert.codegpt.util.coroutines.DisposableCoroutineScope
import com.intellij.openapi.Disposable
import java.awt.BorderLayout
import java.awt.Color
import java.awt.Desktop
import java.net.URI
@ -53,7 +52,7 @@ import javax.swing.Box
import javax.swing.BoxLayout
import javax.swing.JPanel
class AgentToolWindowLandingPanel(private val project: Project) : ResponseMessagePanel(), Disposable {
class AgentToolWindowLandingPanel(private val project: Project) : BorderLayoutPanel(), Disposable {
companion object {
private val logger = thisLogger()
@ -76,37 +75,51 @@ class AgentToolWindowLandingPanel(private val project: Project) : ResponseMessag
}
init {
isOpaque = false
historyListPanel.onOpen = { thread -> openCheckpointThread(thread) }
historyListPanel.onLoadPage = { query, offset, limit, onResult ->
loadHistoryPage(query, offset, limit, onResult)
}
addContent(buildContent())
addToCenter(buildContent())
loadHistory()
}
private fun buildContent(): JPanel {
return BorderLayoutPanel().apply {
border = JBUI.Borders.empty(0)
add(topPanel(), BorderLayout.NORTH)
add(centerPanel(), BorderLayout.CENTER)
return JPanel().apply {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
isOpaque = false
add(primaryMessagePanel())
apiKeyPanel()?.let { add(it) }
}
}
private fun primaryMessagePanel(): ResponseMessagePanel {
return ResponseMessagePanel().apply {
addContent(
BorderLayoutPanel().apply {
border = JBUI.Borders.empty(0)
addToTop(topPanel())
addToCenter(centerPanel())
}
)
}
}
private fun topPanel(): JPanel {
return BorderLayoutPanel().apply {
isOpaque = false
apiKeyPanel()?.let { addToTop(it) }
addToCenter(createTextPane(welcomeMessage(), false))
}
}
private fun apiKeyPanel(): JPanel? {
private fun apiKeyPanel(): ResponseMessagePanel? {
val provider = ModelSettings.getInstance().getServiceForFeature(FeatureType.AGENT)
if (provider != ServiceType.PROXYAI || CredentialsStore.isCredentialSet(CodeGptApiKey)) {
return null
}
return ResponseMessagePanel().apply {
border = JBUI.Borders.customLine(JBColor.border(), 1, 0, 0, 0)
addContent(
createTextPane(
"""
@ -133,7 +146,6 @@ class AgentToolWindowLandingPanel(private val project: Project) : ResponseMessag
}
}
)
border = JBUI.Borders.customLine(JBColor.border(), 1, 0, 0, 0)
}
}
@ -304,7 +316,7 @@ class AgentToolWindowLandingPanel(private val project: Project) : ResponseMessag
private fun refresh() {
removeAll()
addContent(buildContent())
addToCenter(buildContent())
loadHistory()
revalidate()
repaint()

View file

@ -14,10 +14,8 @@ class ChatToolWindowListener : ToolWindowManagerListener {
private fun requestFocusForTextArea(project: Project) {
val contentManager = project.getService(ChatToolWindowContentManager::class.java)
contentManager.tryFindChatTabbedPane().ifPresent { tabbedPane ->
tabbedPane.tryFindActiveTabPanel().ifPresent { tabPanel ->
tabPanel.requestFocusForTextArea()
}
contentManager.tryFindChatToolWindowPanel().ifPresent { panel ->
panel.requestFocusForInput()
}
}
}

View file

@ -1,12 +1,22 @@
package ee.carlrobert.codegpt.toolwindow.ui
import com.intellij.openapi.options.ShowSettingsUtil
import com.intellij.ui.JBColor
import com.intellij.ui.components.ActionLink
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.components.BorderLayoutPanel
import ee.carlrobert.codegpt.Icons
import ee.carlrobert.codegpt.credentials.CredentialsStore
import ee.carlrobert.codegpt.credentials.CredentialsStore.CredentialKey.CodeGptApiKey
import ee.carlrobert.codegpt.settings.GeneralSettings
import ee.carlrobert.codegpt.settings.prompts.ChatActionsState
import ee.carlrobert.codegpt.settings.models.ModelSettings
import ee.carlrobert.codegpt.settings.service.FeatureType
import ee.carlrobert.codegpt.settings.service.ServiceType
import ee.carlrobert.codegpt.settings.service.codegpt.CodeGPTServiceConfigurable
import ee.carlrobert.codegpt.ui.UIUtil
import ee.carlrobert.codegpt.ui.UIUtil.createTextPane
import ee.carlrobert.codegpt.util.ApplicationUtil
import java.awt.BorderLayout
import java.awt.Point
import java.awt.event.ActionListener
@ -22,12 +32,57 @@ class ChatToolWindowLandingPanel(onAction: (LandingPanelAction, Point) -> Unit)
private fun createContent(onAction: (LandingPanelAction, Point) -> Unit): JPanel {
return BorderLayoutPanel().apply {
add(createTextPane(getWelcomeMessage(), false), BorderLayout.NORTH)
add(
BorderLayoutPanel().apply {
isOpaque = false
apiKeyPanel()?.let { addToTop(it) }
addToCenter(createTextPane(getWelcomeMessage(), false))
},
BorderLayout.NORTH
)
add(createActionsListPanel(onAction), BorderLayout.CENTER)
add(createTextPane(getCautionMessage(), false), BorderLayout.SOUTH)
}
}
private fun apiKeyPanel(): JPanel? {
val provider = ModelSettings.getInstance().getServiceForFeature(FeatureType.CHAT)
if (provider != ServiceType.PROXYAI || CredentialsStore.isCredentialSet(CodeGptApiKey)) {
return null
}
return BorderLayoutPanel().apply {
isOpaque = false
addToCenter(
createTextPane(
"""
<html>
<p style="margin-top: 4px; margin-bottom: 4px;">
It looks like you haven't configured your API key yet. Visit <a href="#OPEN_SETTINGS">ProxyAI settings</a> to do so.
</p>
<p style="margin-top: 4px; margin-bottom: 4px;">
Don't have an account? <a href="https://tryproxy.io/signin">Sign up</a> to get started.
</p>
</html>
""".trimIndent(),
false
) { event ->
if (event.eventType == javax.swing.event.HyperlinkEvent.EventType.ACTIVATED &&
event.description == "#OPEN_SETTINGS"
) {
ShowSettingsUtil.getInstance().showSettingsDialog(
ApplicationUtil.findCurrentProject(),
CodeGPTServiceConfigurable::class.java
)
} else {
UIUtil.handleHyperlinkClicked(event)
}
}
)
border = JBUI.Borders.customLine(JBColor.border(), 1, 0, 0, 0)
}
}
private fun createActionsListPanel(onAction: (LandingPanelAction, Point) -> Unit): JPanel {
val listPanel = JPanel()
listPanel.layout = BoxLayout(listPanel, BoxLayout.PAGE_AXIS)
@ -95,4 +150,3 @@ enum class LandingPanelAction(
ChatActionsState.DEFAULT_EXPLAIN_PROMPT
)
}

View file

@ -5,6 +5,7 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase
import ee.carlrobert.codegpt.conversations.ConversationService
import ee.carlrobert.codegpt.conversations.message.Message
import org.assertj.core.api.Assertions.assertThat
import java.awt.event.ActionEvent
class ChatToolWindowTabbedPaneTest : BasePlatformTestCase() {
@ -40,6 +41,29 @@ class ChatToolWindowTabbedPaneTest : BasePlatformTestCase() {
assertThat(tabPanel!!.conversation.messages).isEmpty()
}
fun testCanCloseFirstTabWhenMultipleTabsExist() {
val tabbedPane = ChatToolWindowTabbedPane(Disposer.newDisposable())
tabbedPane.addNewTab(createNewTabPanel())
tabbedPane.addNewTab(createNewTabPanel())
tabbedPane.CloseActionListener("Chat 1")
.actionPerformed(ActionEvent(tabbedPane, ActionEvent.ACTION_PERFORMED, "close"))
assertThat(tabbedPane.activeTabMapping.keys).containsExactly("Chat 2")
assertThat(tabbedPane.tabCount).isEqualTo(1)
}
fun testCanCloseLastRemainingTab() {
val tabbedPane = ChatToolWindowTabbedPane(Disposer.newDisposable())
tabbedPane.addNewTab(createNewTabPanel())
tabbedPane.CloseActionListener("Chat 1")
.actionPerformed(ActionEvent(tabbedPane, ActionEvent.ACTION_PERFORMED, "close"))
assertThat(tabbedPane.activeTabMapping).isEmpty()
assertThat(tabbedPane.tabCount).isZero()
}
private fun createNewTabPanel(): ChatToolWindowTabPanel {
return ChatToolWindowTabPanel(
project,