The owning widget keeps all SWT lifecycle - it creates and disposes the {@link Browser} and its
+ * {@code ProgressListener}, then passes the browser here. The bridge owns the page-load gating state
+ * (whether the page has loaded, plus the queues of scripts and result-producing tasks deferred until
+ * it has); the widget's load listener calls {@link #notifyPageLoaded()} to flush those queues.
+ *
+ *
Domain reactions to JavaScript callbacks are delegated to {@link JavaCallbacks} so this bridge
+ * stays free of clipboard, editor, workbench and logging dependencies.
+ */
+public class BrowserConversationJavaJsBridge {
+
+ /**
+ * Java call-back functions invoked by browser-side JavaScript. Implemented by the owning widget
+ * so all domain behavior stays there. Each method corresponds to one registered
+ * {@link BrowserFunction}. This does not cover the {@link #executeScriptForResult} result path,
+ * which uses a per-call {@link Consumer}.
+ */
+ public interface JavaCallbacks {
+
+ /** Copies {@code code} to the system clipboard. */
+ void copyToClipboard(String code);
+
+ /** Inserts {@code code} at the cursor of the active text editor. */
+ void insertAtCursor(String code);
+
+ /** Accepts the pending tool confirmation with the given action index. */
+ void acceptToolAction(int actionIndex);
+
+ /** Dismisses the pending tool confirmation. */
+ void dismissToolAction();
+
+ /** Handles a generic copilot action (e.g. {@code openLink}, {@code openJobList}). */
+ void copilotAction(String action, String param);
+
+ /** Routes a browser-side (JavaScript) error message to the Eclipse error log. */
+ void logError(String message);
+ }
+
+ private final Browser browser;
+ private final JavaCallbacks callbacks;
+
+ private boolean pageLoaded;
+ private final Queue pendingScripts = new LinkedList<>();
+ /**
+ * Result-producing evaluations deferred until the page has loaded and {@link #pendingScripts}
+ * have flushed. Used so that operations needing a real return value (e.g. verifying a tool
+ * confirmation card was inserted) reflect the actual post-load DOM instead of an optimistic
+ * assumption made while the page was still loading.
+ */
+ private final Queue pendingResultTasks = new LinkedList<>();
+
+ /**
+ * Creates the bridge over an already-created {@link Browser} and registers the Java call-back
+ * functions. The browser's lifecycle (creation, layout, disposal and load-listener registration)
+ * remains the responsibility of the owning widget.
+ *
+ * @param browser the chat browser to drive (created and owned by the widget)
+ * @param callbacks the domain callbacks invoked by browser-side JavaScript
+ */
+ public BrowserConversationJavaJsBridge(Browser browser, JavaCallbacks callbacks) {
+ this.browser = browser;
+ this.callbacks = callbacks;
+ registerBrowserFunctions();
+ }
+
+ /**
+ * Marks the page as loaded and flushes any scripts and result-producing tasks queued while it was
+ * still loading. Must be called on the UI thread from the widget's page-load listener.
+ */
+ public void notifyPageLoaded() {
+ pageLoaded = true;
+ while (!pendingScripts.isEmpty()) {
+ browser.execute(pendingScripts.poll());
+ }
+ // Run deferred result-producing evaluations only after the DOM built by the queued scripts
+ // above exists, so their outcome reflects the real post-load DOM.
+ while (!pendingResultTasks.isEmpty()) {
+ pendingResultTasks.poll().run();
+ }
+ }
+
+ /** Applies the light/dark theme by invoking the {@code window.setTheme} helper. */
+ public void setDarkTheme(boolean dark) {
+ browser.execute("window.setTheme('" + (dark ? "dark" : "light") + "')");
+ }
+
+ /** Scrolls the chat viewport to the bottom and re-arms auto-scroll (see chat-view.html). */
+ public void scrollToBottom() {
+ executeScript("window.forceScrollToBottom()");
+ }
+
+ /** Appends {@code html} as the last child of the element with {@code parentId}. */
+ public void insertBlock(String parentId, String html) {
+ executeScript(insertBlockScript(parentId, html));
+ }
+
+ /**
+ * Inserts {@code html} before the sibling {@code beforeId} within {@code parentId} and reports
+ * whether the DOM insertion actually happened to {@code onResult} (always on the UI thread).
+ */
+ public void insertBlockBefore(String parentId, String html, String beforeId,
+ Consumer onResult) {
+ executeScriptForResult(insertBlockBeforeScript(parentId, html, beforeId), onResult);
+ }
+
+ /** Replaces the element {@code blockId} with {@code html}. */
+ public void replaceBlock(String blockId, String html) {
+ executeScript(replaceBlockScript(blockId, html));
+ }
+
+ /** Removes the element with {@code blockId}. */
+ public void removeBlock(String blockId) {
+ executeScript(removeBlockScript(blockId));
+ }
+
+ /**
+ * Collapses a thinking block by removing the {@code open} attribute from its {@code }
+ * element and replacing the spinner with the given bulb SVG icon.
+ */
+ public void collapseThinkingBlock(String blockId, String bulbSvg) {
+ executeScript(collapseThinkingBlockScript(blockId, bulbSvg));
+ }
+
+ /**
+ * Updates the rendered (Markdown -> HTML) body of a thinking block and auto-scrolls to the
+ * bottom if the user hasn't scrolled up. Avoids replacing the entire block (which resets scroll).
+ * The caller passes pre-rendered HTML so live streaming matches the sealed/restored rendering.
+ */
+ public void updateThinkingBodyText(String blockId, String bodyHtml) {
+ executeScript(updateThinkingBodyTextScript(blockId, bodyHtml));
+ }
+
+ /**
+ * Updates the summary/title of a thinking block without replacing the entire block. The caller
+ * passes pre-rendered inline HTML (Markdown -> HTML) so bold/inline formatting is honored.
+ */
+ public void updateThinkingBlockTitle(String blockId, String titleHtml) {
+ executeScript(updateThinkingBlockTitleScript(blockId, titleHtml));
+ }
+
+ private void registerBrowserFunctions() {
+ new BrowserFunction(browser, "copyToClipboard") {
+ @Override
+ public Object function(Object[] arguments) {
+ if (arguments.length > 0 && arguments[0] instanceof String code) {
+ callbacks.copyToClipboard(code);
+ }
+ return null;
+ }
+ };
+
+ new BrowserFunction(browser, "insertAtCursor") {
+ @Override
+ public Object function(Object[] arguments) {
+ if (arguments.length > 0 && arguments[0] instanceof String code) {
+ callbacks.insertAtCursor(code);
+ }
+ return null;
+ }
+ };
+
+ new BrowserFunction(browser, "acceptToolAction") {
+ @Override
+ public Object function(Object[] arguments) {
+ if (arguments.length > 0 && arguments[0] instanceof Double actionIndex) {
+ callbacks.acceptToolAction(actionIndex.intValue());
+ }
+ return null;
+ }
+ };
+
+ new BrowserFunction(browser, "dismissToolAction") {
+ @Override
+ public Object function(Object[] arguments) {
+ callbacks.dismissToolAction();
+ return null;
+ }
+ };
+
+ new BrowserFunction(browser, "copilotAction") {
+ @Override
+ public Object function(Object[] arguments) {
+ if (arguments.length < 2) {
+ return null;
+ }
+ callbacks.copilotAction(String.valueOf(arguments[0]), String.valueOf(arguments[1]));
+ return null;
+ }
+ };
+
+ // Routes browser-side (JavaScript) error diagnostics to the Eclipse error log so they are
+ // visible to the user; the browser console is not surfaced anywhere in the IDE.
+ new BrowserFunction(browser, "copilotLogError") {
+ @Override
+ public Object function(Object[] arguments) {
+ if (arguments.length > 0 && arguments[0] != null) {
+ callbacks.logError(String.valueOf(arguments[0]));
+ }
+ return null;
+ }
+ };
+ }
+
+ private void executeScript(String script) {
+ if (browser.isDisposed()) {
+ return;
+ }
+ Display.getDefault().asyncExec(() -> {
+ if (browser.isDisposed()) {
+ return;
+ }
+ if (pageLoaded) {
+ browser.execute(script);
+ } else {
+ pendingScripts.add(script);
+ }
+ });
+ }
+
+ /**
+ * Evaluates a JavaScript expression and reports whether the call was successful. The
+ * {@code expression} must evaluate to a boolean indicating success (e.g. a call to a
+ * {@code window.*} helper that returns {@code true}/{@code false}). The {@code onResult} callback
+ * is always invoked on the UI thread. Evaluation is deferred behind any previously queued scripts
+ * to preserve ordering. While the page is still loading, both the execution and the result
+ * capture are deferred until after the initial load completes and {@link #pendingScripts} have
+ * flushed, so the reported outcome reflects the real post-load DOM rather than an optimistic
+ * assumption. This lets callers such as the tool-confirmation flow reliably detect a genuine
+ * insertion failure (and fall back to dismiss) without ever guessing success.
+ */
+ private void executeScriptForResult(String expression, Consumer onResult) {
+ if (browser.isDisposed()) {
+ onResult.accept(false);
+ return;
+ }
+ Display.getDefault().asyncExec(() -> {
+ if (browser.isDisposed()) {
+ onResult.accept(false);
+ return;
+ }
+ if (!pageLoaded) {
+ // Defer both the execution and its result capture until the page has loaded and queued
+ // scripts have flushed, so success/failure reflects the real DOM instead of a guess.
+ pendingResultTasks.add(() -> onResult.accept(evaluateForBoolean(expression)));
+ return;
+ }
+ onResult.accept(evaluateForBoolean(expression));
+ });
+ }
+
+ /**
+ * Executes {@code expression} via {@link Browser#evaluate(String)} and returns whether it
+ * evaluated to boolean {@code true}. Must be called on the UI thread with the page loaded. Any
+ * evaluation failure is logged and reported as {@code false}.
+ */
+ private boolean evaluateForBoolean(String expression) {
+ try {
+ Object result = browser.evaluate("return " + expression + ";");
+ return Boolean.TRUE.equals(result);
+ } catch (RuntimeException e) {
+ CopilotCore.LOGGER.error("Failed to evaluate browser script: " + expression, e);
+ return false;
+ }
+ }
+
+ /** Escapes a string for safe embedding in a single-quoted JavaScript string literal. */
+ public static String escapeForJs(String text) {
+ if (text == null) {
+ return "";
+ }
+ return text.replace("\\", "\\\\")
+ .replace("'", "\\'")
+ .replace("\n", "\\n")
+ .replace("\r", "\\r")
+ .replace("\t", "\\t");
+ }
+
+ /**
+ * Builds the JavaScript call that appends {@code html} as the last child of the element with
+ * {@code parentId}. The invoked helper returns whether the insertion was performed.
+ */
+ public static String insertBlockScript(String parentId, String html) {
+ return "window.insertBlock('" + escapeForJs(parentId) + "', '" + escapeForJs(html) + "')";
+ }
+
+ /**
+ * Builds the JavaScript call that inserts {@code html} before the sibling {@code beforeId} within
+ * the element with {@code parentId}. The invoked helper returns whether the insertion was done.
+ */
+ public static String insertBlockBeforeScript(String parentId, String html, String beforeId) {
+ return "window.insertBlockBefore('" + escapeForJs(parentId) + "', '"
+ + escapeForJs(html) + "', '" + escapeForJs(beforeId) + "')";
+ }
+
+ /** Builds the JavaScript call that replaces the element {@code blockId} with {@code html}. */
+ public static String replaceBlockScript(String blockId, String html) {
+ return "window.replaceBlock('" + escapeForJs(blockId) + "', '" + escapeForJs(html) + "')";
+ }
+
+ /** Builds the JavaScript call that removes the element with {@code blockId}. */
+ public static String removeBlockScript(String blockId) {
+ return "window.removeBlock('" + escapeForJs(blockId) + "')";
+ }
+
+ /**
+ * Builds the script executed by {@link #collapseThinkingBlock(String, String)}. The SVG is
+ * embedded in a double-quoted {@code innerHTML} literal, so its quotes and apostrophes are escaped
+ * for that context rather than via {@link #escapeForJs}.
+ */
+ public static String collapseThinkingBlockScript(String blockId, String bulbSvg) {
+ String bulbSvgJs = bulbSvg.replace("\"", "\\\"").replace("'", "\\'");
+ return """
+ var b=document.getElementById('%s');\
+ if(b){var d=b.querySelector('details');if(d)d.removeAttribute('open');\
+ var s=b.querySelector('.thinking-spinner');\
+ if(s){var icon=document.createElement('span');\
+ icon.innerHTML='%s';\
+ s.parentNode.replaceChild(icon.firstChild,s);}}"""
+ .formatted(escapeForJs(blockId), bulbSvgJs);
+ }
+
+ /** Builds the script executed by {@link #updateThinkingBodyText(String, String)}. */
+ public static String updateThinkingBodyTextScript(String blockId, String bodyHtml) {
+ return """
+ var b=document.getElementById('%s');\
+ if(b){var bd=b.querySelector('.thinking-body');if(bd){\
+ bd.innerHTML='%s';\
+ var thr=60;if(bd.scrollHeight-(bd.scrollTop+bd.clientHeight)<=thr)\
+ bd.scrollTop=bd.scrollHeight;}}"""
+ .formatted(escapeForJs(blockId), escapeForJs(bodyHtml));
+ }
+
+ /** Builds the script executed by {@link #updateThinkingBlockTitle(String, String)}. */
+ public static String updateThinkingBlockTitleScript(String blockId, String titleHtml) {
+ return """
+ var b=document.getElementById('%s');\
+ if(b){var s=b.querySelector('summary');if(s)s.innerHTML='%s';}"""
+ .formatted(escapeForJs(blockId), escapeForJs(titleHtml));
+ }
+}
diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidget.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidget.java
new file mode 100644
index 00000000..95e7f156
--- /dev/null
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidget.java
@@ -0,0 +1,1062 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+package com.microsoft.copilot.eclipse.ui.chat;
+
+import java.net.URL;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.CompletableFuture;
+
+import org.apache.commons.lang3.StringUtils;
+import org.eclipse.core.runtime.FileLocator;
+import org.eclipse.jface.dialogs.MessageDialog;
+import org.eclipse.jface.text.BadLocationException;
+import org.eclipse.jface.text.IDocument;
+import org.eclipse.jface.text.ITextSelection;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.browser.Browser;
+import org.eclipse.swt.browser.ProgressAdapter;
+import org.eclipse.swt.browser.ProgressEvent;
+import org.eclipse.swt.dnd.Clipboard;
+import org.eclipse.swt.dnd.TextTransfer;
+import org.eclipse.swt.dnd.Transfer;
+import org.eclipse.swt.layout.GridData;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Display;
+import org.eclipse.ui.IEditorPart;
+import org.eclipse.ui.texteditor.IDocumentProvider;
+import org.eclipse.ui.texteditor.ITextEditor;
+import org.osgi.framework.Bundle;
+
+import com.microsoft.copilot.eclipse.core.Constants;
+import com.microsoft.copilot.eclipse.core.CopilotCore;
+import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction;
+import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent;
+import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentRound;
+import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentToolCall;
+import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatProgressValue;
+import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolConfirmationResult;
+import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolConfirmationResult.ToolConfirmationResult;
+import com.microsoft.copilot.eclipse.core.lsp.protocol.Thinking;
+import com.microsoft.copilot.eclipse.core.lsp.protocol.codingagent.CodingAgentMessageRequestParams;
+import com.microsoft.copilot.eclipse.core.persistence.AbstractTurnData;
+import com.microsoft.copilot.eclipse.core.persistence.ConversationDataFactory;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.AgentMessageData;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.EditAgentRoundData;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ErrorData;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ErrorMessageData;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ReplyData;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ThinkingBlockData;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ToolCallData;
+import com.microsoft.copilot.eclipse.core.persistence.UserTurnData;
+import com.microsoft.copilot.eclipse.core.utils.BundleUtils;
+import com.microsoft.copilot.eclipse.ui.CopilotUi;
+import com.microsoft.copilot.eclipse.ui.UiConstants;
+import com.microsoft.copilot.eclipse.ui.chat.QuotaActions.QuotaAction;
+import com.microsoft.copilot.eclipse.ui.chat.services.AvatarService;
+import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager;
+import com.microsoft.copilot.eclipse.ui.utils.SwtUtils;
+import com.microsoft.copilot.eclipse.ui.utils.UiUtils;
+
+/**
+ * {@link IConversationWidget} implementation that renders copilot chat turns in an
+ * Eclipse-internal web {@link Browser} widget using an HTML/JavaScript frontend.
+ *
+ *
This widget creates, updates, and removes HTML code blocks wrapped in DIV
+ * blocks with unique IDs. It delegates HTML code generation to {@link ConversationHtmlBlockFactory}
+ * and the commonmark Java library for rendering Markdown code,
+ * including GitHub-flavoured Markdown tables.
+ * The {@link Browser} receives the HTML code based on an HTML template (chat-view.html)
+ * and offers a simple API for adding, replacing, or removing HTML div blocks with a certain ID.
+ *
+ *
HTML block ID scheme: turn containers use the server-assigned turn ID.
+ * Child blocks within a turn use {@code turnId-N} where N is a sequential counter.
+ * The CSS class on each child block identifies its type
+ * (thinking-block, tool-call, response, etc.).
+ */
+public class BrowserConversationWidget
+ implements IConversationWidget, BrowserConversationJavaJsBridge.JavaCallbacks {
+
+ private final Browser browser;
+ private final ConversationHtmlBlockFactory htmlFactory;
+ /**
+ * Chat services used for quota resolution (and token-based-billing detection). Injected so the
+ * browser renderer resolves quota from the same source as the StyledText renderer instead of the
+ * global singleton, which keeps the two renderers consistent and makes quota behavior testable.
+ */
+ private final ChatServiceManager serviceManager;
+
+ // Turn streaming state (per-turn, keyed by turnId).
+ private final Map turnStates = new HashMap<>();
+ private String currentTurnId;
+ private String lastCopilotTurnId;
+ private String conversationId;
+
+ // Subagent nesting (single level, mirrors SWT BaseTurnWidget). Set while the parent's
+ // run_subagent tool call is running; consumed by the subagent's beginTurn.
+ private String activeSubagentContentAreaId;
+ // Maps a run_subagent tool call id to its nested content-area id (used on restore).
+ private final Map subagentContentAreaByToolCallId = new HashMap<>();
+
+ // Tool confirmation state
+ private CompletableFuture pendingConfirmationFuture;
+ private String pendingConfirmationTurnId;
+ private List pendingConfirmationActions;
+ private ConfirmationAction lastSelectedAction;
+
+ /** Java↔JavaScript bridge: creates/runs JS and registers Java call-back functions. */
+ private final BrowserConversationJavaJsBridge bridge;
+ private String copilotAvatarDataUri;
+
+ /** Types of child blocks within a copilot turn. */
+ private enum ChildBlockType {
+ THINKING, TOOL_CALL, RESPONSE
+ }
+
+ /**
+ * Per-turn streaming state, keyed by turnId. Keeping the block counter and current-block pointers
+ * per turn means a parent turn that resumes after a nested subagent keeps its own counter, so no
+ * duplicate DOM ids are produced. {@code contentAreaId} is where this turn's child blocks are
+ * inserted: the turn's own content area, or — for a subagent turn — the nested subagent content
+ * area.
+ */
+ private static final class TurnStreamState {
+ private final String contentAreaId;
+ private int childBlockCounter;
+ private ChildBlockType currentBlockType;
+ private String currentChildBlockId;
+ private String currentToolCallId;
+ private String currentThinkingBlockId;
+ private StringBuilder currentThinkingText;
+ private StringBuilder currentReplyText;
+ private boolean streamingIndicatorVisible;
+
+ TurnStreamState(String contentAreaId) {
+ this.contentAreaId = contentAreaId;
+ }
+
+ void resetTransient() {
+ currentBlockType = null;
+ currentChildBlockId = null;
+ currentToolCallId = null;
+ currentThinkingBlockId = null;
+ currentThinkingText = null;
+ currentReplyText = null;
+ }
+ }
+
+ /**
+ * Creates a new browser-based conversation widget.
+ *
+ * @param parent the parent composite
+ * @param serviceManager the chat services used to resolve avatars, display names, and quota
+ * status; must not be {@code null}
+ */
+ public BrowserConversationWidget(Composite parent, ChatServiceManager serviceManager) {
+ this.serviceManager = Objects.requireNonNull(serviceManager, "serviceManager must not be null");
+ browser = new Browser(parent, SWT.NONE);
+ browser.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true));
+ htmlFactory = new ConversationHtmlBlockFactory();
+
+ Bundle bundle = CopilotUi.getPlugin().getBundle();
+
+ // Resolve avatars via the shared AvatarService so this renderer matches the StyledText renderer.
+ this.copilotAvatarDataUri = serviceManager.getAvatarService().getAvatarForCopilotAsDataUri();
+
+ // Load code block action icons from Eclipse platform bundles
+ Bundle uiBundle = org.eclipse.core.runtime.Platform.getBundle("org.eclipse.ui");
+ String copyUri = BundleUtils.loadPngAsDataUri(uiBundle, "icons/full/etool16/copy_edit.png");
+ Bundle textEditorBundle = org.eclipse.core.runtime.Platform.getBundle(
+ UiConstants.WORKBENCH_TEXTEDITOR);
+ String insertUri = BundleUtils.loadPngAsDataUri(textEditorBundle, UiConstants.INSERT_ICON);
+ htmlFactory.setCodeBlockIcons(copyUri, insertUri);
+
+ bridge = new BrowserConversationJavaJsBridge(browser, this);
+
+ browser.addProgressListener(new ProgressAdapter() {
+ @Override
+ public void completed(ProgressEvent event) {
+ bridge.setDarkTheme(UiUtils.isDarkTheme());
+ bridge.notifyPageLoaded();
+ }
+ });
+
+ try {
+ URL fileUrl = bundle.getEntry("resources/html/chat-view.html");
+ URL resolvedUrl = FileLocator.toFileURL(fileUrl);
+ browser.setUrl(resolvedUrl.toExternalForm());
+ } catch (Exception e) {
+ CopilotCore.LOGGER.error("Failed to load chat-view.html in browser widget", e);
+ }
+ }
+
+ @Override
+ public Control getControl() {
+ return browser;
+ }
+
+ @Override
+ public boolean isDisposed() {
+ return browser.isDisposed();
+ }
+
+ @Override
+ public void dispose() {
+ if (!browser.isDisposed()) {
+ browser.dispose();
+ }
+ }
+
+ @Override
+ public void requestLayout() {
+ browser.requestLayout();
+ }
+
+ @Override
+ public void setConversationId(String conversationId) {
+ this.conversationId = conversationId;
+ }
+
+ @Override
+ public void beginTurn(String turnId, boolean isCopilot, boolean isHistory) {
+ // Idempotency guard: the turn container may already have been created, e.g. by
+ // ensureCopilotTurnContainer() when a tool confirmation arrived before this begin event.
+ // Re-creating it would insert a duplicate container into the DOM.
+ if (turnStates.containsKey(turnId)) {
+ currentTurnId = turnId;
+ if (isCopilot) {
+ lastCopilotTurnId = turnId;
+ }
+ return;
+ }
+ // Subagent begin: nest inside the parent's active subagent block instead of creating a
+ // top-level turn container. Its child blocks are routed to the nested content area.
+ if (isCopilot && activeSubagentContentAreaId != null) {
+ currentTurnId = turnId;
+ turnStates.put(turnId, new TurnStreamState(activeSubagentContentAreaId));
+ return;
+ }
+ currentTurnId = turnId;
+ if (isCopilot) {
+ lastCopilotTurnId = turnId;
+ }
+ turnStates.put(turnId,
+ new TurnStreamState(ConversationHtmlBlockFactory.contentBlockId(turnId, isCopilot)));
+ String turnHtml = htmlFactory.createTurnContainerHtmlBlock(
+ turnId, isCopilot,
+ isCopilot ? copilotAvatarDataUri : getUserAvatarUri(),
+ isCopilot ? getCopilotDisplayName() : getUserDisplayName());
+ bridge.insertBlock(ConversationHtmlBlockFactory.CHAT_CONTAINER_ID, turnHtml);
+ if (isCopilot && !isHistory) {
+ showStreamingIndicator(turnId);
+ }
+ }
+
+ @Override
+ public void processTurnEvent(ChatProgressValue value) {
+ String turnId = value.getTurnId();
+
+ switch (value.getKind()) {
+ case report -> {
+ currentTurnId = turnId;
+ ensureTurnState(turnId, value);
+ removeStreamingIndicator(turnId);
+ processThinking(turnId, value);
+ processAgentRounds(turnId, value);
+ processReply(turnId, extractReplyChunk(value));
+ }
+ case end -> {
+ removeStreamingIndicator(turnId);
+ finalizeTurn(turnId);
+ }
+ default -> {
+ // begin is handled by beginTurn(), called separately by ChatView
+ }
+ }
+
+ // Handle error messages (can arrive on any event kind, including report and end)
+ String errorMessage = ChatErrorMessages.resolveDisplayMessage(value);
+ if (StringUtils.isNotEmpty(errorMessage)) {
+ processErrorMessage(turnId, errorMessage, value.getCode(), value.getErrorModelProviderName());
+ }
+ }
+
+ @Override
+ public void startNewUserTurn(String turnId, String message) {
+ String turnHtml = htmlFactory.createTurnContainerHtmlBlock(
+ turnId, false, getUserAvatarUri(), getUserDisplayName());
+ bridge.insertBlock(ConversationHtmlBlockFactory.CHAT_CONTAINER_ID, turnHtml);
+ String contentId = ConversationHtmlBlockFactory.contentBlockId(turnId, false);
+ String blockId = contentId + "-0";
+ String blockHtml = htmlFactory.createUserRequestHtmlBlock(blockId, message);
+ bridge.insertBlock(contentId, blockHtml);
+ // Re-arm auto-scroll for the new turn and jump to the bottom so the user turn is visible and
+ // the streamed reply is followed. insertBlock's own scroll respects the auto-scroll flag,
+ // which the user may have turned off by scrolling up during the previous turn.
+ scrollToBottom();
+ }
+
+ @Override
+ public void scrollToBottom() {
+ bridge.scrollToBottom();
+ }
+
+ @Override
+ public void refreshScrollerLayout() {
+ // Browser handles its own layout; no action needed
+ }
+
+ @Override
+ public void renderErrorMessage(String content) {
+ String errorId = ConversationHtmlBlockFactory.errorBlockId();
+ String renderedContent = htmlFactory.renderMarkdown(content);
+ String errorHtml = htmlFactory.createErrorTurnHtmlBlock(errorId, renderedContent);
+ bridge.insertBlock(ConversationHtmlBlockFactory.CHAT_CONTAINER_ID, errorHtml);
+ // Scroll the newly inserted error banner into view so the user notices it, mirroring the
+ // SWT renderer's showControl-based scroll for error banners.
+ scrollToBottom();
+ }
+
+ @Override
+ public void showCompactingStatusOnLatestCopilotTurn() {
+ String turnId = lastCopilotTurnId != null ? lastCopilotTurnId : currentTurnId;
+ if (turnId != null) {
+ String blockId = ConversationHtmlBlockFactory.compactingBlockId(turnId);
+ String html = htmlFactory.createCompactingStatusHtmlBlock(blockId);
+ bridge.insertBlock(ConversationHtmlBlockFactory.contentBlockId(turnId, true), html);
+ }
+ }
+
+ @Override
+ public void hideCompactingStatusOnLatestCopilotTurn() {
+ String turnId = lastCopilotTurnId != null ? lastCopilotTurnId : currentTurnId;
+ if (turnId != null) {
+ bridge.removeBlock(ConversationHtmlBlockFactory.compactingBlockId(turnId));
+ }
+ }
+
+ @Override
+ public String getActiveThinkingBlockId(String turnId) {
+ TurnStreamState state = turnId != null ? turnStates.get(turnId) : null;
+ return state != null ? state.currentThinkingBlockId : null;
+ }
+
+ @Override
+ public void restoreTurn(AbstractTurnData turn, ConversationDataFactory dataFactory) {
+ if (turn == null) {
+ return;
+ }
+ // Subagent turn: render nested inside the parent turn's subagent block.
+ if (turn instanceof CopilotTurnData copilotTurn
+ && StringUtils.isNotBlank(copilotTurn.getParentTurnId())) {
+ restoreSubagentTurn(copilotTurn);
+ return;
+ }
+ if (turn instanceof UserTurnData userTurn) {
+ if (userTurn.getMessage() != null
+ && StringUtils.isNotBlank(userTurn.getMessage().getText())) {
+ startNewUserTurn(turn.getTurnId(), userTurn.getMessage().getText());
+ }
+ return;
+ }
+ if (turn instanceof CopilotTurnData copilotTurn) {
+ String turnId = turn.getTurnId();
+ lastCopilotTurnId = turnId;
+ String turnHtml = htmlFactory.createTurnContainerHtmlBlock(
+ turnId, true, copilotAvatarDataUri, getCopilotDisplayName());
+ bridge.insertBlock(ConversationHtmlBlockFactory.CHAT_CONTAINER_ID, turnHtml);
+ restoreCopilotTurnContent(turnId,
+ ConversationHtmlBlockFactory.contentBlockId(turnId, true), copilotTurn);
+ ReplyData replyData = copilotTurn.getReply();
+ if (replyData != null && StringUtils.isNotBlank(replyData.getModelName())) {
+ renderModelInfo(turnId, replyData.getModelName(),
+ replyData.getBillingMultiplier(), replyData.getReasoningEffort());
+ }
+ }
+ }
+
+ /**
+ * Restores a subagent turn into the nested subagent block that was opened while restoring the
+ * parent turn's {@code run_subagent} tool call. Falls back to the parent's content area when the
+ * subagent tool-call id is missing (mirrors the SWT blank-toolCallId path).
+ */
+ private void restoreSubagentTurn(CopilotTurnData copilotTurn) {
+ String toolCallId = copilotTurn.getSubagentToolCallId();
+ String contentAreaId = StringUtils.isNotBlank(toolCallId)
+ ? subagentContentAreaByToolCallId.get(toolCallId) : null;
+ if (contentAreaId == null) {
+ contentAreaId =
+ ConversationHtmlBlockFactory.contentBlockId(copilotTurn.getParentTurnId(), true);
+ }
+ restoreCopilotTurnContent(copilotTurn.getTurnId(), contentAreaId, copilotTurn);
+ }
+
+ @Override
+ public void renderModelInfo(String turnId, String modelName, double billingMultiplier,
+ String reasoningEffort) {
+ // When token-based billing is enabled, the per-turn billing multiplier is no longer
+ // a meaningful price signal — hide it, matching StyledText widget behavior.
+ double effectiveMultiplier = billingMultiplier;
+ try {
+ if (serviceManager != null && serviceManager.getAuthStatusManager()
+ .getQuotaStatus().tokenBasedBillingEnabled()) {
+ effectiveMultiplier = 0;
+ }
+ } catch (Exception e) {
+ // Defensive: if quota status unavailable, fall back to showing multiplier
+ }
+ String blockId = ConversationHtmlBlockFactory.modelInfoBlockId(turnId);
+ String html = htmlFactory.createModelInfoHtmlBlock(
+ blockId, modelName, effectiveMultiplier, reasoningEffort);
+ bridge.insertBlock(ConversationHtmlBlockFactory.copilotTurnContainerId(turnId), html);
+ }
+
+ @Override
+ public void renderAgentMessage(CodingAgentMessageRequestParams params) {
+ if (params == null) {
+ return;
+ }
+ String turnId = params.getTurnId();
+ if (StringUtils.isBlank(turnId)) {
+ return;
+ }
+ String blockId = ConversationHtmlBlockFactory.agentMessageBlockId(turnId);
+ String html = htmlFactory.createAgentMessageHtmlBlock(
+ blockId, params.getTitle(), params.getDescription(), params.getPrLink());
+ bridge.insertBlock(ConversationHtmlBlockFactory.copilotTurnContainerId(turnId), html);
+ }
+
+ @Override
+ public CompletableFuture requestToolConfirmation(
+ String turnId, ConfirmationContent content, Object input) {
+ // Cancel any existing pending confirmation
+ cancelToolConfirmation(turnId);
+
+ // Safety net: without actionable buttons the card can never be resolved by the user, so
+ // returning a pending future would stall the agent indefinitely. Dismiss immediately instead.
+ if (content == null || content.getActions() == null || content.getActions().isEmpty()) {
+ return CompletableFuture.completedFuture(
+ new LanguageModelToolConfirmationResult(ToolConfirmationResult.DISMISS));
+ }
+
+ // The confirmation request may arrive before the turn's begin event (e.g. when the terminal
+ // call is the turn's first action), so the copilot turn container may not exist yet. Ensure it
+ // exists before inserting the card; otherwise the insertion silently no-ops and the agent hangs.
+ ensureCopilotTurnContainer(turnId);
+
+ CompletableFuture future = new CompletableFuture<>();
+ pendingConfirmationFuture = future;
+ pendingConfirmationTurnId = turnId;
+ pendingConfirmationActions = content.getActions();
+ lastSelectedAction = null;
+
+ String blockId = ConversationHtmlBlockFactory.confirmationBlockId(turnId);
+ String html = htmlFactory.createConfirmationHtmlBlock(blockId, content, input);
+ // Insert before the model-info block so confirmation appears above it
+ String modelInfoId = ConversationHtmlBlockFactory.modelInfoBlockId(turnId);
+ insertConfirmationBlock(turnId, html, modelInfoId, future);
+ scrollToBottom();
+
+ return future;
+ }
+
+ /**
+ * Creates the copilot turn DOM container for {@code turnId} if {@link #beginTurn} has not run for
+ * it yet. Delegates to {@code beginTurn}, which is idempotent, so a subsequent real begin event
+ * for the same turn will not create a duplicate container.
+ */
+ private void ensureCopilotTurnContainer(String turnId) {
+ if (turnStates.containsKey(turnId)) {
+ return;
+ }
+ beginTurn(turnId, true, false);
+ }
+
+ /**
+ * Inserts the confirmation card and verifies the DOM insertion actually happened. If the insertion
+ * could not be performed (e.g. the target container is missing), the pending confirmation is
+ * dismissed as a fallback so the agent never stalls waiting on a card that was never rendered.
+ */
+ private void insertConfirmationBlock(String turnId, String html, String beforeId,
+ CompletableFuture future) {
+ bridge.insertBlockBefore(ConversationHtmlBlockFactory.copilotTurnContainerId(turnId), html,
+ beforeId, success -> {
+ if (!success && future == pendingConfirmationFuture && !future.isDone()) {
+ CopilotCore.LOGGER.error(new IllegalStateException(
+ "Failed to insert tool confirmation card for turn " + turnId
+ + "; dismissing to avoid a stall"));
+ resolveConfirmation(-1, false);
+ }
+ });
+ }
+
+ @Override
+ public void cancelToolConfirmation(String turnId) {
+ if (pendingConfirmationFuture != null && !pendingConfirmationFuture.isDone()) {
+ pendingConfirmationFuture.complete(
+ new LanguageModelToolConfirmationResult(ToolConfirmationResult.DISMISS));
+ }
+ pendingConfirmationFuture = null;
+ pendingConfirmationActions = null;
+ pendingConfirmationTurnId = null;
+ removeConfirmationBlock(turnId);
+ }
+
+ @Override
+ public ConfirmationAction getLastSelectedConfirmationAction() {
+ return lastSelectedAction;
+ }
+
+ private void resolveConfirmation(int actionIndex, boolean accepted) {
+ if (pendingConfirmationFuture == null || pendingConfirmationFuture.isDone()) {
+ return;
+ }
+ if (accepted && pendingConfirmationActions != null
+ && actionIndex >= 0 && actionIndex < pendingConfirmationActions.size()) {
+ lastSelectedAction = pendingConfirmationActions.get(actionIndex);
+ }
+ ToolConfirmationResult result = accepted
+ ? ToolConfirmationResult.ACCEPT : ToolConfirmationResult.DISMISS;
+ pendingConfirmationFuture.complete(new LanguageModelToolConfirmationResult(result));
+ pendingConfirmationFuture = null;
+ pendingConfirmationActions = null;
+ if (pendingConfirmationTurnId != null) {
+ removeConfirmationBlock(pendingConfirmationTurnId);
+ pendingConfirmationTurnId = null;
+ }
+ }
+
+ private void removeConfirmationBlock(String turnId) {
+ if (turnId == null) {
+ return;
+ }
+ String blockId = ConversationHtmlBlockFactory.confirmationBlockId(turnId);
+ bridge.removeBlock(blockId);
+ }
+
+ /**
+ * Processes a thinking chunk: starts a new thinking block or updates the current one.
+ * Seals the thinking block if renderable output follows in the same event.
+ */
+ private void processThinking(String turnId, ChatProgressValue value) {
+ Thinking thinking = value.getThinking();
+ boolean hasThinking = thinking != null && thinking.text() != null
+ && !thinking.text().isBlank();
+
+ TurnStreamState state = stateFor(turnId);
+ if (hasThinking) {
+ if (state.currentBlockType != ChildBlockType.THINKING) {
+ state.currentThinkingText = new StringBuilder(thinking.text());
+ state.currentBlockType = ChildBlockType.THINKING;
+ state.currentChildBlockId = ConversationHtmlBlockFactory.copilotChildBlockId(
+ turnId, state.childBlockCounter++);
+ state.currentThinkingBlockId = state.currentChildBlockId;
+ String blockHtml = htmlFactory.createThinkingHtmlBlock(
+ state.currentChildBlockId, thinking.text());
+ bridge.insertBlock(state.contentAreaId, blockHtml);
+ } else {
+ state.currentThinkingText.append(thinking.text());
+ bridge.updateThinkingBodyText(state.currentChildBlockId,
+ htmlFactory.renderMarkdown(state.currentThinkingText.toString()));
+ }
+ }
+
+ // Seal thinking if renderable output follows in the same event
+ if (hasRenderableOutput(value) && state.currentBlockType == ChildBlockType.THINKING) {
+ sealCurrentThinkingBlock(turnId);
+ }
+ }
+
+ /**
+ * Seals the current thinking block: collapses it, removes the spinner, and requests a title
+ * from the language server. Mirrors the behavior of {@code ThinkingTurnWidget.sealThinking()}.
+ * Uses targeted DOM manipulation instead of replacing the entire block.
+ */
+ private void sealCurrentThinkingBlock(String turnId) {
+ TurnStreamState state = stateFor(turnId);
+ if (state.currentThinkingBlockId == null || state.currentThinkingText == null) {
+ state.currentBlockType = null;
+ return;
+ }
+ String blockId = state.currentThinkingBlockId;
+ String thinkingContent = state.currentThinkingText.toString();
+ state.currentBlockType = null;
+
+ // Collapse details, remove spinner (targeted DOM ops, no full re-render)
+ bridge.collapseThinkingBlock(blockId, SvgIcons.get(SvgIcons.Icon.THINKING_BULB));
+
+ // Request title from language server (async)
+ requestThinkingTitle(blockId, thinkingContent, turnId);
+
+ // The block is sealed; it is no longer the turn's active thinking block.
+ state.currentThinkingBlockId = null;
+ state.currentThinkingText = null;
+ }
+
+ /**
+ * Requests a thinking block title from the language server and updates the summary on response.
+ * Follows the same logic as {@code ThinkingTurnWidget}: extracts bold titles from thinking text
+ * and sends either extractedTitles or the full content to the server.
+ */
+ private void requestThinkingTitle(String blockId, String thinkingContent, String turnId) {
+ var ls = CopilotCore.getPlugin().getCopilotLanguageServer();
+ if (ls == null || StringUtils.isBlank(thinkingContent)) {
+ return;
+ }
+ var params = ThinkingTitles.buildTitleParams(
+ thinkingContent, ThinkingTitles.extractTitles(thinkingContent));
+ ls.generateThinkingTitle(params)
+ .thenAccept(resp -> {
+ if (resp != null && StringUtils.isNotBlank(resp.title())) {
+ bridge.updateThinkingBlockTitle(blockId, htmlFactory.renderMarkdownInline(resp.title()));
+ persistThinkingTitle(turnId, blockId, resp.title());
+ }
+ })
+ .exceptionally(ex -> null);
+ }
+
+ private void persistThinkingTitle(String turnId, String thinkingBlockId, String title) {
+ if (serviceManager != null) {
+ ThinkingTitles.persistTitle(serviceManager.getPersistenceManager(),
+ conversationId, turnId, thinkingBlockId, title);
+ }
+ }
+
+ /**
+ * Processes agent rounds: handles tool calls and agent-round replies.
+ */
+ private void processAgentRounds(String turnId, ChatProgressValue value) {
+ List agentRounds = value.getAgentRounds();
+ if (agentRounds == null || agentRounds.isEmpty()) {
+ return;
+ }
+ AgentRound round = agentRounds.get(0);
+ if (round.getToolCalls() != null && !round.getToolCalls().isEmpty()) {
+ AgentToolCall toolCall = round.getToolCalls().get(0);
+ processToolCall(turnId, toolCall);
+ }
+ }
+
+ /**
+ * Processes a reply chunk: starts a new response block or updates the current one.
+ */
+ private void processReply(String turnId, String replyChunk) {
+ if (replyChunk == null || replyChunk.isEmpty()) {
+ return;
+ }
+ TurnStreamState state = stateFor(turnId);
+ if (state.currentBlockType != ChildBlockType.RESPONSE) {
+ state.currentReplyText = new StringBuilder(replyChunk);
+ state.currentBlockType = ChildBlockType.RESPONSE;
+ state.currentChildBlockId = ConversationHtmlBlockFactory.copilotChildBlockId(
+ turnId, state.childBlockCounter++);
+ String blockHtml = htmlFactory.createCopilotReplyHtmlBlock(
+ state.currentChildBlockId, state.currentReplyText.toString());
+ bridge.insertBlock(state.contentAreaId, blockHtml);
+ } else {
+ state.currentReplyText.append(replyChunk);
+ String blockHtml = htmlFactory.createCopilotReplyHtmlBlock(
+ state.currentChildBlockId, state.currentReplyText.toString());
+ bridge.replaceBlock(state.currentChildBlockId, blockHtml);
+ }
+ }
+
+ /**
+ * Processes a tool call: creates a new tool call block or updates an existing one. The
+ * {@code run_subagent} tool call is not rendered as a normal block — it drives subagent nesting
+ * (mirrors {@code BaseTurnWidget} in the SWT renderer).
+ */
+ private void processToolCall(String turnId, AgentToolCall toolCall) {
+ if (toolCall != null && "run_subagent".equalsIgnoreCase(toolCall.getName())) {
+ handleSubagentToolCall(turnId, toolCall);
+ return;
+ }
+ TurnStreamState state = stateFor(turnId);
+ String toolCallId = toolCall.getId();
+ boolean isSameToolCall = state.currentBlockType == ChildBlockType.TOOL_CALL
+ && state.currentChildBlockId != null
+ && toolCallId != null && toolCallId.equals(state.currentToolCallId);
+
+ if (!isSameToolCall) {
+ state.currentBlockType = ChildBlockType.TOOL_CALL;
+ state.currentToolCallId = toolCallId;
+ String blockId =
+ ConversationHtmlBlockFactory.toolCallBlockId(turnId, state.childBlockCounter++);
+ state.currentChildBlockId = blockId;
+ String blockHtml = htmlFactory.createToolCallHtmlBlock(blockId, toolCall);
+ bridge.insertBlock(state.contentAreaId, blockHtml);
+ } else {
+ String blockHtml = htmlFactory.createToolCallHtmlBlock(
+ state.currentChildBlockId, toolCall);
+ bridge.replaceBlock(state.currentChildBlockId, blockHtml);
+ }
+ }
+
+ /**
+ * Handles the parent turn's {@code run_subagent} tool call. On {@code running} it opens a nested
+ * subagent block under the parent's content area; on completion/cancellation/error it closes the
+ * active nesting. Mirrors {@code BaseTurnWidget.handleSubagentToolCall} in the SWT renderer.
+ */
+ private void handleSubagentToolCall(String parentTurnId, AgentToolCall toolCall) {
+ String status = toolCall.getStatus() == null ? "" : toolCall.getStatus().toLowerCase();
+ String toolCallId = toolCall.getId();
+ switch (status) {
+ case "running" -> {
+ if (activeSubagentContentAreaId == null && StringUtils.isNotBlank(toolCallId)) {
+ activeSubagentContentAreaId = openSubagentBlock(parentTurnId, toolCallId,
+ subagentTitle(toolCall.getProgressMessage(), toolCall.getName()));
+ }
+ }
+ case "completed", "cancelled", "error" -> activeSubagentContentAreaId = null;
+ default -> {
+ // pending/unknown: nothing to do yet
+ }
+ }
+ }
+
+ /**
+ * Inserts a nested subagent block under the given parent turn's content area (idempotent per
+ * tool-call id) and returns the id of its inner content area, where the subagent's child blocks
+ * are inserted.
+ */
+ private String openSubagentBlock(String parentTurnId, String toolCallId, String title) {
+ String existing = subagentContentAreaByToolCallId.get(toolCallId);
+ if (existing != null) {
+ return existing;
+ }
+ String blockId = ConversationHtmlBlockFactory.subagentBlockId(parentTurnId, toolCallId);
+ String contentAreaId =
+ ConversationHtmlBlockFactory.subagentContentAreaId(parentTurnId, toolCallId);
+ String html = htmlFactory.createSubagentBlockHtmlBlock(
+ blockId, contentAreaId, copilotAvatarDataUri, title);
+ bridge.insertBlock(ConversationHtmlBlockFactory.contentBlockId(parentTurnId, true), html);
+ subagentContentAreaByToolCallId.put(toolCallId, contentAreaId);
+ return contentAreaId;
+ }
+
+ /** Derives the subagent card title: progress message, else tool name, else "Subagent". */
+ private static String subagentTitle(String progressMessage, String name) {
+ if (StringUtils.isNotBlank(progressMessage)) {
+ return progressMessage;
+ }
+ if (StringUtils.isNotBlank(name)) {
+ return name;
+ }
+ return "Subagent";
+ }
+
+ /**
+ * Processes a streaming error message: renders a warning block with the error text and,
+ * for quota-exceeded (402) errors, plan-driven action buttons.
+ */
+ private void processErrorMessage(String turnId, String message, int code,
+ String modelProviderName) {
+ List actions = resolveQuotaActions(code, modelProviderName);
+ TurnStreamState state = stateFor(turnId);
+ String blockId =
+ ConversationHtmlBlockFactory.copilotChildBlockId(turnId, state.childBlockCounter++);
+ state.currentBlockType = null;
+ bridge.insertBlock(state.contentAreaId,
+ htmlFactory.createWarningMessageHtmlBlock(blockId, message, actions));
+ // Scroll the newly inserted warning banner (e.g. quota-exceeded / 402 with plan-driven
+ // action buttons) into view so the user notices the required action, mirroring the SWT
+ // renderer's showControl-based scroll for warn banners.
+ scrollToBottom();
+ }
+
+ /**
+ * Resolves the quota action buttons appropriate for an error code. Returns an empty list
+ * for non-quota errors or when the required auth status is unavailable.
+ */
+ private List resolveQuotaActions(int code, String modelProviderName) {
+ if (serviceManager == null) {
+ return List.of();
+ }
+ try {
+ return QuotaActions.forQuotaStatus(
+ serviceManager.getAuthStatusManager().getQuotaStatus(), code, modelProviderName);
+ } catch (Exception e) {
+ CopilotCore.LOGGER.error("Failed to resolve quota actions", e);
+ return List.of();
+ }
+ }
+
+ /** Finalizes a turn: seals thinking, final response render, and resets its transient pointers. */
+ private void finalizeTurn(String turnId) {
+ TurnStreamState state = turnStates.get(turnId);
+ if (state == null) {
+ return;
+ }
+ // Seal any active thinking block before the turn ends
+ if (state.currentBlockType == ChildBlockType.THINKING) {
+ sealCurrentThinkingBlock(turnId);
+ } else if (state.currentBlockType == ChildBlockType.RESPONSE
+ && state.currentReplyText != null && state.currentReplyText.length() > 0) {
+ String blockHtml = htmlFactory.createCopilotReplyHtmlBlock(
+ state.currentChildBlockId, state.currentReplyText.toString());
+ bridge.replaceBlock(state.currentChildBlockId, blockHtml);
+ }
+ state.resetTransient();
+ }
+
+ private void restoreCopilotTurnContent(String turnId, String contentId,
+ CopilotTurnData copilotTurn) {
+ ReplyData replyData = copilotTurn.getReply();
+ if (replyData == null) {
+ return;
+ }
+
+ int blockIdx = 0;
+
+ if (StringUtils.isNotBlank(replyData.getText())) {
+ String blockId = ConversationHtmlBlockFactory.copilotChildBlockId(turnId, blockIdx++);
+ bridge.insertBlock(contentId,
+ htmlFactory.createCopilotReplyHtmlBlock(blockId, replyData.getText()));
+ }
+
+ if (replyData.getEditAgentRounds() != null) {
+ for (EditAgentRoundData round : replyData.getEditAgentRounds()) {
+ ThinkingBlockData thinkingBlock = round.getThinkingBlock();
+ if (thinkingBlock != null && StringUtils.isNotBlank(thinkingBlock.getContent())) {
+ String blockId = ConversationHtmlBlockFactory.copilotChildBlockId(turnId, blockIdx++);
+ bridge.insertBlock(contentId,
+ htmlFactory.createRestoredThinkingHtmlBlock(blockId, thinkingBlock));
+ }
+ // Reply text renders before tool calls so it appears above them (and above any nested
+ // subagent card opened by a run_subagent tool call), matching StyledTextConversationWidget.
+ if (StringUtils.isNotBlank(round.getReply())) {
+ String blockId = ConversationHtmlBlockFactory.copilotChildBlockId(turnId, blockIdx++);
+ bridge.insertBlock(contentId,
+ htmlFactory.createCopilotReplyHtmlBlock(blockId, round.getReply()));
+ }
+ if (round.getToolCalls() != null) {
+ for (ToolCallData tc : round.getToolCalls()) {
+ // A run_subagent tool call opens a nested block; the subagent turn (restored
+ // afterwards) renders into it rather than showing a normal tool-call block.
+ if (tc != null && "run_subagent".equalsIgnoreCase(tc.getName())
+ && StringUtils.isNotBlank(tc.getId())) {
+ openSubagentBlock(turnId, tc.getId(),
+ subagentTitle(tc.getProgressMessage(), tc.getName()));
+ continue;
+ }
+ String blockId = ConversationHtmlBlockFactory.copilotChildBlockId(turnId, blockIdx++);
+ bridge.insertBlock(contentId,
+ htmlFactory.createRestoredToolCallHtmlBlock(blockId, tc));
+ }
+ }
+ }
+ }
+
+ if (replyData.getErrorMessages() != null) {
+ for (ErrorMessageData errorMessageData : replyData.getErrorMessages()) {
+ ErrorData errorData = errorMessageData.getError();
+ String errorMessage = errorData != null
+ ? errorData.getMessage() : "An error occurred";
+ int errorCode = errorData != null ? errorData.getCode() : 0;
+ String modelProviderName = errorData != null ? errorData.getModelProviderName() : null;
+ List actions = resolveQuotaActions(errorCode, modelProviderName);
+ String blockId = ConversationHtmlBlockFactory.copilotChildBlockId(turnId, blockIdx++);
+ bridge.insertBlock(contentId,
+ htmlFactory.createWarningMessageHtmlBlock(blockId, errorMessage, actions));
+ }
+ }
+
+ if (replyData.getAgentMessages() != null) {
+ for (AgentMessageData agentMsg : replyData.getAgentMessages()) {
+ if (StringUtils.equals(agentMsg.getAgentSlug(),
+ UiConstants.GITHUB_COPILOT_CODING_AGENT_SLUG)) {
+ String blockId = ConversationHtmlBlockFactory.copilotChildBlockId(turnId, blockIdx++);
+ bridge.insertBlock(contentId,
+ htmlFactory.createAgentMessageHtmlBlock(blockId,
+ agentMsg.getTitle(), agentMsg.getDescription(),
+ agentMsg.getPrLink()));
+ }
+ }
+ }
+ }
+
+ private void showStreamingIndicator(String turnId) {
+ TurnStreamState state = stateFor(turnId);
+ String indicatorId = ConversationHtmlBlockFactory.streamingIndicatorId(turnId);
+ String html = htmlFactory.createStreamingIndicatorHtmlBlock(indicatorId);
+ bridge.insertBlock(state.contentAreaId, html);
+ state.streamingIndicatorVisible = true;
+ }
+
+ private void removeStreamingIndicator(String turnId) {
+ TurnStreamState state = turnStates.get(turnId);
+ if (state == null || !state.streamingIndicatorVisible) {
+ return;
+ }
+ bridge.removeBlock(ConversationHtmlBlockFactory.streamingIndicatorId(turnId));
+ state.streamingIndicatorVisible = false;
+ }
+
+ private boolean hasRenderableOutput(ChatProgressValue value) {
+ if (StringUtils.isNotBlank(value.getReply())) {
+ return true;
+ }
+ if (value.getAgentRounds() != null && !value.getAgentRounds().isEmpty()) {
+ AgentRound round = value.getAgentRounds().get(0);
+ if (round.getReply() != null && !round.getReply().isEmpty()) {
+ return true;
+ }
+ if (round.getToolCalls() != null && !round.getToolCalls().isEmpty()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private String extractReplyChunk(ChatProgressValue value) {
+ if (value.getAgentRounds() != null && !value.getAgentRounds().isEmpty()) {
+ return value.getAgentRounds().get(0).getReply();
+ }
+ return value.getReply();
+ }
+
+ /** Returns the streaming state for a turn, creating a default top-level one if absent. */
+ private TurnStreamState stateFor(String turnId) {
+ return turnStates.computeIfAbsent(turnId,
+ id -> new TurnStreamState(ConversationHtmlBlockFactory.contentBlockId(id, true)));
+ }
+
+ /**
+ * Ensures a streaming state exists for a turn seen in a {@code report} event. Normally the state
+ * is created by {@link #beginTurn}; this defensively creates one (nested when a subagent is
+ * active) if the begin event was not observed.
+ */
+ private TurnStreamState ensureTurnState(String turnId, ChatProgressValue value) {
+ TurnStreamState state = turnStates.get(turnId);
+ if (state != null) {
+ return state;
+ }
+ boolean nested = value != null && StringUtils.isNotBlank(value.getParentTurnId())
+ && activeSubagentContentAreaId != null;
+ String contentAreaId = nested
+ ? activeSubagentContentAreaId
+ : ConversationHtmlBlockFactory.contentBlockId(turnId, true);
+ state = new TurnStreamState(contentAreaId);
+ turnStates.put(turnId, state);
+ return state;
+ }
+
+ private String getUserDisplayName() {
+ return serviceManager.getAvatarService().getUserName();
+ }
+
+ private String getCopilotDisplayName() {
+ return serviceManager.getAvatarService().getCopilotName();
+ }
+
+ /** Returns the current user's avatar as a {@code data:} URI (see {@link AvatarService}). */
+ private String getUserAvatarUri() {
+ return serviceManager.getAvatarService().getAvatarForCurrentUserAsDataUri();
+ }
+
+ @Override
+ public void copyToClipboard(String code) {
+ Display.getDefault().asyncExec(() -> {
+ Clipboard clipboard = new Clipboard(Display.getDefault());
+ clipboard.setContents(
+ new Object[]{code},
+ new Transfer[]{TextTransfer.getInstance()});
+ clipboard.dispose();
+ });
+ }
+
+ @Override
+ public void insertAtCursor(String code) {
+ Display.getDefault().asyncExec(() -> {
+ if (browser.isDisposed()) {
+ return;
+ }
+ IEditorPart editor = SwtUtils.getActiveEditorPart();
+ if (editor == null) {
+ showInsertError("Cannot Insert", "No active editor found.");
+ return;
+ }
+
+ ITextEditor textEditor = editor instanceof ITextEditor te
+ ? te
+ : editor.getAdapter(ITextEditor.class);
+ if (textEditor == null) {
+ CopilotCore.LOGGER
+ .error(new IllegalStateException("The active editor doesn't support text insertion."));
+ showInsertError("Cannot Insert", "The active editor doesn't support text insertion.");
+ return;
+ }
+
+ IDocumentProvider provider = textEditor.getDocumentProvider();
+ IDocument doc = provider == null ? null : provider.getDocument(textEditor.getEditorInput());
+ if (doc == null) {
+ CopilotCore.LOGGER
+ .error(new IllegalStateException("Failed to get the document from the active editor."));
+ showInsertError("Cannot Insert", "Failed to get the document from the active editor.");
+ return;
+ }
+
+ try {
+ ITextSelection sel = (ITextSelection) textEditor.getSelectionProvider().getSelection();
+ int offset = sel.getOffset();
+ doc.replace(offset, sel.getLength(), code);
+
+ // Set the cursor position after the inserted text
+ textEditor.selectAndReveal(offset + code.length(), 0);
+ } catch (BadLocationException e) {
+ CopilotCore.LOGGER.error("Failed to insert code at cursor", e);
+ showInsertError("Insert Failed", "An error occurred while inserting the code: "
+ + e.getMessage());
+ }
+ });
+ }
+
+ /**
+ * Shows a modal error dialog for a failed "Insert into editor" action, matching the feedback the
+ * StyledText renderer gives for the same failures. The dialog restores focus to the previously
+ * focused control on dismissal, so no explicit focus handling is needed here.
+ *
+ * @param title the dialog title
+ * @param message the human-readable reason the insertion could not be performed
+ */
+ private void showInsertError(String title, String message) {
+ if (browser.isDisposed()) {
+ return;
+ }
+ MessageDialog.openError(browser.getShell(), title, message);
+ }
+
+ @Override
+ public void acceptToolAction(int actionIndex) {
+ resolveConfirmation(actionIndex, true);
+ }
+
+ @Override
+ public void dismissToolAction() {
+ resolveConfirmation(-1, false);
+ }
+
+ @Override
+ public void copilotAction(String action, String param) {
+ switch (action) {
+ case "openLink":
+ UiUtils.openLink(param);
+ break;
+ case "openJobList":
+ UiUtils.openE4Part(Constants.GITHUB_JOBS_VIEW_ID);
+ break;
+ default:
+ break;
+ }
+ }
+
+ @Override
+ public void logError(String message) {
+ CopilotCore.LOGGER.error(new IllegalStateException("Browser chat renderer: " + message));
+ }
+}
diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java
index b3db64b4..c7e8c6b8 100644
--- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java
@@ -3,17 +3,14 @@
package com.microsoft.copilot.eclipse.ui.chat;
-import java.util.ArrayList;
import java.util.HashMap;
import java.util.IdentityHashMap;
-import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
-import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
import org.eclipse.e4.core.services.events.IEventBroker;
@@ -34,13 +31,9 @@
import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentToolCall;
import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatProgressValue;
import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel;
-import com.microsoft.copilot.eclipse.core.lsp.protocol.TodoItem;
-import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolSpecificData;
import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CheckQuotaResult;
import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CopilotPlan;
-import com.microsoft.copilot.eclipse.ui.CopilotUi;
import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager;
-import com.microsoft.copilot.eclipse.ui.chat.services.TodoListService;
import com.microsoft.copilot.eclipse.ui.i18n.Messages;
import com.microsoft.copilot.eclipse.ui.swt.CssConstants;
import com.microsoft.copilot.eclipse.ui.utils.MenuUtils;
@@ -59,13 +52,6 @@ public class ChatContentViewer extends Composite {
private static final int SCROLL_THRESHOLD = 100;
- /**
- * Matches the trailing "| Request ID: ..." and "GitHub Request ID: ..." segments that the
- * language server appends to user-facing error messages.
- */
- private static final Pattern REQUEST_ID_SUFFIX =
- Pattern.compile("\\s*\\|?\\s*(?:GitHub\\s+)?Request\\s+ID:\\s*\\S+\\.?", Pattern.CASE_INSENSITIVE);
-
private ChatServiceManager serviceManager;
private String conversationId;
@@ -253,8 +239,6 @@ private void doProcessTurnEvent(ChatProgressValue value) {
return;
}
- ChatServiceManager chatServiceManager = CopilotUi.getPlugin().getChatServiceManager();
-
if (value.getKind() == WorkDoneProgressKind.report) {
if (turnWidget instanceof ThinkingTurnWidget thinkingTurn) {
thinkingTurn.setConversationContext(conversationId, value.getTurnId());
@@ -277,9 +261,6 @@ private void doProcessTurnEvent(ChatProgressValue value) {
if (agentRound.getToolCalls() != null && !agentRound.getToolCalls().isEmpty()) {
AgentToolCall toolCall = agentRound.getToolCalls().get(0);
turnWidget.appendToolCallStatus(toolCall);
-
- // Extract and process todo list from tool result details
- processTodoListFromToolCall(chatServiceManager, value.getConversationId(), toolCall);
}
} else {
// Handle chat mode responses
@@ -294,15 +275,7 @@ private void doProcessTurnEvent(ChatProgressValue value) {
turnWidget.flushMessageBuffer();
}
- String errMsg = value.getErrorMessage();
- if (StringUtils.isNotEmpty(errMsg)) {
- errMsg = REQUEST_ID_SUFFIX.matcher(errMsg).replaceAll(StringUtils.EMPTY).trim();
- }
- String reason = value.getErrorReason();
- if (StringUtils.isNotEmpty(reason) && reason.equals("model_not_supported")) {
- // TODO: add enable button for better UX.
- errMsg = Messages.chat_model_unsupported_message;
- }
+ String errMsg = ChatErrorMessages.resolveDisplayMessage(value);
if (StringUtils.isNotEmpty(errMsg)) {
// TODO: Remove this legacy fallback after TBB is officially released.
// When the language server has not enabled token-based billing yet, fall back to the
@@ -398,36 +371,6 @@ private static boolean hasRenderableAgentRound(ChatProgressValue value) {
return false;
}
- /**
- * Process todo list from tool call result. Extracts todo list data from the tool-specific data
- * and updates the TodoListService.
- *
- * @param chatServiceManager the chat service manager
- * @param conversationId the conversation ID
- * @param toolCall the agent tool call containing tool-specific data
- */
- private void processTodoListFromToolCall(ChatServiceManager chatServiceManager, String conversationId,
- AgentToolCall toolCall) {
- if (chatServiceManager == null || conversationId == null || toolCall == null) {
- return;
- }
-
- ToolSpecificData toolSpecificData = toolCall.getToolSpecificData();
- if (toolSpecificData == null || toolSpecificData.getTodoList() == null) {
- return;
- }
-
- TodoListService todoListService = chatServiceManager.getTodoListService();
- if (todoListService == null) {
- return;
- }
-
- List todos = toolSpecificData.getTodoList();
- if (todos != null) {
- todoListService.setTodoList(new ArrayList<>(todos));
- }
- }
-
/**
* Shows the compacting status on the latest Copilot turn after flushing any buffered reply text.
*/
diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatErrorMessages.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatErrorMessages.java
new file mode 100644
index 00000000..640b14e9
--- /dev/null
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatErrorMessages.java
@@ -0,0 +1,54 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+package com.microsoft.copilot.eclipse.ui.chat;
+
+import java.util.regex.Pattern;
+
+import org.apache.commons.lang3.StringUtils;
+
+import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatProgressValue;
+import com.microsoft.copilot.eclipse.ui.i18n.Messages;
+
+/**
+ * Normalizes user-facing error messages carried by {@link ChatProgressValue} so that both the SWT
+ * ({@link ChatContentViewer}) and browser ({@link BrowserConversationWidget}) conversation widgets
+ * display identical text.
+ */
+public final class ChatErrorMessages {
+
+ /** Server reason string indicating the selected model is not supported. */
+ private static final String REASON_MODEL_NOT_SUPPORTED = "model_not_supported";
+
+ /**
+ * Matches the trailing "| Request ID: ..." and "GitHub Request ID: ..." segments that the
+ * language server appends to user-facing error messages.
+ */
+ private static final Pattern REQUEST_ID_SUFFIX = Pattern.compile(
+ "\\s*\\|?\\s*(?:GitHub\\s+)?Request\\s+ID:\\s*\\S+\\.?", Pattern.CASE_INSENSITIVE);
+
+ private ChatErrorMessages() {
+ }
+
+ /**
+ * Resolves the display error message for a progress event: strips the trailing request-ID suffix
+ * and maps the {@code model_not_supported} reason to a friendly message. Returns the original
+ * message (which may be blank) when no normalization applies.
+ *
+ * @param value the progress event
+ * @return the normalized message to display, possibly blank
+ */
+ public static String resolveDisplayMessage(ChatProgressValue value) {
+ if (value == null) {
+ return StringUtils.EMPTY;
+ }
+ String message = value.getErrorMessage();
+ if (StringUtils.isNotEmpty(message)) {
+ message = REQUEST_ID_SUFFIX.matcher(message).replaceAll(StringUtils.EMPTY).trim();
+ }
+ if (REASON_MODEL_NOT_SUPPORTED.equals(value.getErrorReason())) {
+ message = Messages.chat_model_unsupported_message;
+ }
+ return message;
+ }
+}
diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java
index c5aa62c6..81570c85 100644
--- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java
@@ -9,6 +9,7 @@
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
@@ -25,6 +26,7 @@
import org.eclipse.e4.core.services.events.IEventBroker;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jface.preference.IPreferenceStore;
+import org.eclipse.jface.util.IPropertyChangeListener;
import org.eclipse.lsp4e.LSPEclipseUtils;
import org.eclipse.lsp4j.Range;
import org.eclipse.lsp4j.WorkspaceFolder;
@@ -69,19 +71,13 @@
import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotStatusResult;
import com.microsoft.copilot.eclipse.core.lsp.protocol.RateLimitWarningParams;
import com.microsoft.copilot.eclipse.core.lsp.protocol.TodoItem;
+import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolSpecificData;
import com.microsoft.copilot.eclipse.core.lsp.protocol.Turn;
import com.microsoft.copilot.eclipse.core.lsp.protocol.codingagent.CodingAgentMessageRequestParams;
import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.QuotaWarningParams;
import com.microsoft.copilot.eclipse.core.persistence.AbstractTurnData;
import com.microsoft.copilot.eclipse.core.persistence.ConversationPersistenceManager;
import com.microsoft.copilot.eclipse.core.persistence.ConversationXmlData;
-import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData;
-import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.AgentMessageData;
-import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.EditAgentRoundData;
-import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ErrorData;
-import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ErrorMessageData;
-import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ReplyData;
-import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ToolCallData;
import com.microsoft.copilot.eclipse.core.persistence.UserTurnData;
import com.microsoft.copilot.eclipse.terminal.api.IRunInTerminalTool;
import com.microsoft.copilot.eclipse.terminal.api.TerminalServiceManager;
@@ -116,7 +112,7 @@ public class ChatView extends ViewPart implements ChatProgressListener, MessageL
private Composite contentWrapper;
private HandoffContainer handoffContainer;
private ActionBar actionBar;
- private ChatContentViewer chatContentViewer;
+ private IConversationWidget conversationWidget;
private Composite loadingViewer;
private Composite noSubscriptionViewer;
private Composite beforeLoginWelcomeViewer;
@@ -155,6 +151,9 @@ public class ChatView extends ViewPart implements ChatProgressListener, MessageL
private EventHandler compressionStartedHandler;
private EventHandler compressionCompletedHandler;
+ /** Listener that rebuilds the conversation page when the chat renderer preference is toggled. */
+ private IPropertyChangeListener rendererPreferenceListener;
+
// Context activation for chat view keyboard shortcuts
private static final String CHAT_VIEW_CONTEXT = "com.microsoft.copilot.eclipse.chatViewContext";
@@ -297,8 +296,8 @@ public void done(IJobChangeEvent event) {
}
// Clear existing content by recreating the chat content viewer
- if (chatContentViewer != null) {
- chatContentViewer.dispose();
+ if (conversationWidget != null) {
+ conversationWidget.dispose();
createConversationPage();
}
@@ -328,7 +327,9 @@ public void done(IJobChangeEvent event) {
}
// Scroll to bottom after restoring all turns
- SwtUtils.invokeOnDisplayThreadAsync(this::scrollContentToBottom, chatContentViewer);
+ if (conversationWidget != null) {
+ conversationWidget.scrollToBottom();
+ }
// Hide chat history and show restored conversation
hideChatHistory();
@@ -416,10 +417,10 @@ public void done(IJobChangeEvent event) {
return;
}
SwtUtils.invokeOnDisplayThreadAsync(() -> {
- if (this.chatContentViewer == null || this.chatContentViewer.isDisposed()) {
+ if (this.conversationWidget == null || this.conversationWidget.isDisposed()) {
return;
}
- this.chatContentViewer.showCompactingStatusOnLatestCopilotTurn();
+ this.conversationWidget.showCompactingStatusOnLatestCopilotTurn();
}, parent);
};
this.eventBroker.subscribe(CopilotEventConstants.TOPIC_CHAT_COMPRESSION_STARTED,
@@ -434,10 +435,10 @@ public void done(IJobChangeEvent event) {
return;
}
SwtUtils.invokeOnDisplayThreadAsync(() -> {
- if (this.chatContentViewer == null || this.chatContentViewer.isDisposed()) {
+ if (this.conversationWidget == null || this.conversationWidget.isDisposed()) {
return;
}
- this.chatContentViewer.hideCompactingStatusOnLatestCopilotTurn();
+ this.conversationWidget.hideCompactingStatusOnLatestCopilotTurn();
if (params.contextInfo() != null) {
this.chatServiceManager.getContextWindowService().updateContextSize(params.contextInfo());
}
@@ -448,6 +449,9 @@ public void done(IJobChangeEvent event) {
// Register part listener to activate/deactivate chat view context for keyboard shortcuts
registerPartListener();
+
+ // Rebuild the conversation page in place when the chat renderer preference is toggled.
+ registerRendererPreferenceListener();
}
/**
@@ -786,8 +790,88 @@ private void createLoadingPage() {
*/
private void createConversationPage() {
clearChatView(this.contentWrapper);
- this.chatContentViewer = new ChatContentViewer(this.contentWrapper, SWT.NONE, this.chatServiceManager);
- this.chatContentViewer.requestLayout();
+ this.conversationWidget = createConversationWidget(this.contentWrapper);
+ this.conversationWidget.requestLayout();
+ }
+
+ private IConversationWidget createConversationWidget(Composite parent) {
+ boolean useBrowser = CopilotUi.getPlugin().getPreferenceStore()
+ .getBoolean(Constants.USE_BROWSER_BASED_CHAT_RENDERER);
+ if (useBrowser) {
+ return new BrowserConversationWidget(parent, this.chatServiceManager);
+ }
+ return new StyledTextConversationWidget(parent, this.chatServiceManager);
+ }
+
+ /**
+ * Register a preference listener that rebuilds the conversation page in place when the
+ * {@link Constants#USE_BROWSER_BASED_CHAT_RENDERER} preference changes, so a renderer switch takes
+ * effect on an already-open chat view without requiring an IDE restart.
+ */
+ private void registerRendererPreferenceListener() {
+ IPreferenceStore preferenceStore = CopilotUi.getPlugin().getPreferenceStore();
+ this.rendererPreferenceListener = event -> {
+ if (!Constants.USE_BROWSER_BASED_CHAT_RENDERER.equals(event.getProperty())) {
+ return;
+ }
+ if (Objects.equals(event.getOldValue(), event.getNewValue())) {
+ return;
+ }
+ SwtUtils.invokeOnDisplayThreadAsync(this::reloadConversationRenderer, parent);
+ };
+ preferenceStore.addPropertyChangeListener(this.rendererPreferenceListener);
+ }
+
+ /**
+ * Rebuilds the conversation page using the renderer currently selected by the
+ * {@link Constants#USE_BROWSER_BASED_CHAT_RENDERER} preference, preserving the active conversation.
+ *
+ *
The current conversation (if any) is reloaded from persistence so the visible history is not
+ * lost. The reload is skipped while a response is streaming, because the in-flight turn is not yet
+ * persisted and would be dropped by the widget swap; the new renderer then takes effect the next
+ * time the page is rebuilt. Must be invoked on the UI thread.
+ */
+ public void reloadConversationRenderer() {
+ if (this.contentWrapper == null || this.contentWrapper.isDisposed() || this.conversationWidget == null) {
+ return;
+ }
+
+ // Avoid swapping the widget mid-stream; the in-flight turn is not yet persisted and would be lost.
+ if (this.actionBar != null && !this.actionBar.isSendButton()) {
+ return;
+ }
+
+ String currentConversationId = this.conversationId;
+
+ createConversationPage();
+
+ // Re-restore the active conversation's turns so the visible history survives the renderer swap.
+ if (StringUtils.isNotBlank(currentConversationId) && this.persistenceManager != null) {
+ this.persistenceManager.loadConversation(currentConversationId).thenAccept(historyConversation -> {
+ if (historyConversation == null) {
+ return;
+ }
+ SwtUtils.invokeOnDisplayThreadAsync(() -> {
+ if (this.conversationWidget == null || this.conversationWidget.isDisposed()) {
+ return;
+ }
+ if (historyConversation.getTurns() != null && !historyConversation.getTurns().isEmpty()) {
+ for (AbstractTurnData turn : historyConversation.getTurns()) {
+ restoreTurn(turn);
+ }
+ }
+ List todos = historyConversation.getTodos();
+ TodoListService todoService = chatServiceManager.getTodoListService();
+ if (todoService != null && todos != null && !todos.isEmpty()) {
+ todoService.setTodoList(todos);
+ }
+ this.conversationWidget.scrollToBottom();
+ }, contentWrapper);
+ }).exceptionally(ex -> {
+ CopilotCore.LOGGER.error("Failed to reload conversation after renderer change: " + currentConversationId, ex);
+ return null;
+ });
+ }
}
/**
@@ -836,9 +920,9 @@ private void clearChatView(Composite composite) {
this.agentModeViewer.dispose();
this.agentModeViewer = null;
}
- if (this.chatContentViewer != null) {
- this.chatContentViewer.dispose();
- this.chatContentViewer = null;
+ if (this.conversationWidget != null) {
+ this.conversationWidget.dispose();
+ this.conversationWidget = null;
}
if (this.noSubscriptionViewer != null) {
this.noSubscriptionViewer.dispose();
@@ -855,13 +939,13 @@ private void clearChatView(Composite composite) {
*/
@Override
public void onChatProgress(ChatProgressValue value) {
- if (this.actionBar.isSendButton()) {
+ if (this.actionBar == null || this.actionBar.isSendButton()) {
return;
}
switch (value.getKind()) {
case begin:
- if (this.chatContentViewer != null) {
- this.chatContentViewer.getLatestOrCreateNewTurnWidget(value.getTurnId(), true, false);
+ if (this.conversationWidget != null) {
+ this.conversationWidget.beginTurn(value.getTurnId(), true, false);
}
// Handle subagent conversation ID management
@@ -885,8 +969,8 @@ public void onChatProgress(ChatProgressValue value) {
this.conversationState = ConversationState.CONTINUED_CONVERSATION;
}
// Always sync conversationId — chatContentViewer may have been recreated
- if (this.chatContentViewer != null) {
- this.chatContentViewer.setConversationId(this.conversationId);
+ if (this.conversationWidget != null) {
+ this.conversationWidget.setConversationId(this.conversationId);
}
}
@@ -934,14 +1018,19 @@ public void onChatProgress(ChatProgressValue value) {
}
if ((value.getAgentRounds() == null || value.getAgentRounds().isEmpty())
&& (value.getReply() == null || value.getReply().isEmpty())
- && (value.getThinking() == null || StringUtils.isBlank(value.getThinking().text()))) {
+ && (value.getThinking() == null || StringUtils.isBlank(value.getThinking().text()))
+ && StringUtils.isEmpty(value.getErrorMessage())) {
return;
}
- if (this.chatContentViewer != null) {
- this.chatContentViewer.processTurnEvent(value);
+ if (this.conversationWidget != null) {
+ this.conversationWidget.processTurnEvent(value);
}
- String thinkingBlockId = this.chatContentViewer != null
- ? this.chatContentViewer.getActiveThinkingBlockId(value.getTurnId()) : null;
+
+ // Update the todo list from any todo-writing tool calls in this report.
+ updateTodoListFromReport(value);
+
+ String thinkingBlockId = this.conversationWidget != null
+ ? this.conversationWidget.getActiveThinkingBlockId(value.getTurnId()) : null;
// Track run_subagent tool call ID for associating subagent turns
if (StringUtils.isBlank(value.getParentTurnId()) && value.getAgentRounds() != null) {
@@ -983,13 +1072,13 @@ public void onChatProgress(ChatProgressValue value) {
return;
}
- if (this.chatContentViewer != null) {
- this.chatContentViewer.processTurnEvent(value);
+ if (this.conversationWidget != null) {
+ this.conversationWidget.processTurnEvent(value);
this.actionBar.resetSendButton();
this.topBanner.updateTitle(value.getSuggestedTitle());
}
- String endThinkingBlockId = this.chatContentViewer != null
- ? this.chatContentViewer.getActiveThinkingBlockId(value.getTurnId()) : null;
+ String endThinkingBlockId = this.conversationWidget != null
+ ? this.conversationWidget.getActiveThinkingBlockId(value.getTurnId()) : null;
// Persist final conversation state and conversation title on end
if (persistenceManager != null) {
@@ -1225,7 +1314,9 @@ private void onSendInternal(String workDoneToken, String message, String agentSl
if (createNewTurn) {
// TODO: Move to createPartControl...eventBroker.subscribe(CopilotEventConstants.TOPIC_CHAT_ON_SEND...(line 114)
// after the refactor.
- this.chatContentViewer.startNewTurn(workDoneToken, message);
+ if (this.conversationWidget != null) {
+ this.conversationWidget.startNewUserTurn(workDoneToken, message);
+ }
}
}
@@ -1295,13 +1386,15 @@ private void displayErrorAndResetSendButton(String workDoneToken, String message
}
String content = String.format(Messages.chat_chatContentView_errorTemplate, message, workDoneToken);
SwtUtils.invokeOnDisplayThread(() -> {
- chatContentViewer.renderErrorMessage(content);
+ if (conversationWidget != null) {
+ conversationWidget.renderErrorMessage(content);
+ }
actionBar.resetSendButton();
}, parent);
}
private void handleCodingAgentMessage(CodingAgentMessageRequestParams params) {
- if (params == null || this.chatContentViewer == null) {
+ if (params == null) {
return;
}
@@ -1315,14 +1408,9 @@ private void handleCodingAgentMessage(CodingAgentMessageRequestParams params) {
persistenceManager.addCodingAgentMessage(params, UiConstants.GITHUB_COPILOT_CODING_AGENT_SLUG);
}
- SwtUtils.invokeOnDisplayThread(() -> {
- if (this.chatContentViewer != null && !this.chatContentViewer.isDisposed()) {
- BaseTurnWidget turnWidget = this.chatContentViewer.getTurnWidget(params.getTurnId());
- if (turnWidget != null && !turnWidget.isDisposed()) {
- turnWidget.createAgentMessageWidget(params);
- }
- }
- }, parent);
+ if (this.conversationWidget != null && !this.conversationWidget.isDisposed()) {
+ this.conversationWidget.renderAgentMessage(params);
+ }
}
/**
@@ -1439,8 +1527,8 @@ public void onCancel() {
if (this.actionBar != null && !this.actionBar.isDisposed()) {
this.actionBar.resetSendButton();
}
- if (this.chatContentViewer != null && !this.chatContentViewer.isDisposed()) {
- this.chatContentViewer.hideCompactingStatusOnLatestCopilotTurn();
+ if (this.conversationWidget != null && !this.conversationWidget.isDisposed()) {
+ this.conversationWidget.hideCompactingStatusOnLatestCopilotTurn();
}
}
@@ -1491,10 +1579,36 @@ public String getSubagentConversationId() {
}
/**
- * Get the current chat content viewer.
+ * Returns the active conversation widget.
*/
- public ChatContentViewer getChatContentViewer() {
- return this.chatContentViewer;
+ public IConversationWidget getConversationWidget() {
+ return this.conversationWidget;
+ }
+
+ /**
+ * Updates the {@link TodoListService} from any todo-writing tool calls carried by a report
+ * progress event. Kept in the view (not a widget) because it is renderer-agnostic domain logic
+ * shared by both the SWT and browser conversation widgets.
+ */
+ private void updateTodoListFromReport(ChatProgressValue value) {
+ if (value == null || value.getAgentRounds() == null || value.getAgentRounds().isEmpty()) {
+ return;
+ }
+ TodoListService todoListService = chatServiceManager.getTodoListService();
+ if (todoListService == null) {
+ return;
+ }
+ for (AgentRound round : value.getAgentRounds()) {
+ if (round.getToolCalls() == null) {
+ continue;
+ }
+ for (AgentToolCall toolCall : round.getToolCalls()) {
+ ToolSpecificData toolSpecificData = toolCall.getToolSpecificData();
+ if (toolSpecificData != null && toolSpecificData.getTodoList() != null) {
+ todoListService.setTodoList(new ArrayList<>(toolSpecificData.getTodoList()));
+ }
+ }
+ }
}
/**
@@ -1640,6 +1754,11 @@ public void dispose() {
}
}
+ if (this.rendererPreferenceListener != null) {
+ CopilotUi.getPlugin().getPreferenceStore().removePropertyChangeListener(this.rendererPreferenceListener);
+ this.rendererPreferenceListener = null;
+ }
+
if (this.chatServiceManager != null) {
if (this.chatServiceManager.getUserPreferenceService() != null) {
this.chatServiceManager.getUserPreferenceService().unbindChatView();
@@ -1673,9 +1792,9 @@ public void dispose() {
this.chatHistoryViewer.dispose();
this.chatHistoryViewer = null;
}
- if (this.chatContentViewer != null) {
- this.chatContentViewer.dispose();
- this.chatContentViewer = null;
+ if (this.conversationWidget != null) {
+ this.conversationWidget.dispose();
+ this.conversationWidget = null;
}
if (this.afterLoginWelcomeViewer != null) {
this.afterLoginWelcomeViewer.dispose();
@@ -1884,21 +2003,16 @@ public void hideChatHistory() {
* Scroll the chat content viewer to the bottom.
*/
public void scrollContentToBottom() {
- if (chatContentViewer == null || chatContentViewer.isDisposed()) {
+ if (conversationWidget == null || conversationWidget.isDisposed()) {
return;
}
-
- SwtUtils.invokeOnDisplayThreadAsync(() -> {
- chatContentViewer.refreshLayoutFull();
- chatContentViewer.scrollToBottomIfAutoScroll();
- }, chatContentViewer);
+ conversationWidget.scrollToBottom();
}
/**
- * Render model information in the Copilot turn widget.
+ * Render model information in the conversation widget.
*
* @param turnId the turn ID
- * @param conversationId the conversation ID to use for persistence
* @param modelName the model name
* @param billingMultiplier the billing multiplier
* @param reasoningEffort the reasoning effort sent for this turn (may be {@code null} when the model does not
@@ -1906,134 +2020,23 @@ public void scrollContentToBottom() {
*/
private void renderModelInfoInTurnWidget(String turnId, String modelName, double billingMultiplier,
String reasoningEffort) {
- BaseTurnWidget turnWidget = this.chatContentViewer.getTurnWidget(turnId);
- if (turnWidget instanceof CopilotTurnWidget copilotWidget) {
- copilotWidget.renderModelInfo(modelName, billingMultiplier, reasoningEffort);
-
- // Refresh the scroller layout to ensure the footer is visible.
- SwtUtils.invokeOnDisplayThreadAsync(() -> {
- this.chatContentViewer.refreshLayoutFull();
- this.chatContentViewer.scrollToBottomIfAutoScroll();
- }, this.chatContentViewer);
+ if (this.conversationWidget == null || this.conversationWidget.isDisposed()) {
+ return;
}
+ this.conversationWidget.renderModelInfo(turnId, modelName, billingMultiplier, reasoningEffort);
}
/**
- * Restore a single turn from persisted conversation data.
+ * Restore a single turn from persisted conversation data. Delegates to the active
+ * {@link IConversationWidget} implementation.
*
* @param turn the turn data to restore
*/
private void restoreTurn(AbstractTurnData turn) {
- if (turn == null || chatContentViewer == null) {
- return;
- }
-
- // Subagent turns: render their content inside the parent turn's subagent block
- if (turn instanceof CopilotTurnData copilotTurn
- && StringUtils.isNotBlank(copilotTurn.getParentTurnId())) {
- BaseTurnWidget parentWidget = chatContentViewer.getTurnWidget(copilotTurn.getParentTurnId());
- if (parentWidget != null) {
- String toolCallId = copilotTurn.getSubagentToolCallId();
- if (StringUtils.isNotBlank(toolCallId)) {
- // Restore subagent content into the SubagentMessageBlock identified by the tool call ID
- parentWidget.restoreSubagentContent(toolCallId, copilotTurn, persistenceManager.getDataFactory());
- } else {
- // Fallback: append to parent widget directly (legacy data without subagentToolCallId)
- restoreCopilotTurnContent(copilotTurn, parentWidget);
- }
- }
- return;
- }
-
- // Create user turn widget and populate with user message
- if (turn instanceof UserTurnData userTurn) {
- if (userTurn.getMessage() == null || StringUtils.isNotBlank(userTurn.getMessage().getText())) {
- BaseTurnWidget userTurnWidget = chatContentViewer.getLatestOrCreateNewTurnWidget(turn.getTurnId(), false, true);
- userTurnWidget.appendMessage(userTurn.getMessage().getText());
- userTurnWidget.flushMessageBuffer();
- return;
- }
- } else if (turn instanceof CopilotTurnData copilotTurn) {
- BaseTurnWidget copilotTurnWidget = chatContentViewer.getLatestOrCreateNewTurnWidget(turn.getTurnId(), true, true);
- restoreCopilotTurnContent(copilotTurn, copilotTurnWidget);
-
- copilotTurnWidget.flushMessageBuffer();
-
- // Restore model info footer if model name is present
- // This must be done AFTER flushMessageBuffer() to ensure footer appears at the bottom
- ReplyData replyData = copilotTurn.getReply();
- if (replyData != null && StringUtils.isNotBlank(replyData.getModelName())) {
- // Reasoning effort was captured and persisted at send time so the footer reflects what was actually used
- // for this turn, not whatever the user has selected now.
- renderModelInfoInTurnWidget(turn.getTurnId(), replyData.getModelName(), replyData.getBillingMultiplier(),
- replyData.getReasoningEffort());
- }
- }
- }
-
- /**
- * Restores the content of a CopilotTurnData (reply text, agent rounds, tool calls, errors, agent messages) into the
- * given turn widget. Used for both main copilot turns and subagent turns.
- */
- private void restoreCopilotTurnContent(CopilotTurnData copilotTurn, BaseTurnWidget turnWidget) {
- ReplyData replyData = copilotTurn.getReply();
- if (replyData == null) {
+ if (turn == null || conversationWidget == null || conversationWidget.isDisposed()) {
return;
}
-
- ThinkingTurnWidget thinkingWidget =
- turnWidget instanceof ThinkingTurnWidget ? (ThinkingTurnWidget) turnWidget : null;
-
- if (StringUtils.isNotBlank(replyData.getText())) {
- turnWidget.appendMessage(replyData.getText());
- }
-
- if (replyData.getEditAgentRounds() != null && !replyData.getEditAgentRounds().isEmpty()) {
- for (EditAgentRoundData round : replyData.getEditAgentRounds()) {
- // Restore thinking block before the round's reply and tool calls
- if (thinkingWidget != null && round.getThinkingBlock() != null) {
- thinkingWidget.restoreThinkingBlock(round.getThinkingBlock());
- }
- if (round.getReply() != null && !round.getReply().isEmpty()) {
- turnWidget.appendMessage(round.getReply());
- }
- if (round.getToolCalls() != null && !round.getToolCalls().isEmpty()) {
- for (ToolCallData toolCallData : round.getToolCalls()) {
- AgentToolCall agentToolCall = persistenceManager.getDataFactory()
- .convertToolCallDataToAgentToolCall(toolCallData);
- turnWidget.appendToolCallStatus(agentToolCall);
- }
- }
- }
- }
-
- if (replyData.getErrorMessages() != null && !replyData.getErrorMessages().isEmpty()) {
- for (ErrorMessageData errorMessageData : replyData.getErrorMessages()) {
- ErrorData errorData = errorMessageData.getError();
- SwtUtils.invokeOnDisplayThread(() -> {
- String errorMessage = errorData != null ? errorData.getMessage() : Messages.chat_warnWidget_defaultErrorMsg;
- int errorCode = errorData != null ? errorData.getCode() : 0;
- String modelProviderName = errorData != null ? errorData.getModelProviderName() : null;
- turnWidget.createWarnDialog(errorMessage, errorCode, modelProviderName);
- }, parent);
- }
- }
-
- if (replyData.getAgentMessages() != null && !replyData.getAgentMessages().isEmpty()) {
- for (AgentMessageData agentMessageData : replyData.getAgentMessages()) {
- if (StringUtils.equals(agentMessageData.getAgentSlug(), UiConstants.GITHUB_COPILOT_CODING_AGENT_SLUG)) {
- SwtUtils.invokeOnDisplayThread(() -> {
- CodingAgentMessageRequestParams params = new CodingAgentMessageRequestParams();
- params.setTitle(agentMessageData.getTitle());
- params.setDescription(agentMessageData.getDescription());
- params.setPrLink(agentMessageData.getPrLink());
- params.setConversationId(this.conversationId);
- params.setTurnId(copilotTurn.getTurnId());
- turnWidget.createAgentMessageWidget(params);
- }, parent);
- }
- }
- }
+ conversationWidget.restoreTurn(turn, persistenceManager.getDataFactory());
}
/**
diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ConversationHtmlBlockFactory.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ConversationHtmlBlockFactory.java
new file mode 100644
index 00000000..5f17ec76
--- /dev/null
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ConversationHtmlBlockFactory.java
@@ -0,0 +1,567 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+package com.microsoft.copilot.eclipse.ui.chat;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.commons.lang3.StringUtils;
+import org.commonmark.Extension;
+import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension;
+import org.commonmark.ext.gfm.tables.TablesExtension;
+import org.commonmark.ext.task.list.items.TaskListItemsExtension;
+import org.commonmark.parser.Parser;
+import org.commonmark.renderer.html.HtmlRenderer;
+
+import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction;
+import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent;
+import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentToolCall;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ThinkingBlockData;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ToolCallData;
+import com.microsoft.copilot.eclipse.ui.chat.QuotaActions.QuotaAction;
+
+/**
+ * Factory for creating HTML block fragments used by {@link BrowserConversationWidget}.
+ *
+ *
Each method produces a self-contained HTML DIV block having an
+ * {@code id} attribute. The widget inserts or updates these blocks in the browser DOM
+ * using the generic JavaScript API for code block manipulation.
+ */
+public class ConversationHtmlBlockFactory {
+
+ /**
+ * DOM ID of the root chat container defined in {@code resources/html/chat-view.html}. New turn
+ * containers and top-level blocks are appended as its children. Must stay in sync with the
+ * {@code id} used in {@code chat-view.html}.
+ */
+ public static final String CHAT_CONTAINER_ID = "chat-container";
+
+ private final Parser markdownParser;
+ private final HtmlRenderer htmlRenderer;
+ private String copyIconDataUri = "";
+ private String insertIconDataUri = "";
+
+ /** Creates a new factory with GFM tables and other extensions. */
+ public ConversationHtmlBlockFactory() {
+ List extensions = List.of(
+ TablesExtension.create(),
+ TaskListItemsExtension.create(),
+ StrikethroughExtension.create()
+ );
+ this.markdownParser = Parser.builder().extensions(extensions).build();
+ this.htmlRenderer = HtmlRenderer.builder()
+ .extensions(extensions)
+ // prevent HTML injection in potentially malicious LLM-generated Markdown code
+ .escapeHtml(true)
+ // avoid potentially malicious URL schemes in LLM-generated Markdown code
+ .sanitizeUrls(true)
+ .build();
+ }
+
+ /**
+ * Sets the data URIs for code block action button icons.
+ *
+ * @param copyIconUri base64 data URI for the copy icon
+ * @param insertIconUri base64 data URI for the insert icon
+ */
+ public void setCodeBlockIcons(String copyIconUri, String insertIconUri) {
+ this.copyIconDataUri = copyIconUri != null ? copyIconUri : "";
+ this.insertIconDataUri = insertIconUri != null ? insertIconUri : "";
+ }
+
+ /**
+ * User turn container ID: {@code turnId-user}. User and copilot turns share the same
+ * server-assigned turn ID (workDoneToken), so each role gets a distinct container ID.
+ */
+ public static String userTurnContainerId(String turnId) {
+ return turnId + "-user";
+ }
+
+ /**
+ * Copilot turn container ID: {@code turnId-copilot}. Paired with
+ * {@link #userTurnContainerId(String)} to avoid duplicate DOM IDs.
+ */
+ public static String copilotTurnContainerId(String turnId) {
+ return turnId + "-copilot";
+ }
+
+ /** Content container ID, scoped by role: {@code turnId-user-content} or
+ * {@code turnId-copilot-content}.
+ */
+ public static String contentBlockId(String turnId, boolean isCopilot) {
+ String containerId = isCopilot
+ ? copilotTurnContainerId(turnId) : userTurnContainerId(turnId);
+ return containerId + "-content";
+ }
+
+ /** Sequential copilot child block ID: {@code turnId-N}. */
+ public static String copilotChildBlockId(String turnId, int index) {
+ return turnId + "-" + index;
+ }
+
+ /** Tool call block ID: {@code turnId-tc-N}. */
+ public static String toolCallBlockId(String turnId, int index) {
+ return turnId + "-tc-" + index;
+ }
+
+ /** Compacting status block ID: {@code turnId-compacting}. */
+ public static String compactingBlockId(String turnId) {
+ return turnId + "-compacting";
+ }
+
+ /** Model info block ID: {@code turnId-model-info}. */
+ public static String modelInfoBlockId(String turnId) {
+ return turnId + "-model-info";
+ }
+
+ /** Agent message block ID: {@code turnId-agent-msg-timestamp}. */
+ public static String agentMessageBlockId(String turnId) {
+ return turnId + "-agent-msg-" + System.currentTimeMillis();
+ }
+
+ /** Error turn block ID: {@code error-timestamp}. Not scoped to a turn (errors may be turnless). */
+ public static String errorBlockId() {
+ return "error-" + System.currentTimeMillis();
+ }
+
+ /** Streaming indicator block ID: {@code turnId-streaming}. */
+ public static String streamingIndicatorId(String turnId) {
+ return turnId + "-streaming";
+ }
+
+ /** Confirmation block ID: {@code turnId-confirm}. */
+ public static String confirmationBlockId(String turnId) {
+ return turnId + "-confirm";
+ }
+
+ /** Subagent block ID: {@code parentTurnId-subagent-toolCallId}. */
+ public static String subagentBlockId(String parentTurnId, String toolCallId) {
+ return parentTurnId + "-subagent-" + toolCallId;
+ }
+
+ /** Subagent content-area ID (where the nested subagent turn's child blocks are inserted). */
+ public static String subagentContentAreaId(String parentTurnId, String toolCallId) {
+ return subagentBlockId(parentTurnId, toolCallId) + "-content";
+ }
+
+ /**
+ * Creates the outer turn container with header (avatar + name) and empty content div.
+ */
+ public String createTurnContainerHtmlBlock(String turnId, boolean isCopilot,
+ String avatarDataUri, String displayName) {
+ String containerId = isCopilot
+ ? copilotTurnContainerId(turnId) : userTurnContainerId(turnId);
+ String cssClass = isCopilot ? "turn turn-copilot" : "turn turn-user";
+ String avatar = (avatarDataUri != null && !avatarDataUri.isEmpty())
+ ? "".formatted(escapeHtml(avatarDataUri))
+ : "";
+ return """
+
%s\
+ %s
\
+
"""
+ .formatted(escapeHtml(containerId), cssClass, avatar,
+ escapeHtml(displayName), escapeHtml(contentBlockId(turnId, isCopilot)));
+ }
+
+ /**
+ * Creates a nested subagent block: a bordered card with a header (copilot avatar + title) and an
+ * inner content area into which the subagent turn's child blocks are inserted. Mirrors the SWT
+ * {@code SubagentTurnWidget} (copilot avatar + tool-call-derived title) and shares the
+ * {@code subagent-message-block} CSS class for visual parity with the other message cards.
+ */
+ public String createSubagentBlockHtmlBlock(String blockId, String contentAreaId,
+ String avatarDataUri, String title) {
+ String avatar = StringUtils.isNotBlank(avatarDataUri)
+ ? "".formatted(escapeHtml(avatarDataUri))
+ : "";
+ String titleText = escapeHtml(StringUtils.isNotBlank(title) ? title : "Subagent");
+ return """
+
\
+
%s%s
\
+
"""
+ .formatted(escapeHtml(blockId), avatar, titleText, escapeHtml(contentAreaId));
+ }
+
+ /** Creates a collapsible thinking block (open by default during streaming, with spinner). */
+ public String createThinkingHtmlBlock(String blockId, String thinkingText) {
+ return """
+
"""
+ .formatted(escapeHtml(blockId), SvgIcons.get(SvgIcons.Icon.THINKING_BULB),
+ summary, renderMarkdown(thinkingText));
+ }
+
+ /** Creates a restored thinking block (closed, no spinner, with optional title). */
+ public String createRestoredThinkingHtmlBlock(String blockId, ThinkingBlockData data) {
+ return createSealedThinkingHtmlBlock(blockId, data.getContent(), data.getTitle());
+ }
+
+ /** Creates a tool call status block from a live AgentToolCall. */
+ public String createToolCallHtmlBlock(String blockId, AgentToolCall toolCall) {
+ String progressMsg = toolCall.getProgressMessage();
+ String progressSpan = (progressMsg != null && !progressMsg.isEmpty())
+ ? " %s".formatted(escapeHtml(progressMsg))
+ : "";
+ return toolCallHtmlBlock(blockId, toolCall.getStatus(), toolCall.getName(), progressSpan);
+ }
+
+ /** Creates a tool call status block from persisted ToolCallData. */
+ public String createRestoredToolCallHtmlBlock(String blockId, ToolCallData tc) {
+ String progressMsg = tc.getProgressMessage();
+ String progressSpan = StringUtils.isNotBlank(progressMsg)
+ ? " %s".formatted(escapeHtml(progressMsg))
+ : "";
+ return toolCallHtmlBlock(blockId, tc.getStatus(), tc.getName(), progressSpan);
+ }
+
+ /**
+ * Builds a tool-call status block. The {@code progressSpan} is a pre-rendered, optional
+ * progress fragment (empty string when absent) so each caller keeps its own presence check.
+ */
+ private static String toolCallHtmlBlock(String blockId, String status, String name,
+ String progressSpan) {
+ return """
+
%s \
+ %s%s
"""
+ .formatted(escapeHtml(blockId), toolCallStatusClass(status),
+ toolCallIcon(status), escapeHtml(name), progressSpan);
+ }
+
+ /**
+ * Returns the icon HTML for a tool call status. Running state shows animated dots;
+ * completed shows a green checkmark; error shows a red cross.
+ *
+ *
Note: The "running" state is sent by the language server for in-progress tool calls.
+ * Most tool calls complete quickly, so the running indicator may only flash briefly.
+ */
+ private static String toolCallIcon(String status) {
+ if ("completed".equals(status)) {
+ return """
+ ✓""";
+ } else if ("error".equals(status)) {
+ return """
+ ✗""";
+ } else if ("cancelled".equals(status)) {
+ return """
+ ✗""";
+ }
+ // running or unknown — spinning circle (same as thinking spinner)
+ return """
+ \
+ """;
+ }
+
+ private static String toolCallStatusClass(String status) {
+ if ("completed".equals(status)) {
+ return " tc-completed";
+ } else if ("error".equals(status) || "cancelled".equals(status)) {
+ return " tc-failed";
+ }
+ return "";
+ }
+
+ /** Creates a copilot response block with rendered Markdown content. */
+ public String createCopilotReplyHtmlBlock(String blockId, String markdownText) {
+ String renderedHtml = renderMarkdown(markdownText);
+ return """
+
%s
""".formatted(escapeHtml(blockId), renderedHtml);
+ }
+
+ /** Creates a user request block with rendered Markdown content. */
+ public String createUserRequestHtmlBlock(String blockId, String markdownText) {
+ String renderedHtml = renderMarkdown(markdownText);
+ return """
+
%s
""".formatted(escapeHtml(blockId), renderedHtml);
+ }
+
+ /** Creates an error message block (simple, no action buttons). */
+ public String createErrorMessageHtmlBlock(String blockId, String errorMessage) {
+ return createWarningMessageHtmlBlock(blockId, errorMessage, List.of());
+ }
+
+ /**
+ * Creates a warning message block with an SVG warning icon, message text, and optional
+ * action buttons. Used for quota-exceeded (402) errors and generic turn-level errors.
+ *
+ * @param blockId unique DOM element ID
+ * @param message the warning/error message to display
+ * @param actions quota actions to render as buttons; empty list for no buttons
+ * @return self-contained HTML div block
+ */
+ public String createWarningMessageHtmlBlock(String blockId, String message,
+ List actions) {
+ String actionsBlock = "";
+ if (actions != null && !actions.isEmpty()) {
+ StringBuilder buttons = new StringBuilder();
+ for (QuotaAction action : actions) {
+ String cssClass = action.primary() ? "btn-confirm btn-primary" : "btn-confirm";
+ buttons.append(createCopilotActionButtonHtml(cssClass, action.label(), "openLink",
+ action.url(), action.tooltip()));
+ }
+ actionsBlock = "
"
+ .formatted(escapeHtml(explanation.toString()));
+ }
+ }
+
+ // Action buttons: mirror the SWT split-dropdown layout. The primary accept action is
+ // rendered as a button; any additional accept actions are moved into a caret-triggered
+ // dropdown attached to it, and the dismiss action stays a separate button beside it.
+ StringBuilder actionsHtml = new StringBuilder();
+ List actions = content.getActions();
+ if (actions != null && !actions.isEmpty()) {
+ int primaryIndex = -1;
+ int dismissIndex = -1;
+ List alternativeIndexes = new ArrayList<>();
+ for (int i = 0; i < actions.size(); i++) {
+ ConfirmationAction action = actions.get(i);
+ if (!action.isAccept()) {
+ dismissIndex = i;
+ } else if (action.isPrimary() && primaryIndex < 0) {
+ primaryIndex = i;
+ } else {
+ alternativeIndexes.add(i);
+ }
+ }
+ // Fallback: if no accept action is flagged primary, promote the first accept action so
+ // the confirmation card always stays actionable.
+ if (primaryIndex < 0 && !alternativeIndexes.isEmpty()) {
+ primaryIndex = alternativeIndexes.remove(0);
+ }
+ if (primaryIndex >= 0) {
+ appendConfirmationSplitButton(actionsHtml, actions, primaryIndex, alternativeIndexes);
+ }
+ if (dismissIndex >= 0) {
+ ConfirmationAction dismiss = actions.get(dismissIndex);
+ actionsHtml.append("""
+ """
+ .formatted(escapeHtml(dismiss.getLabel())));
+ }
+ }
+
+ return """
+
\
+
%s%s
%s%s%s\
+
%s
"""
+ .formatted(escapeHtml(blockId), SvgIcons.get(SvgIcons.Icon.TERMINAL),
+ escapeHtml(content.getTitle()), message, commandPanel, explanationPanel,
+ actionsHtml.toString());
+ }
+
+ /**
+ * Appends the primary accept action as a button. When alternative accept actions exist,
+ * the button is wrapped in a split-button group with a caret that opens a dropdown menu
+ * listing those alternatives; otherwise a plain primary button is emitted.
+ */
+ private void appendConfirmationSplitButton(StringBuilder html,
+ List actions, int primaryIndex, List alternativeIndexes) {
+ ConfirmationAction primary = actions.get(primaryIndex);
+ if (alternativeIndexes.isEmpty()) {
+ html.append("""
+ """
+ .formatted(primaryIndex, escapeHtml(primary.getLabel())));
+ return;
+ }
+ html.append("""
+
");
+ }
+
+ /** Renders Markdown to HTML and injects code block action buttons. */
+ public String renderMarkdown(String markdown) {
+ if (markdown == null || markdown.isEmpty()) {
+ return "";
+ }
+ String html = htmlRenderer.render(markdownParser.parse(markdown));
+ return injectCodeBlockButtons(html);
+ }
+
+ /**
+ * Renders Markdown to HTML for an inline context such as a thinking block title inside a
+ * {@code }. Strips a single wrapping {@code
...
} (which commonmark always emits
+ * around a lone paragraph) so the result stays inline and does not break the summary layout.
+ * Does not inject code block action buttons.
+ */
+ public String renderMarkdownInline(String markdown) {
+ if (markdown == null || markdown.isEmpty()) {
+ return "";
+ }
+ String html = htmlRenderer.render(markdownParser.parse(markdown)).strip();
+ if (html.startsWith("
""".formatted(copyImg, insertImg);
+ return html.replace("", buttons + "");
+ }
+
+ private static String createCopilotActionButtonHtml(String cssClass, String label, String action,
+ String param, String tooltip) {
+ String titleAttr = StringUtils.isNotBlank(tooltip)
+ ? " title=\"%s\"".formatted(escapeHtml(tooltip))
+ : "";
+ return """
+ """
+ .formatted(escapeHtml(cssClass), escapeHtml(action), escapeHtml(param), titleAttr,
+ escapeHtml(label));
+ }
+
+ /** Escapes special HTML characters in the given text. Returns empty string for null. */
+ public static String escapeHtml(String text) {
+ if (text == null) {
+ return "";
+ }
+ return text.replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace("\"", """);
+ }
+}
diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/CopilotTurnWidget.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/CopilotTurnWidget.java
index f026aa0e..67920700 100644
--- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/CopilotTurnWidget.java
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/CopilotTurnWidget.java
@@ -45,7 +45,7 @@ protected Image getAvatar(AvatarService avatarService) {
@Override
protected String getRoleName() {
- return Messages.chat_turnWidget_copilot;
+ return serviceManager.getAvatarService().getCopilotName();
}
@Override
diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/IConversationWidget.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/IConversationWidget.java
new file mode 100644
index 00000000..acbac96a
--- /dev/null
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/IConversationWidget.java
@@ -0,0 +1,159 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+package com.microsoft.copilot.eclipse.ui.chat;
+
+import java.util.concurrent.CompletableFuture;
+
+import org.eclipse.swt.custom.StyledText;
+import org.eclipse.swt.widgets.Control;
+
+import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction;
+import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent;
+import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatProgressValue;
+import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolConfirmationResult;
+import com.microsoft.copilot.eclipse.core.lsp.protocol.codingagent.CodingAgentMessageRequestParams;
+import com.microsoft.copilot.eclipse.core.persistence.AbstractTurnData;
+import com.microsoft.copilot.eclipse.core.persistence.ConversationDataFactory;
+
+/**
+ * Abstraction for the conversation content area in the chat view. Implementations render chat turns
+ * either via ({@link StyledTextConversationWidget}) using {@link StyledText}
+ * or via ({@link BrowserConversationWidget}) using an Eclipse-internal web browser rendering HTML code.
+ */
+public interface IConversationWidget {
+
+ /**
+ * Returns the underlying SWT control for layout purposes.
+ */
+ Control getControl();
+
+ /**
+ * Returns whether this widget has been disposed.
+ */
+ boolean isDisposed();
+
+ /**
+ * Disposes this widget and releases all resources.
+ */
+ void dispose();
+
+ /**
+ * Requests a layout pass on the underlying control.
+ */
+ void requestLayout();
+
+ /**
+ * Sets the conversation ID for this widget.
+ */
+ void setConversationId(String conversationId);
+
+ /**
+ * Begins a new user / copilot turn for the given turn ID.
+ *
+ * @param turnId the unique ID of the turn
+ * @param isCopilot true if this is a Copilot turn, false for user turns
+ * @param isHistory true if the turn is being restored from history
+ */
+ void beginTurn(String turnId, boolean isCopilot, boolean isHistory);
+
+ /**
+ * Processes a chat progress event (report or end phase). The widget updates the turn content
+ * accordingly.
+ */
+ void processTurnEvent(ChatProgressValue value);
+
+ /**
+ * Creates a new user turn entry in the conversation.
+ *
+ * @param turnId the turn ID
+ * @param message the user's message text
+ */
+ void startNewUserTurn(String turnId, String message);
+
+ /**
+ * Scrolls the conversation content to the bottom.
+ */
+ void scrollToBottom();
+
+ /**
+ * Refreshes the internal scroller layout (e.g., after content changes).
+ */
+ void refreshScrollerLayout();
+
+ /**
+ * Renders an error message in the conversation area.
+ */
+ void renderErrorMessage(String content);
+
+ /**
+ * Shows a "compacting" status indicator on the latest Copilot turn.
+ */
+ void showCompactingStatusOnLatestCopilotTurn();
+
+ /**
+ * Hides the "compacting" status indicator on the latest Copilot turn.
+ */
+ void hideCompactingStatusOnLatestCopilotTurn();
+
+ /**
+ * Returns the active thinking block ID for the given turn, or null if none.
+ */
+ String getActiveThinkingBlockId(String turnId);
+
+ /**
+ * Restores a single turn from persisted conversation data. Implementations render user turns,
+ * copilot turns (with thinking blocks, tool calls, errors, agent messages), and model info
+ * footers.
+ *
+ * @param turn the turn data to restore (either {@code UserTurnData} or {@code CopilotTurnData})
+ * @param dataFactory factory for converting persisted data to runtime objects
+ */
+ void restoreTurn(AbstractTurnData turn, ConversationDataFactory dataFactory);
+
+ /**
+ * Renders model info footer below a copilot turn (model name, billing multiplier, reasoning
+ * effort).
+ *
+ * @param turnId the turn ID to attach the footer to
+ * @param modelName the model name to display
+ * @param billingMultiplier the billing multiplier (0 means not shown)
+ * @param reasoningEffort the reasoning effort level (may be null)
+ */
+ void renderModelInfo(String turnId, String modelName, double billingMultiplier,
+ String reasoningEffort);
+
+ /**
+ * Renders a coding agent message (e.g., PR link) in the specified turn.
+ *
+ * @param params the agent message parameters containing turn ID, title, description, and link
+ */
+ void renderAgentMessage(CodingAgentMessageRequestParams params);
+
+ /**
+ * Requests tool execution confirmation from the user. Implementations render a confirmation UI
+ * (inline HTML in browser view, or SWT dialog in SWT view) and return a future that completes
+ * when the user accepts or dismisses.
+ *
+ * @param turnId the turn ID where the confirmation should appear
+ * @param content confirmation content with title, message, and action buttons
+ * @param input tool input (may contain "command", "explanation", "action" keys)
+ * @return future that completes with the user's confirmation result
+ */
+ CompletableFuture requestToolConfirmation(
+ String turnId, ConfirmationContent content, Object input);
+
+ /**
+ * Cancels any pending tool confirmation for the given turn. Completes the pending future with
+ * DISMISS and removes the confirmation UI.
+ *
+ * @param turnId the turn ID whose confirmation should be cancelled
+ */
+ void cancelToolConfirmation(String turnId);
+
+ /**
+ * Returns the selected {@link ConfirmationAction} from the last completed confirmation, or null
+ * if the user dismissed or no confirmation was shown. Used by the caller to cache decisions.
+ */
+ ConfirmationAction getLastSelectedConfirmationAction();
+}
diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/QuotaActions.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/QuotaActions.java
index 5c3a0137..35bc75a3 100644
--- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/QuotaActions.java
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/QuotaActions.java
@@ -7,6 +7,7 @@
import org.apache.commons.lang3.StringUtils;
+import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CheckQuotaResult;
import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CopilotPlan;
import com.microsoft.copilot.eclipse.ui.UiConstants;
import com.microsoft.copilot.eclipse.ui.i18n.Messages;
@@ -34,6 +35,51 @@ public record QuotaAction(String label, String tooltip, String url, boolean prim
private QuotaActions() {
}
+ /**
+ * The plan inputs needed to build quota actions, extracted from a {@link CheckQuotaResult}. Shared
+ * by the browser renderer and the StyledText {@link WarnWidget} path so the {@code CheckQuotaResult}
+ * decomposition lives in one place.
+ *
+ * @param plan the user's Copilot plan, or {@code null} when unknown
+ * @param overageEnabled {@code true} when additional paid usage is already enabled for the user
+ * @param canUpgradePlan whether the user can upgrade their plan, or {@code null} when the language
+ * server did not supply this field
+ */
+ public record QuotaPlanContext(CopilotPlan plan, boolean overageEnabled, Boolean canUpgradePlan) {
+
+ /** Extracts the plan inputs from a language-server {@link CheckQuotaResult}. */
+ public static QuotaPlanContext from(CheckQuotaResult quotaStatus) {
+ boolean overageEnabled = quotaStatus.premiumInteractions() != null
+ && quotaStatus.premiumInteractions().overagePermitted();
+ return new QuotaPlanContext(quotaStatus.copilotPlan(), overageEnabled,
+ quotaStatus.canUpgradePlan());
+ }
+ }
+
+ /**
+ * Resolves the {@link QuotaAction}s for a quota-exceeded error given the current quota status.
+ * Returns an empty list for non-{@code 402} errors, BYOK quota errors, when token-based billing is
+ * not enabled, or when {@code quotaStatus} is {@code null}. Otherwise delegates to
+ * {@link #forPlan(CopilotPlan, boolean, Boolean)} with the inputs from
+ * {@link QuotaPlanContext#from(CheckQuotaResult)}.
+ *
+ * @param quotaStatus the current quota status, or {@code null}
+ * @param code the language-server error code
+ * @param modelProviderName the BYOK model-provider name, or {@code null}
+ * @return an immutable, possibly empty list; never {@code null}
+ */
+ public static List forQuotaStatus(CheckQuotaResult quotaStatus, int code,
+ String modelProviderName) {
+ if (code != 402 || isByokQuotaExceeded(code, modelProviderName)) {
+ return List.of();
+ }
+ if (quotaStatus == null || !quotaStatus.tokenBasedBillingEnabled()) {
+ return List.of();
+ }
+ QuotaPlanContext context = QuotaPlanContext.from(quotaStatus);
+ return forPlan(context.plan(), context.overageEnabled(), context.canUpgradePlan());
+ }
+
/**
* Returns the ordered list of {@link QuotaAction}s appropriate for the supplied plan.
*
diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/StyledTextConversationWidget.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/StyledTextConversationWidget.java
new file mode 100644
index 00000000..394fd4c3
--- /dev/null
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/StyledTextConversationWidget.java
@@ -0,0 +1,307 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+package com.microsoft.copilot.eclipse.ui.chat;
+
+import java.util.concurrent.CompletableFuture;
+
+import org.apache.commons.lang3.StringUtils;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Control;
+
+import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction;
+import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent;
+import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentToolCall;
+import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatProgressValue;
+import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolConfirmationResult;
+import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolConfirmationResult.ToolConfirmationResult;
+import com.microsoft.copilot.eclipse.core.lsp.protocol.codingagent.CodingAgentMessageRequestParams;
+import com.microsoft.copilot.eclipse.core.persistence.AbstractTurnData;
+import com.microsoft.copilot.eclipse.core.persistence.ConversationDataFactory;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.AgentMessageData;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.EditAgentRoundData;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ErrorData;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ErrorMessageData;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ReplyData;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ToolCallData;
+import com.microsoft.copilot.eclipse.core.persistence.UserTurnData;
+import com.microsoft.copilot.eclipse.ui.UiConstants;
+import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager;
+import com.microsoft.copilot.eclipse.ui.utils.SwtUtils;
+
+/**
+ * {@link IConversationWidget} implementation backed by the existing SWT {@link ChatContentViewer}
+ * using {@link StyledText} to render the content.
+ * This is a thin adapter that delegates all calls to the underlying viewer.
+ */
+public class StyledTextConversationWidget implements IConversationWidget {
+
+ private final ChatContentViewer viewer;
+
+ /**
+ * Creates a new {@link StyledTextConversationWidget} that internally creates a {@link ChatContentViewer}.
+ *
+ * @param parent the parent composite
+ * @param serviceManager the chat service manager
+ */
+ public StyledTextConversationWidget(Composite parent, ChatServiceManager serviceManager) {
+ this.viewer = new ChatContentViewer(parent, SWT.NONE, serviceManager);
+ }
+
+ /**
+ * Returns the underlying {@link ChatContentViewer} for SWT-specific operations that are not part
+ * of the {@link IConversationWidget} interface (e.g., {@code getTurnWidget()}).
+ */
+ public ChatContentViewer getChatContentViewer() {
+ return viewer;
+ }
+
+ @Override
+ public Control getControl() {
+ return viewer;
+ }
+
+ @Override
+ public boolean isDisposed() {
+ return viewer.isDisposed();
+ }
+
+ @Override
+ public void dispose() {
+ viewer.dispose();
+ }
+
+ @Override
+ public void requestLayout() {
+ viewer.requestLayout();
+ }
+
+ @Override
+ public void setConversationId(String conversationId) {
+ viewer.setConversationId(conversationId);
+ }
+
+ @Override
+ public void beginTurn(String turnId, boolean isCopilot, boolean isHistory) {
+ viewer.getLatestOrCreateNewTurnWidget(turnId, isCopilot, isHistory);
+ }
+
+ @Override
+ public void processTurnEvent(ChatProgressValue value) {
+ viewer.processTurnEvent(value);
+ }
+
+ @Override
+ public void startNewUserTurn(String turnId, String message) {
+ viewer.startNewTurn(turnId, message);
+ }
+
+ @Override
+ public void scrollToBottom() {
+ viewer.getDisplay().asyncExec(() -> {
+ if (viewer.isDisposed()) {
+ return;
+ }
+ viewer.refreshLayoutFull();
+ viewer.scrollToBottomIfAutoScroll();
+ });
+ }
+
+ @Override
+ public void refreshScrollerLayout() {
+ viewer.refreshLayoutFull();
+ }
+
+ @Override
+ public void renderErrorMessage(String content) {
+ viewer.renderErrorMessage(content);
+ }
+
+ @Override
+ public void showCompactingStatusOnLatestCopilotTurn() {
+ viewer.showCompactingStatusOnLatestCopilotTurn();
+ }
+
+ @Override
+ public void hideCompactingStatusOnLatestCopilotTurn() {
+ viewer.hideCompactingStatusOnLatestCopilotTurn();
+ }
+
+ @Override
+ public String getActiveThinkingBlockId(String turnId) {
+ return viewer.getActiveThinkingBlockId(turnId);
+ }
+
+ @Override
+ public void restoreTurn(AbstractTurnData turn, ConversationDataFactory dataFactory) {
+ if (turn == null) {
+ return;
+ }
+
+ // Subagent turns: render inside parent turn's subagent block
+ if (turn instanceof CopilotTurnData copilotTurn
+ && StringUtils.isNotBlank(copilotTurn.getParentTurnId())) {
+ BaseTurnWidget parentWidget = viewer.getTurnWidget(copilotTurn.getParentTurnId());
+ if (parentWidget != null) {
+ String toolCallId = copilotTurn.getSubagentToolCallId();
+ if (StringUtils.isNotBlank(toolCallId)) {
+ parentWidget.restoreSubagentContent(toolCallId, copilotTurn, dataFactory);
+ } else {
+ restoreCopilotTurnContent(copilotTurn, parentWidget, dataFactory);
+ }
+ }
+ return;
+ }
+
+ // User turn
+ if (turn instanceof UserTurnData userTurn) {
+ if (userTurn.getMessage() == null
+ || StringUtils.isNotBlank(userTurn.getMessage().getText())) {
+ BaseTurnWidget userTurnWidget =
+ viewer.getLatestOrCreateNewTurnWidget(turn.getTurnId(), false, true);
+ userTurnWidget.appendMessage(userTurn.getMessage().getText());
+ userTurnWidget.flushMessageBuffer();
+ }
+ return;
+ }
+
+ // Copilot turn
+ if (turn instanceof CopilotTurnData copilotTurn) {
+ BaseTurnWidget copilotTurnWidget =
+ viewer.getLatestOrCreateNewTurnWidget(turn.getTurnId(), true, true);
+ restoreCopilotTurnContent(copilotTurn, copilotTurnWidget, dataFactory);
+ copilotTurnWidget.flushMessageBuffer();
+
+ // Restore model info footer
+ ReplyData replyData = copilotTurn.getReply();
+ if (replyData != null && StringUtils.isNotBlank(replyData.getModelName())) {
+ renderModelInfo(turn.getTurnId(), replyData.getModelName(),
+ replyData.getBillingMultiplier(), replyData.getReasoningEffort());
+ }
+ }
+ }
+
+ @Override
+ public void renderModelInfo(String turnId, String modelName, double billingMultiplier,
+ String reasoningEffort) {
+ if (viewer.isDisposed()) {
+ return;
+ }
+ BaseTurnWidget turnWidget = viewer.getTurnWidget(turnId);
+ if (turnWidget instanceof CopilotTurnWidget copilotWidget) {
+ copilotWidget.renderModelInfo(modelName, billingMultiplier, reasoningEffort);
+ SwtUtils.invokeOnDisplayThreadAsync(viewer::refreshLayoutFull, viewer);
+ }
+ }
+
+ @Override
+ public void renderAgentMessage(CodingAgentMessageRequestParams params) {
+ if (viewer.isDisposed() || params == null) {
+ return;
+ }
+ SwtUtils.invokeOnDisplayThread(() -> {
+ BaseTurnWidget turnWidget = viewer.getTurnWidget(params.getTurnId());
+ if (turnWidget != null && !turnWidget.isDisposed()) {
+ turnWidget.createAgentMessageWidget(params);
+ }
+ }, viewer);
+ }
+
+ @Override
+ public CompletableFuture requestToolConfirmation(
+ String turnId, ConfirmationContent content, Object input) {
+ BaseTurnWidget turnWidget = viewer.getTurnWidget(turnId);
+ if (turnWidget == null) {
+ return CompletableFuture.completedFuture(
+ new LanguageModelToolConfirmationResult(ToolConfirmationResult.DISMISS));
+ }
+ BaseTurnWidget activeTurnWidget = turnWidget.getActiveTurnWidget();
+ CompletableFuture future =
+ activeTurnWidget.requestToolExecutionConfirmation(content, input);
+ viewer.refreshLayoutFull();
+ return future;
+ }
+
+ @Override
+ public void cancelToolConfirmation(String turnId) {
+ BaseTurnWidget turnWidget = viewer.getTurnWidget(turnId);
+ if (turnWidget != null) {
+ turnWidget.getActiveTurnWidget().cancelToolConfirmation();
+ }
+ }
+
+ @Override
+ public ConfirmationAction getLastSelectedConfirmationAction() {
+ // In the SWT implementation, the selected action is retrieved from the dialog directly
+ // by AgentToolService. Return null here; SWT path uses dialog.getSelectedAction().
+ return null;
+ }
+
+ private void restoreCopilotTurnContent(CopilotTurnData copilotTurn, BaseTurnWidget turnWidget,
+ ConversationDataFactory dataFactory) {
+ ReplyData replyData = copilotTurn.getReply();
+ if (replyData == null) {
+ return;
+ }
+
+ ThinkingTurnWidget thinkingWidget =
+ turnWidget instanceof ThinkingTurnWidget ? (ThinkingTurnWidget) turnWidget : null;
+
+ if (StringUtils.isNotBlank(replyData.getText())) {
+ turnWidget.appendMessage(replyData.getText());
+ }
+
+ if (replyData.getEditAgentRounds() != null && !replyData.getEditAgentRounds().isEmpty()) {
+ for (EditAgentRoundData round : replyData.getEditAgentRounds()) {
+ if (thinkingWidget != null && round.getThinkingBlock() != null) {
+ thinkingWidget.restoreThinkingBlock(round.getThinkingBlock());
+ }
+ if (round.getReply() != null && !round.getReply().isEmpty()) {
+ turnWidget.appendMessage(round.getReply());
+ }
+ if (round.getToolCalls() != null && !round.getToolCalls().isEmpty()) {
+ for (ToolCallData toolCallData : round.getToolCalls()) {
+ AgentToolCall agentToolCall =
+ dataFactory.convertToolCallDataToAgentToolCall(toolCallData);
+ turnWidget.appendToolCallStatus(agentToolCall);
+ }
+ }
+ }
+ }
+
+ // Flush buffered text before creating error/agent widgets so that reply text
+ // always appears above them in the layout.
+ turnWidget.flushMessageBuffer();
+
+ if (replyData.getErrorMessages() != null && !replyData.getErrorMessages().isEmpty()) {
+ for (ErrorMessageData errorMessageData : replyData.getErrorMessages()) {
+ ErrorData errorData = errorMessageData.getError();
+ SwtUtils.invokeOnDisplayThread(() -> {
+ String errorMessage = errorData != null
+ ? errorData.getMessage() : Messages.chat_warnWidget_defaultErrorMsg;
+ int errorCode = errorData != null ? errorData.getCode() : 0;
+ String modelProviderName = errorData != null ? errorData.getModelProviderName() : null;
+ turnWidget.createWarnDialog(errorMessage, errorCode, modelProviderName);
+ }, viewer);
+ }
+ }
+
+ if (replyData.getAgentMessages() != null && !replyData.getAgentMessages().isEmpty()) {
+ for (AgentMessageData agentMessageData : replyData.getAgentMessages()) {
+ if (StringUtils.equals(agentMessageData.getAgentSlug(),
+ UiConstants.GITHUB_COPILOT_CODING_AGENT_SLUG)) {
+ SwtUtils.invokeOnDisplayThread(() -> {
+ CodingAgentMessageRequestParams params = new CodingAgentMessageRequestParams();
+ params.setTitle(agentMessageData.getTitle());
+ params.setDescription(agentMessageData.getDescription());
+ params.setPrLink(agentMessageData.getPrLink());
+ params.setTurnId(copilotTurn.getTurnId());
+ turnWidget.createAgentMessageWidget(params);
+ }, viewer);
+ }
+ }
+ }
+ }
+}
diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/SvgIcons.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/SvgIcons.java
new file mode 100644
index 00000000..9820f1ab
--- /dev/null
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/SvgIcons.java
@@ -0,0 +1,235 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+package com.microsoft.copilot.eclipse.ui.chat;
+
+import java.util.EnumMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.osgi.framework.Bundle;
+
+import com.microsoft.copilot.eclipse.core.utils.BundleUtils;
+import com.microsoft.copilot.eclipse.ui.CopilotUi;
+
+/**
+ * Registry of inline SVG icons used by the browser-based chat renderer.
+ *
+ *
Each icon lives as a standalone {@code .svg} file under {@code resources/html/icons/}
+ * containing only the icon geometry (path/line elements and a {@code viewBox}). At load time
+ * this registry injects the presentation attributes the chat DOM requires (CSS {@code class},
+ * sizing, {@code currentColor} theming, inline {@code style}) onto the root {@code }
+ * element. Because those attributes are enforced by code rather than stored in the file, an
+ * icon file can be replaced (e.g. with a fresh export) without risk of losing the CSS class or
+ * theming the chat view depends on.
+ *
+ *
The resulting markup is inlined directly into the browser DOM (not referenced via an
+ * {@code } element), which is required for {@code currentColor} to adapt to the
+ * light/dark theme.
+ */
+public final class SvgIcons {
+
+ /** The available chat icons, each mapped to its file name and enforced root attributes. */
+ public enum Icon {
+ /** Lightbulb icon for sealed (completed) thinking blocks. */
+ THINKING_BULB("thinking-bulb.svg", attrs(
+ "class", "thinking-icon",
+ "width", "14",
+ "height", "14",
+ "fill", "none",
+ "stroke", "currentColor",
+ "stroke-width", "1.3",
+ "stroke-linecap", "round",
+ "stroke-linejoin", "round")),
+ /** Pull request icon for coding-agent message blocks. */
+ PULL_REQUEST("pull-request.svg", attrs(
+ "width", "14",
+ "height", "14",
+ "fill", "currentColor",
+ "style", "vertical-align: middle; margin-right: 4px;")),
+ /** Terminal/command icon for tool confirmation blocks. */
+ TERMINAL("terminal.svg", attrs(
+ "width", "14",
+ "height", "14",
+ "fill", "currentColor",
+ "style", "vertical-align: middle; margin-right: 4px;")),
+ /** Warning triangle icon for quota warning and error blocks. */
+ WARNING("warning.svg", attrs(
+ "width", "14",
+ "height", "14",
+ "fill", "currentColor",
+ "style", "vertical-align: middle; margin-right: 4px; flex-shrink: 0;"));
+
+ private final String fileName;
+ private final Map enforcedAttributes;
+
+ Icon(String fileName, Map enforcedAttributes) {
+ this.fileName = fileName;
+ this.enforcedAttributes = enforcedAttributes;
+ }
+ }
+
+ private static final String ICON_RESOURCE_DIR = "resources/html/icons/";
+
+ private static final Pattern ATTRIBUTE_PATTERN =
+ Pattern.compile("([\\w:-]+)\\s*=\\s*\"([^\"]*)\"");
+
+ private static volatile Map cache;
+
+ private SvgIcons() {
+ }
+
+ /**
+ * Returns the ready-to-inline SVG markup for the given icon, with all required presentation
+ * attributes applied. Icons are loaded and processed once, then cached.
+ *
+ * @param icon the icon to render
+ * @return the inline SVG markup, or an empty string if the icon file cannot be read
+ */
+ public static String get(Icon icon) {
+ Map local = cache;
+ if (local == null) {
+ synchronized (SvgIcons.class) {
+ local = cache;
+ if (local == null) {
+ local = loadAll(CopilotUi.getPlugin().getBundle());
+ cache = local;
+ }
+ }
+ }
+ return local.getOrDefault(icon, "");
+ }
+
+ private static Map loadAll(Bundle bundle) {
+ Map map = new EnumMap<>(Icon.class);
+ for (Icon icon : Icon.values()) {
+ String raw = BundleUtils.readResourceAsString(bundle, ICON_RESOURCE_DIR + icon.fileName);
+ map.put(icon, raw == null ? "" : applyRootAttributes(raw, icon.enforcedAttributes));
+ }
+ return map;
+ }
+
+ /**
+ * Applies (adds or overrides) the given attributes on the root {@code } element of the
+ * supplied markup, dropping any {@code xmlns} declarations so the result is lean inline HTML.
+ * Attributes present in the source but not listed are preserved (notably {@code viewBox}).
+ *
+ *
Attributes present in the source but not listed are preserved (notably {@code viewBox}). The
+ * {@code style} attribute is treated specially: rather than replacing it wholesale, its CSS
+ * sub-properties are merged so enforced declarations are added or overridden while unrelated
+ * existing declarations are kept.
+ *
+ *
Visible for unit testing.
+ *
+ * @param svg the raw SVG markup
+ * @param enforcedAttributes the attributes to enforce on the root element
+ * @return the SVG markup with the enforced attributes applied, or the input unchanged if it
+ * does not contain a recognizable root {@code } element
+ */
+ public static String applyRootAttributes(String svg, Map enforcedAttributes) {
+ if (svg == null) {
+ return "";
+ }
+ String trimmed = stripXmlProlog(svg.trim());
+ int start = trimmed.indexOf("', start);
+ if (tagEnd < 0) {
+ return trimmed;
+ }
+ String openTag = trimmed.substring(start, tagEnd); // "'
+
+ Map attributes = parseAttributes(openTag);
+ attributes.keySet().removeIf(name -> name.equals("xmlns") || name.startsWith("xmlns:"));
+ for (Map.Entry enforced : enforcedAttributes.entrySet()) {
+ String name = enforced.getKey();
+ String value = enforced.getValue();
+ if ("style".equals(name) && attributes.containsKey("style")) {
+ value = mergeStyle(attributes.get("style"), value);
+ }
+ attributes.put(name, value);
+ }
+
+ StringBuilder rebuilt = new StringBuilder(" entry : attributes.entrySet()) {
+ rebuilt.append(' ').append(entry.getKey())
+ .append("=\"").append(entry.getValue()).append('"');
+ }
+ String rest = trimmed.substring(tagEnd + 1); // inner content plus ""
+ rebuilt.append('>').append(rest);
+ return rebuilt.toString();
+ }
+
+ private static Map parseAttributes(String openTag) {
+ Map attributes = new LinkedHashMap<>();
+ Matcher matcher = ATTRIBUTE_PATTERN.matcher(openTag);
+ while (matcher.find()) {
+ attributes.put(matcher.group(1), matcher.group(2));
+ }
+ return attributes;
+ }
+
+ /**
+ * Merges the {@code enforced} CSS declarations into the {@code existing} ones. Declarations from
+ * {@code existing} are kept unless {@code enforced} overrides them by property name; declarations
+ * present only in {@code enforced} are appended. This ensures enforcing a {@code style} attribute
+ * never drops unrelated sub-properties already declared on the element.
+ *
+ * @param existing the current value of the {@code style} attribute
+ * @param enforced the {@code style} declarations to add or override
+ * @return the merged {@code style} value
+ */
+ private static String mergeStyle(String existing, String enforced) {
+ Map declarations = parseStyle(existing);
+ declarations.putAll(parseStyle(enforced));
+ StringBuilder merged = new StringBuilder();
+ for (Map.Entry entry : declarations.entrySet()) {
+ if (merged.length() > 0) {
+ merged.append(' ');
+ }
+ merged.append(entry.getKey()).append(": ").append(entry.getValue()).append(';');
+ }
+ return merged.toString();
+ }
+
+ private static Map parseStyle(String style) {
+ Map declarations = new LinkedHashMap<>();
+ if (style == null) {
+ return declarations;
+ }
+ for (String declaration : style.split(";")) {
+ int colon = declaration.indexOf(':');
+ if (colon < 0) {
+ continue;
+ }
+ String property = declaration.substring(0, colon).trim();
+ String value = declaration.substring(colon + 1).trim();
+ if (!property.isEmpty()) {
+ declarations.put(property, value);
+ }
+ }
+ return declarations;
+ }
+
+ private static String stripXmlProlog(String svg) {
+ if (svg.startsWith("");
+ if (end >= 0) {
+ return svg.substring(end + 2).trim();
+ }
+ }
+ return svg;
+ }
+
+ private static Map attrs(String... pairs) {
+ Map map = new LinkedHashMap<>();
+ for (int i = 0; i + 1 < pairs.length; i += 2) {
+ map.put(pairs[i], pairs[i + 1]);
+ }
+ return map;
+ }
+}
diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingBlock.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingBlock.java
index e273986f..22cf827b 100644
--- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingBlock.java
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingBlock.java
@@ -8,7 +8,6 @@
import java.util.Objects;
import java.util.UUID;
import java.util.regex.Matcher;
-import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
import org.eclipse.e4.ui.services.IStylingEngine;
@@ -38,8 +37,6 @@
*/
public class ThinkingBlock extends Composite {
private static final String SECONDARY_TEXT_CSS_CLASS = "text-secondary";
- private static final Pattern TITLE_PATTERN =
- Pattern.compile("(?:^|\\n)\\*\\*([^*\\r\\n]+?)\\*\\*(?=\\r?\\n|$)");
private static final int STREAMING_MAX_HEIGHT = 180;
@@ -357,7 +354,7 @@ private static List parseSections(String raw) {
if (raw == null || raw.isEmpty()) {
return result;
}
- Matcher matcher = TITLE_PATTERN.matcher(raw);
+ Matcher matcher = ThinkingTitles.TITLE_PATTERN.matcher(raw);
String currentTitle = null;
int cursor = 0;
while (matcher.find()) {
diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingTitles.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingTitles.java
new file mode 100644
index 00000000..1707d5f3
--- /dev/null
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingTitles.java
@@ -0,0 +1,85 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+package com.microsoft.copilot.eclipse.ui.chat;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import com.microsoft.copilot.eclipse.core.lsp.protocol.GenerateThinkingTitleParams;
+import com.microsoft.copilot.eclipse.core.persistence.ConversationPersistenceManager;
+
+/**
+ * Shared helpers for the "thinking" title feature used by both the browser renderer
+ * ({@link BrowserConversationWidget}) and the StyledText renderer ({@link ThinkingBlock} /
+ * {@link ThinkingTurnWidget}). Centralizes the standalone-bold-line title pattern, title
+ * extraction, the {@link GenerateThinkingTitleParams} construction rule, and title persistence so
+ * the two renderers cannot drift.
+ */
+public final class ThinkingTitles {
+
+ /**
+ * Matches a standalone {@code **Title**} line in thinking text: a bold span that occupies its own
+ * line (start-of-text or after a newline, terminated by a newline or end-of-text). Group 1 is the
+ * title text.
+ */
+ public static final Pattern TITLE_PATTERN =
+ Pattern.compile("(?:^|\\n)\\*\\*([^*\\r\\n]+?)\\*\\*(?=\\r?\\n|$)");
+
+ private ThinkingTitles() {
+ }
+
+ /**
+ * Extracts the non-blank {@code **Title**} strings from {@code text} in document order.
+ *
+ * @param text the thinking text to scan; may be {@code null}
+ * @return the trimmed, non-empty titles; never {@code null}
+ */
+ public static String[] extractTitles(String text) {
+ List titles = new ArrayList<>();
+ if (text != null) {
+ Matcher matcher = TITLE_PATTERN.matcher(text);
+ while (matcher.find()) {
+ String title = matcher.group(1).trim();
+ if (!title.isEmpty()) {
+ titles.add(title);
+ }
+ }
+ }
+ return titles.toArray(String[]::new);
+ }
+
+ /**
+ * Builds the {@link GenerateThinkingTitleParams} for a title-generation request. The server
+ * schema rejects null entries inside {@code extractedTitles}, so exactly one field is populated:
+ * the extracted titles when present, otherwise the raw content.
+ *
+ * @param content the accumulated thinking content
+ * @param titles the titles extracted from {@code content}; may be {@code null} or empty
+ * @return params carrying either the titles or the content, never both
+ */
+ public static GenerateThinkingTitleParams buildTitleParams(String content, String[] titles) {
+ boolean hasTitles = titles != null && titles.length > 0;
+ return new GenerateThinkingTitleParams(hasTitles ? null : content, hasTitles ? titles : null);
+ }
+
+ /**
+ * Persists a generated thinking-block {@code title}. No-op when {@code persistenceManager} or
+ * {@code conversationId} is {@code null}.
+ *
+ * @param persistenceManager the persistence manager, or {@code null}
+ * @param conversationId the conversation id, or {@code null}
+ * @param turnId the turn id
+ * @param thinkingBlockId the thinking block id
+ * @param title the generated title
+ */
+ public static void persistTitle(ConversationPersistenceManager persistenceManager,
+ String conversationId, String turnId, String thinkingBlockId, String title) {
+ if (persistenceManager == null || conversationId == null) {
+ return;
+ }
+ persistenceManager.updateThinkingBlockTitle(conversationId, turnId, thinkingBlockId, title);
+ }
+}
diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingTurnWidget.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingTurnWidget.java
index 87505e23..3ace6d19 100644
--- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingTurnWidget.java
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingTurnWidget.java
@@ -115,10 +115,7 @@ public void sealThinking() {
return;
}
String[] titles = target.getExtractedTitles();
- // Server schema rejects null entries inside extractedTitles, so we send one of the two fields, never both.
- boolean hasTitles = titles.length > 0;
- GenerateThinkingTitleParams params = new GenerateThinkingTitleParams(hasTitles ? null : content,
- hasTitles ? titles : null);
+ GenerateThinkingTitleParams params = ThinkingTitles.buildTitleParams(content, titles);
String thinkingBlockId = target.getThinkingId();
ls.generateThinkingTitle(params)
.thenAccept(resp -> SwtUtils.invokeOnDisplayThread(() -> {
@@ -182,11 +179,11 @@ private void cancelThinkingBlock(String cancelTurnId, String thinkingBlockId) {
}
private void persistThinkingTitle(String conversationId, String persistTurnId, String thinkingBlockId, String title) {
- if (conversationId == null || serviceManager == null) {
+ if (serviceManager == null) {
return;
}
- serviceManager.getPersistenceManager()
- .updateThinkingBlockTitle(conversationId, persistTurnId, thinkingBlockId, title);
+ ThinkingTitles.persistTitle(serviceManager.getPersistenceManager(), conversationId, persistTurnId,
+ thinkingBlockId, title);
}
/**
diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/UserTurnWidget.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/UserTurnWidget.java
index bb5b4c99..ca45acec 100644
--- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/UserTurnWidget.java
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/UserTurnWidget.java
@@ -3,8 +3,6 @@
package com.microsoft.copilot.eclipse.ui.chat;
-import java.util.Optional;
-
import org.eclipse.jface.text.source.SourceViewer;
import org.eclipse.swt.SWT;
import org.eclipse.swt.custom.StyledText;
@@ -21,7 +19,6 @@
import com.microsoft.copilot.eclipse.ui.chat.services.AvatarService;
import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager;
-import com.microsoft.copilot.eclipse.ui.i18n.Messages;
import com.microsoft.copilot.eclipse.ui.utils.AccessibilityUtils;
/**
@@ -44,8 +41,7 @@ protected Image getAvatar(AvatarService avatarService) {
@Override
protected String getRoleName() {
- return Optional.ofNullable(serviceManager.getAuthStatusManager().getUserName()).filter(s -> !s.isEmpty())
- .orElse(Messages.chat_turnWidget_user);
+ return serviceManager.getAvatarService().getUserName();
}
@Override
diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AgentToolService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AgentToolService.java
index c7a013c2..7bb87eaa 100644
--- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AgentToolService.java
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AgentToolService.java
@@ -36,10 +36,8 @@
import com.microsoft.copilot.eclipse.terminal.api.IRunInTerminalTool;
import com.microsoft.copilot.eclipse.terminal.api.TerminalServiceManager;
import com.microsoft.copilot.eclipse.ui.CopilotUi;
-import com.microsoft.copilot.eclipse.ui.chat.BaseTurnWidget;
-import com.microsoft.copilot.eclipse.ui.chat.ChatContentViewer;
import com.microsoft.copilot.eclipse.ui.chat.ChatView;
-import com.microsoft.copilot.eclipse.ui.chat.InvokeToolConfirmationDialog;
+import com.microsoft.copilot.eclipse.ui.chat.IConversationWidget;
import com.microsoft.copilot.eclipse.ui.chat.confirmation.AttachedFileRegistry;
import com.microsoft.copilot.eclipse.ui.chat.confirmation.ConfirmationService;
import com.microsoft.copilot.eclipse.ui.chat.tools.BaseTool;
@@ -279,36 +277,28 @@ public CompletableFuture onToolConfirmation
new LanguageModelToolConfirmationResult(ToolConfirmationResult.DISMISS));
}
- BaseTurnWidget turnWidget = boundChatView.getChatContentViewer().getTurnWidget(params.getTurnId());
- if (turnWidget == null) {
- LanguageModelToolConfirmationResult result = new LanguageModelToolConfirmationResult(
- ToolConfirmationResult.DISMISS);
- return CompletableFuture.completedFuture(result);
+ IConversationWidget widget = boundChatView.getConversationWidget();
+ if (widget == null || widget.isDisposed()) {
+ return CompletableFuture.completedFuture(
+ new LanguageModelToolConfirmationResult(ToolConfirmationResult.DISMISS));
}
- // Get the active turn widget (may be a subagent widget if in subagent context)
- BaseTurnWidget activeTurnWidget = turnWidget.getActiveTurnWidget();
-
- AtomicReference> ref = new AtomicReference<>();
ConfirmationContent content = autoApproveResult.getContent();
+ AtomicReference> ref =
+ new AtomicReference<>();
SwtUtils.invokeOnDisplayThread(() -> {
- ref.set(activeTurnWidget.requestToolExecutionConfirmation(
- content, params.getInput()));
- boundChatView.getChatContentViewer().refreshLayoutFull();
+ ref.set(widget.requestToolConfirmation(
+ params.getTurnId(), content, params.getInput()));
});
CompletableFuture future = ref.get();
if (future != null && content != null) {
- // Capture dialog reference before it can be reset by a new request
- final InvokeToolConfirmationDialog dialog =
- activeTurnWidget.getConfirmDialog();
+ final IConversationWidget widgetRef = widget;
final String sessConvId = sessionConversationId;
future = future.thenApply(result -> {
- ConfirmationAction selected = dialog != null
- ? dialog.getSelectedAction() : null;
+ ConfirmationAction selected = widgetRef.getLastSelectedConfirmationAction();
if (selected != null && selected.isAccept()) {
- confirmationService.cacheDecision(selected, params,
- sessConvId);
+ confirmationService.cacheDecision(selected, params, sessConvId);
}
return result;
});
@@ -321,19 +311,9 @@ private boolean validToolConfirmInvokeParams(String conversationId, String turnI
return false;
}
- // Check if the conversation ID matches either the main conversation ID or the subagent conversation ID
- boolean conversationIdMatches = Objects.equals(conversationId, boundChatView.getConversationId())
+ // Check if the conversation ID matches either the main or subagent conversation
+ return Objects.equals(conversationId, boundChatView.getConversationId())
|| Objects.equals(conversationId, boundChatView.getSubagentConversationId());
-
- if (!conversationIdMatches) {
- return false;
- }
-
- ChatContentViewer chatContentViewer = boundChatView.getChatContentViewer();
- if (chatContentViewer == null || chatContentViewer.getTurnWidget(turnId) == null) {
- return false;
- }
- return true;
}
/**
diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AvatarService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AvatarService.java
index 656a6f62..7a889634 100644
--- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AvatarService.java
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AvatarService.java
@@ -3,6 +3,7 @@
package com.microsoft.copilot.eclipse.ui.chat.services;
+import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URL;
import java.util.Map;
@@ -14,15 +15,23 @@
import org.eclipse.core.runtime.Status;
import org.eclipse.core.runtime.jobs.Job;
import org.eclipse.e4.core.services.events.IEventBroker;
+import org.eclipse.swt.SWT;
import org.eclipse.swt.graphics.Image;
+import org.eclipse.swt.graphics.ImageData;
+import org.eclipse.swt.graphics.ImageLoader;
import org.eclipse.swt.widgets.Display;
import org.eclipse.ui.PlatformUI;
+import org.osgi.framework.Bundle;
+import org.osgi.framework.FrameworkUtil;
import org.osgi.service.event.EventHandler;
import com.microsoft.copilot.eclipse.core.AuthStatusManager;
import com.microsoft.copilot.eclipse.core.CopilotCore;
import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants;
import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotStatusResult;
+import com.microsoft.copilot.eclipse.core.utils.BundleUtils;
+import com.microsoft.copilot.eclipse.core.utils.DataUriUtils;
+import com.microsoft.copilot.eclipse.ui.i18n.Messages;
import com.microsoft.copilot.eclipse.ui.utils.SwtUtils;
import com.microsoft.copilot.eclipse.ui.utils.UiUtils;
@@ -31,14 +40,21 @@
*/
public class AvatarService {
private static final String AVATAR_URL = "https://avatars.githubusercontent.com/%s?s=24&v=4";
- private static final String DEFAULT_COPILOT_AVATAR_NAME = "/icons/chat/chat_message_copilot_avatar.png";
- private static final String DEFAULT_USER_AVATAR_NAME = "/icons/chat/chat_message_user_avatar.png";
+
+ /** Bundle-relative path of the default Copilot avatar icon. */
+ public static final String DEFAULT_COPILOT_AVATAR_PATH = "/icons/chat/chat_message_copilot_avatar.png";
+
+ /** Bundle-relative path of the default user avatar icon. */
+ public static final String DEFAULT_USER_AVATAR_PATH = "/icons/chat/chat_message_user_avatar.png";
private Map avatarCache = new ConcurrentHashMap<>();
+ private Map avatarDataUriCache = new ConcurrentHashMap<>();
private Map jobs = new ConcurrentHashMap<>();
private Image defaultGithubAvatar;
private Image defaultUserAvatar;
+ private String defaultGithubAvatarDataUri;
+ private String defaultUserAvatarDataUri;
private AuthStatusManager authStatusManager;
private IEventBroker eventBroker;
private EventHandler authStatusChangedEventHandler;
@@ -48,8 +64,13 @@ public class AvatarService {
*/
public AvatarService(AuthStatusManager authStatusManager) {
this.authStatusManager = authStatusManager;
- this.defaultGithubAvatar = UiUtils.buildImageFromPngPath(DEFAULT_COPILOT_AVATAR_NAME);
- this.defaultUserAvatar = UiUtils.buildImageFromPngPath(DEFAULT_USER_AVATAR_NAME);
+ this.defaultGithubAvatar = UiUtils.buildImageFromPngPath(DEFAULT_COPILOT_AVATAR_PATH);
+ this.defaultUserAvatar = UiUtils.buildImageFromPngPath(DEFAULT_USER_AVATAR_PATH);
+ Bundle bundle = FrameworkUtil.getBundle(getClass());
+ this.defaultGithubAvatarDataUri =
+ BundleUtils.readResourceAsDataUri(bundle, DEFAULT_COPILOT_AVATAR_PATH, "image/png");
+ this.defaultUserAvatarDataUri =
+ BundleUtils.readResourceAsDataUri(bundle, DEFAULT_USER_AVATAR_PATH, "image/png");
this.authStatusChangedEventHandler = event -> {
Object property = event.getProperty(IEventBroker.DATA);
if (property instanceof CopilotStatusResult statusResult && statusResult.isSignedIn()) {
@@ -84,6 +105,45 @@ public Image getAvatarForCopilot() {
return defaultGithubAvatar;
}
+ /**
+ * Gets the display name of the current user, or a default fallback label when the user name is
+ * blank (e.g. signed out). Shared by both conversation widgets.
+ *
+ * @return the current user's name, or the default user label if it is blank
+ */
+ public String getUserName() {
+ String user = this.authStatusManager.getUserName();
+ return StringUtils.isNotBlank(user) ? user : Messages.chat_turnWidget_user;
+ }
+
+ /**
+ * Gets the display name for the Copilot responder. Shared by both conversation widgets.
+ *
+ * @return the Copilot display name
+ */
+ public String getCopilotName() {
+ return Messages.chat_turnWidget_copilot;
+ }
+
+ /**
+ * Gets the copilot avatar as a {@code data:} URI, for embedding in HTML (browser renderer).
+ *
+ * @return the copilot avatar as a {@code data:image/png} URI
+ */
+ public String getAvatarForCopilotAsDataUri() {
+ return defaultGithubAvatarDataUri;
+ }
+
+ /**
+ * Gets the avatar for the current user as a {@code data:} URI, for embedding in HTML.
+ *
+ * @return the current user's avatar as a {@code data:image/png} URI, or the default user avatar
+ * while the real one is still being downloaded
+ */
+ public String getAvatarForCurrentUserAsDataUri() {
+ return getAvatarAsDataUri(this.authStatusManager.getUserName());
+ }
+
/**
* Gets the avatar for a user.
*
@@ -95,42 +155,77 @@ public synchronized Image getAvatar(Display display, String user) {
if (StringUtils.isBlank(user)) {
return defaultUserAvatar;
}
-
Image image = avatarCache.get(user);
if (image != null) {
return image;
- } else {
- if (!jobs.containsKey(user)) {
- Job downloadJob = new Job("Download avatar for " + user) {
- @Override
- protected IStatus run(IProgressMonitor monitor) {
- Image downloadedImage = null;
- try {
- URL url = new URL(String.format(AVATAR_URL, user));
- try (var stream = url.openStream()) {
- downloadedImage = new Image(display, stream);
- }
- } catch (IOException e) {
- CopilotCore.LOGGER.error(e);
- }
- if (downloadedImage != null) {
- Image result = downloadedImage;
- // as the image is not always 24x24 even we set it in parameters, resize here if not
- if (result.getBounds().width != 24 || result.getBounds().height != 24) {
- result = UiUtils.resizeImage(display, downloadedImage, 24, 24);
- downloadedImage.dispose();
- }
- avatarCache.put(user, result);
- jobs.remove(user);
- }
- return Status.OK_STATUS;
+ }
+ scheduleAvatarDownload(display, user);
+ return defaultUserAvatar;
+ }
+
+ /**
+ * Gets the avatar for a user as a {@code data:} URI, for embedding in HTML (browser renderer).
+ *
+ *
Shares the same cache and asynchronous download as {@link #getAvatar(Display, String)}: on a
+ * cache miss the default user avatar is returned immediately and the real avatar is fetched in the
+ * background, so later renders pick it up (matching the StyledText renderer's behavior).
+ *
+ * @param user the user
+ * @return the user's avatar as a {@code data:image/png} URI, or the default user avatar while the
+ * real one is still being downloaded
+ */
+ public synchronized String getAvatarAsDataUri(String user) {
+ if (StringUtils.isBlank(user)) {
+ return defaultUserAvatarDataUri;
+ }
+ String dataUri = avatarDataUriCache.get(user);
+ if (dataUri != null) {
+ return dataUri;
+ }
+ scheduleAvatarDownload(SwtUtils.getDisplay(), user);
+ return defaultUserAvatarDataUri;
+ }
+
+ private void scheduleAvatarDownload(Display display, String user) {
+ if (jobs.containsKey(user)) {
+ return;
+ }
+ Job downloadJob = new Job("Download avatar for " + user) {
+ @Override
+ protected IStatus run(IProgressMonitor monitor) {
+ Image downloadedImage = null;
+ try {
+ URL url = new URL(String.format(AVATAR_URL, user));
+ try (var stream = url.openStream()) {
+ downloadedImage = new Image(display, stream);
+ }
+ } catch (IOException e) {
+ CopilotCore.LOGGER.error(e);
+ }
+ if (downloadedImage != null) {
+ Image result = downloadedImage;
+ // as the image is not always 24x24 even we set it in parameters, resize here if not
+ if (result.getBounds().width != 24 || result.getBounds().height != 24) {
+ result = UiUtils.resizeImage(display, downloadedImage, 24, 24);
+ downloadedImage.dispose();
}
- };
- jobs.put(user, downloadJob);
- downloadJob.schedule();
+ avatarCache.put(user, result);
+ avatarDataUriCache.put(user, toPngDataUri(result));
+ jobs.remove(user);
+ }
+ return Status.OK_STATUS;
}
- return defaultUserAvatar;
- }
+ };
+ jobs.put(user, downloadJob);
+ downloadJob.schedule();
+ }
+
+ private static String toPngDataUri(Image image) {
+ ImageLoader loader = new ImageLoader();
+ loader.data = new ImageData[] {image.getImageData()};
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ loader.save(out, SWT.IMAGE_PNG);
+ return DataUriUtils.toDataUri(out.toByteArray(), "image/png");
}
/**
@@ -140,6 +235,7 @@ public void dispose() {
defaultGithubAvatar.dispose();
defaultUserAvatar.dispose();
avatarCache.values().forEach(Image::dispose);
+ avatarDataUriCache.clear();
jobs.values().forEach(Job::cancel);
if (this.eventBroker != null) {
this.eventBroker.unsubscribe(this.authStatusChangedEventHandler);
diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/UserPreferenceService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/UserPreferenceService.java
index 0ca4b969..6a9a930c 100644
--- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/UserPreferenceService.java
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/UserPreferenceService.java
@@ -503,10 +503,12 @@ public void bindChatView(ChatView chatView) {
* Unbind the currently bound chat view if any.
*/
public void unbindChatView() {
- if (chatViewSideEffect != null) {
- chatViewSideEffect.dispose();
- chatViewSideEffect = null;
- }
+ ensureRealm(() -> {
+ if (chatViewSideEffect != null) {
+ chatViewSideEffect.dispose();
+ chatViewSideEffect = null;
+ }
+ });
}
/**
diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/ChatPreferencesPage.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/ChatPreferencesPage.java
index 0b0ce6ff..cd8d90de 100644
--- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/ChatPreferencesPage.java
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/ChatPreferencesPage.java
@@ -45,6 +45,17 @@ public void createFieldEditors() {
GridDataFactory gdf = GridDataFactory.fillDefaults().span(2, 1).align(SWT.FILL, SWT.FILL).grab(true, false);
+ // checkbox: choose the conversation renderer (browser-based vs. StyledText-based).
+ Composite appearanceComposite = createSectionComposite(parent, gdf);
+ BooleanFieldEditor browserRendererField = new BooleanFieldEditor(Constants.USE_BROWSER_BASED_CHAT_RENDERER,
+ Messages.preferences_page_browser_renderer, SWT.WRAP, appearanceComposite);
+ applyFieldWidthHint(browserRendererField, appearanceComposite);
+ browserRendererField.getDescriptionControl(appearanceComposite)
+ .setToolTipText(Messages.preferences_page_browser_renderer_tooltip);
+ addField(browserRendererField);
+
+ addSeparator(parent);
+
Composite workspaceContextComposite = createSectionComposite(parent, gdf);
BooleanFieldEditor workspaceContextField = new BooleanFieldEditor(Constants.WORKSPACE_CONTEXT_ENABLED,
Messages.preferences_page_watched_files, SWT.WRAP, workspaceContextComposite);
diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializer.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializer.java
index 2e0f33af..5bcf071a 100644
--- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializer.java
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializer.java
@@ -38,6 +38,7 @@ public void initializeDefaultPreferences() {
pref.setDefault(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE,
CustomInstructionsChatLoadScope.DEFAULT_VALUE.getValue());
pref.setDefault(Constants.AUTO_BREAKPOINT_RESPONSE, false);
+ pref.setDefault(Constants.USE_BROWSER_BASED_CHAT_RENDERER, false);
pref.setDefault(Constants.MCP, """
{
"servers": {
diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java
index d8053973..1cf1f1a7 100644
--- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java
@@ -58,6 +58,8 @@ public class Messages extends NLS {
public static String preferences_page_watched_files;
public static String preferences_page_watched_files_note_content;
public static String preferences_page_restart_question;
+ public static String preferences_page_browser_renderer;
+ public static String preferences_page_browser_renderer_tooltip;
public static String preferences_page_mcp;
public static String preferences_page_proxy_config_link;
public static String preferences_page_proxy_settings;
diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties
index bd10669e..92806d86 100644
--- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties
@@ -97,6 +97,8 @@ preferences_page_custom_instructions_git_commit_desc=Set custom instructions for
preferences_page_custom_instructions_git_commit_note= Access this feature in the Git Staging view by clicking the Copilot icon. You can find this view in the Git perspective or add it via the 'Window' > 'Show View' menu.
preferences_page_watched_files_note_content= Allow the use of @workspace in Ask Mode. Enabling this feature may affect startup performance.
preferences_page_restart_question=You need to restart Eclipse to apply the changes. Would you like to restart now?
+preferences_page_browser_renderer= Activate browser-based conversation rendering
+preferences_page_browser_renderer_tooltip=Either render conversations in a web browser with GFM tables support (and more) or use the seasoned StyledText-based SWT rendering
preferences_page_restart_required= Restart Required
# CustomModesPreferencePage
customModes_page_description=Configure custom agents stored as .agent.md files in .github/agents directory.
diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/UiUtils.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/UiUtils.java
index 5d1ab2a9..5506e993 100644
--- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/UiUtils.java
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/UiUtils.java
@@ -35,7 +35,7 @@
import org.eclipse.core.filesystem.IFileStore;
import org.eclipse.core.resources.IFile;
import org.eclipse.core.resources.IResource;
-import org.eclipse.core.runtime.preferences.InstanceScope;
+import org.eclipse.core.runtime.Platform;
import org.eclipse.e4.ui.model.application.ui.basic.MPart;
import org.eclipse.e4.ui.services.IStylingEngine;
import org.eclipse.e4.ui.workbench.modeling.EPartService;
@@ -88,7 +88,7 @@
import org.eclipse.ui.part.IShowInTarget;
import org.eclipse.ui.part.ShowInContext;
import org.eclipse.ui.texteditor.ITextEditor;
-import org.osgi.service.prefs.Preferences;
+import org.osgi.framework.Bundle;
import com.microsoft.copilot.eclipse.core.CopilotCore;
import com.microsoft.copilot.eclipse.core.utils.PlatformUtils;
@@ -502,15 +502,89 @@ public static void applyChatFont(Control control) {
/**
* Returns true if Eclipse is currently using a dark theme.
*
+ *
Prefers the active e4 CSS theme id, resolved reflectively to avoid a compile-time dependency
+ * on the friend-restricted {@code org.eclipse.e4.ui.css.swt.theme} package. When e4 theming is
+ * unavailable, falls back to sampling the actual widget background color.
+ *
* @return true if dark theme is active, false otherwise
*/
public static boolean isDarkTheme() {
- Preferences preferences = InstanceScope.INSTANCE.getNode("org.eclipse.e4.ui.css.swt.theme");
- String themeCssUri = preferences.get("themeid", "");
- if (themeCssUri.toLowerCase().contains("dark")) {
- return true;
+ String activeThemeId = getActiveThemeIdIfThemeSupportIsAvailable();
+ if (activeThemeId != null) {
+ return activeThemeId.toLowerCase().contains("dark");
}
- return false;
+ // The persisted themeid preference is unreliable here: it can still hold a "...dark" value from
+ // a previous themed session while the IDE renders in its native light appearance. Sample the
+ // real background color instead.
+ return isBackgroundDark();
+ }
+
+ /**
+ * Reflectively resolves the id of the active e4 CSS theme. The theme bundle's own class loader
+ * loads the friend-restricted {@code IThemeEngine}/{@code ITheme} types, keeping the theming
+ * dependency optional. Returns {@code null} when theming is unavailable or the theme cannot be
+ * determined, so the caller can fall back.
+ */
+ private static String getActiveThemeIdIfThemeSupportIsAvailable() {
+ Bundle themeBundle = Platform.getBundle("org.eclipse.e4.ui.css.swt.theme");
+ if (themeBundle == null) {
+ return null;
+ }
+ try {
+ Class> themeEngineClass = themeBundle.loadClass("org.eclipse.e4.ui.css.swt.theme.IThemeEngine");
+ Object themeEngine = PlatformUI.getWorkbench().getService(themeEngineClass);
+ if (themeEngine == null) {
+ return null;
+ }
+ Object activeTheme = themeEngineClass.getMethod("getActiveTheme").invoke(themeEngine);
+ if (activeTheme == null) {
+ return null;
+ }
+ Class> themeClass = themeBundle.loadClass("org.eclipse.e4.ui.css.swt.theme.ITheme");
+ Object themeId = themeClass.getMethod("getId").invoke(activeTheme);
+ return themeId == null ? null : themeId.toString();
+ } catch (ReflectiveOperationException | RuntimeException | LinkageError e) {
+ return null;
+ }
+ }
+
+ /**
+ * Fallback theme detection that samples the widget background color, for when no theming bundle is
+ * available. {@link Display#getSystemColor(int)} must run on the UI thread, so this reads directly
+ * when already on a UI thread and otherwise marshals via {@link Display#getDefault()}. Degrades to
+ * light when no color can be resolved.
+ */
+ private static boolean isBackgroundDark() {
+ Display current = Display.getCurrent();
+ if (current != null && !current.isDisposed()) {
+ return isDark(current.getSystemColor(SWT.COLOR_WIDGET_BACKGROUND).getRGB());
+ }
+ Display display = Display.getDefault();
+ if (display == null || display.isDisposed()) {
+ return false;
+ }
+ RGB[] holder = new RGB[1];
+ display.syncExec(() -> {
+ if (!display.isDisposed()) {
+ holder[0] = display.getSystemColor(SWT.COLOR_WIDGET_BACKGROUND).getRGB();
+ }
+ });
+ return holder[0] != null && isDark(holder[0]);
+ }
+
+ /**
+ * Decides whether a color is dark based on its perceived brightness (luma).
+ *
+ *
Uses the ITU-R BT.601 luma coefficients (0.299 R, 0.587 G, 0.114 B), which sum to 1.0 so the
+ * result stays in the 0-255 range; values below the mid-point (128) are treated as dark. See
+ * ITU-R Recommendation BT.601.
+ *
+ * @param rgb the color to evaluate
+ * @return true if the color is dark, false otherwise
+ */
+ public static boolean isDark(RGB rgb) {
+ double luminance = 0.299 * rgb.red + 0.587 * rgb.green + 0.114 * rgb.blue;
+ return luminance < 128;
}
/**
diff --git a/launch/Build and test Copilot for Eclipse.launch b/launch/Build and test Copilot for Eclipse.launch
new file mode 100644
index 00000000..837045cf
--- /dev/null
+++ b/launch/Build and test Copilot for Eclipse.launch
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/launch/Run Checkstyle in Copilot for Eclipse.launch b/launch/Run Checkstyle in Copilot for Eclipse.launch
new file mode 100644
index 00000000..30546b16
--- /dev/null
+++ b/launch/Run Checkstyle in Copilot for Eclipse.launch
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+