llm: add tabbed support to LLM Chat panel#7176
Conversation
Signed-off-by: Simon Bennetts <psiinon@gmail.com>
|
New Issues (2)Checkmarx found the following issues in this Pull Request
Fixed Issues (3)Great job! The following issues were fixed in this Pull Request
Use @Checkmarx to interact with Checkmarx PR Assistant. |
Signed-off-by: Simon Bennetts <psiinon@gmail.com>
|
Ready for review |
kingthorin
left a comment
There was a problem hiding this comment.
I haven't pulled the branch and tested, this is the only issue I see (very minor).
kingthorin
left a comment
There was a problem hiding this comment.
This seems good to me, just that one non-blocking date thing.
There was a problem hiding this comment.
Pull request overview
This PR introduces multi-conversation support to the LLM add-on by converting the LLM Chat panel into a tabbed UI, and updates the add-on’s LLM communication listeners to surface request/response output in the chat UI.
Changes:
- Refactors the LLM Chat panel into a tabbed interface with per-tab conversation panels, plus-tab creation, renaming, and close controls.
- Reworks LLM communication listeners/handlers to write request/response events to the chat UI (GUI) or to logs (headless).
- Updates i18n strings, help documentation, and the add-on changelog to reflect tabbed chat support.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| addOns/llm/src/main/resources/org/zaproxy/addon/llm/resources/Messages.properties | Adds new i18n keys for tab rename and error labeling. |
| addOns/llm/src/main/javahelp/org/zaproxy/addon/llm/resources/help/contents/chat.html | Documents new multi-tab chat behavior and context menu behavior per selected tab. |
| addOns/llm/src/main/java/org/zaproxy/addon/llm/ui/LlmNumberedRenamableTabbedPane.java | New tabbed container implementation (plus tab, renaming, tagged tabs). |
| addOns/llm/src/main/java/org/zaproxy/addon/llm/ui/LlmCloseTabPanel.java | New custom tab header with close button and title label. |
| addOns/llm/src/main/java/org/zaproxy/addon/llm/ui/LlmChatTabPanel.java | New per-tab chat UI panel with its own input/output and conversation sending. |
| addOns/llm/src/main/java/org/zaproxy/addon/llm/ui/LlmChatPanel.java | Refactors main chat panel into a container hosting the new tabbed UI. |
| addOns/llm/src/main/java/org/zaproxy/addon/llm/services/LlmLogResponseHandler.java | New headless/log-only listener implementation. |
| addOns/llm/src/main/java/org/zaproxy/addon/llm/services/LlmGuiResponseHandler.java | Refactors GUI listener to append LLM request/response events into chat tabs. |
| addOns/llm/src/main/java/org/zaproxy/addon/llm/services/LlmCommunicationService.java | Updates service to accept a generic ChatModelListener and wires it into the model. |
| addOns/llm/src/main/java/org/zaproxy/addon/llm/ExtensionLlm.java | Tracks the chat panel instance and wires GUI vs log listeners when creating comms services. |
| addOns/llm/CHANGELOG.md | Updates add-on description to “tabbed LLM Chat panel.” |
| addOns/alertFilters/src/main/java/org/zaproxy/zap/extension/alertFilters/llm/LlmActionReviewAlert.java | Removes call to switch focus to the Output tab (API removed). |
Comments suppressed due to low confidence (2)
addOns/llm/src/main/java/org/zaproxy/addon/llm/services/LlmGuiResponseHandler.java:47
- This listener updates Swing UI state (showTab(), setProcessing()) directly from the model callback thread. Swing components must only be accessed on the EDT; wrap these calls (and any direct UI state changes) in SwingUtilities.invokeLater/invokeAndWait. Also, chatPanel can be null (e.g. if tab lookup fails), which would cause an immediate NPE; add a null guard/fallback handler.
addOns/llm/src/main/java/org/zaproxy/addon/llm/services/LlmCommunicationService.java:86 - ChatModel is a static field but each LlmCommunicationService instance builds the model with an instance-specific listener (and provider config). With multiple concurrent comms services/tabs, the most recently constructed service will overwrite the static model (and its listeners), causing responses/events to be delivered to the wrong UI tab and mixing contexts. Make the ChatModel an instance field (or cache models by provider/config without embedding per-tab listeners) so each service can have an independent listener/context.
private LlmAssistant llmAssistant;
private ChatModelListener listener;
@Getter private LlmProviderConfig pconf;
@Getter private String modelName;
private Requestor requestor;
private static ChatModel model;
private static ObjectMapper objectMapper = new ObjectMapper();
private static ObjectWriter prettyWriter = objectMapper.writerWithDefaultPrettyPrinter();
private ChatMemory chatMemory;
public LlmCommunicationService(
LlmProviderConfig pconf, String modelName, ChatModelListener listener) {
this.pconf = pconf;
this.modelName = modelName;
this.listener = listener;
chatMemory = MessageWindowChatMemory.withMaxMessages(10);
model = buildModel();
llmAssistant =
AiServices.builder(LlmAssistant.class)
.chatModel(model)
.chatMemory(chatMemory)
.build();
requestor = new Requestor(HttpSender.MANUAL_REQUEST_INITIATOR, new HistoryPersister());
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| addChangeListener( | ||
| new ChangeListener() { | ||
| private boolean adding = false; | ||
|
|
||
| @Override | ||
| public void stateChanged(ChangeEvent e) { | ||
| LlmNumberedRenamableTabbedPane ntp = | ||
| (LlmNumberedRenamableTabbedPane) e.getSource(); | ||
| if (!adding && ntp.getSelectedIndex() == ntp.getTabCount() - 1) { | ||
| adding = true; | ||
| ntp.addDefaultTab(); | ||
| adding = false; | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| addMouseListener( | ||
| new MouseAdapter() { | ||
| @Override | ||
| public void mouseClicked(MouseEvent evt) { | ||
| if (evt.getClickCount() == 2) { | ||
| int index = indexAtLocation(evt.getX(), evt.getY()); | ||
| if (index > -1 && index < getTabCount() - 1) { | ||
| Component comp = getTabComponentAt(index); | ||
| if (comp != null) { | ||
| String newName = | ||
| JOptionPane.showInputDialog( | ||
| Constant.messages.getString( | ||
| "llm.chat.tab.rename"), | ||
| comp.getName()); | ||
| if (!StringUtils.isEmpty(newName)) { | ||
| comp.setName(newName); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| addTab("", PLUS_ICON != null ? PLUS_ICON : null, hiddenComponent); | ||
| } |
| public void addDefaultTab() { | ||
| addTab(nextTabName()); | ||
| } | ||
|
|
||
| public LlmChatTabPanel addTab(String tabName) { | ||
| return addTab("CHAT-" + tabName, tabName); | ||
| } | ||
|
|
||
| public LlmChatTabPanel addTab(String tag, String tabName) { | ||
| int index = getTabCount() - 1; | ||
| LlmChatTabPanel pane = new LlmChatTabPanel(extension, tag); | ||
| insertTab(tabName, null, pane, null, index); | ||
| setTabComponentAt(index, new LlmCloseTabPanel(tabName, this, tag)); | ||
| setSelectedIndex(index); | ||
| return pane; | ||
| } | ||
|
|
||
| public LlmChatTabPanel getTaggedTab(String tag, String tabName) { | ||
| return taggedTabs.computeIfAbsent(tag, k -> addTab(tag, tabName)); | ||
| } |
| public LlmCloseTabPanel(String tabName, LlmNumberedRenamableTabbedPane tabbedPane, String tag) { | ||
| super(); | ||
| this.setOpaque(false); | ||
| lblTitle = new JLabel(tabName); | ||
| this.tag = tag; | ||
| JButton btnClose = new JButton(); | ||
| btnClose.setOpaque(false); | ||
|
|
||
| btnClose.setRolloverIcon(CLOSE_TAB_RED_ICON); | ||
| btnClose.setRolloverEnabled(true); | ||
| btnClose.setContentAreaFilled(false); | ||
| btnClose.setToolTipText(Constant.messages.getString("all.button.close")); | ||
| btnClose.setIcon(CLOSE_TAB_GREY_ICON); | ||
| btnClose.setBorder(new EmptyBorder(0, 6, 0, 0)); | ||
| btnClose.setBorderPainted(false); | ||
| btnClose.setFocusable(false); | ||
|
|
||
| GridBagConstraints gbc = new GridBagConstraints(); | ||
| gbc.gridx = 0; | ||
| gbc.gridy = 0; | ||
| gbc.weightx = 1; | ||
|
|
||
| this.add(lblTitle, gbc); | ||
|
|
||
| gbc.gridx++; | ||
| gbc.weightx = 0; | ||
| this.add(btnClose, gbc); | ||
|
|
| public void actionPerformed(ActionEvent evt) { | ||
| JTabbedPane ntp = tabbedPane; | ||
|
|
||
| int index = ntp.indexOfTab(tabName); | ||
| if (index >= 0) { | ||
| if (ntp.getTabCount() > 2 && index == ntp.getTabCount() - 2) { | ||
| ntp.setSelectedIndex(index - 1); | ||
| } | ||
| ntp.removeTabAt(index); | ||
| } |
| @@ -171,7 +194,10 @@ public LlmCommunicationService getCommunicationService(String commsKey, String o | |||
| new LlmCommunicationService( | |||
| options.getDefaultProviderConfig(), | |||
| options.getDefaultModelName(), | |||
| outputTabName)); | |||
| this.hasView() | |||
| ? new LlmGuiResponseHandler( | |||
| getChatTab(commsKey, outputTabName)) | |||
| : new LlmLogResponseHandler())); | |||
| appendMessage( | ||
| Constant.messages.getString( | ||
| "llm.chat.panel.message.format", | ||
| Constant.messages.getString(USER_LABEL), | ||
| message)); | ||
|
|
||
| Thread chatThread = | ||
| new Thread( | ||
| () -> { | ||
| try { | ||
| LlmCommunicationService service = | ||
| extension.getCommunicationService( | ||
| tag, | ||
| Constant.messages.getString( | ||
| "llm.chat.output.panel")); | ||
| if (service == null) { | ||
| appendToOutput("llm.chat.panel.error.service", null); | ||
| return; | ||
| } | ||
| if (useStructuredPayload) { | ||
| ChatRequest chatRequest = | ||
| ChatRequest.builder() | ||
| .messages( | ||
| SystemMessage.from( | ||
| UNTRUSTED_DATA_SYSTEM_MESSAGE), | ||
| UserMessage.from(message)) | ||
| .build(); | ||
| ChatResponse response = service.chat(chatRequest); | ||
| appendToOutput(ASSISTANT_LABEL, response.aiMessage().text()); | ||
| } else { | ||
| appendToOutput(ASSISTANT_LABEL, service.chat(message)); | ||
| } |
| * | ||
| * ZAP is an HTTP/HTTPS proxy for assessing web application security. | ||
| * | ||
| * Copyright 2025 The ZAP Development Team |


No description provided.