diff --git a/.github/skills/ui-action/REFERENCE.md b/.github/skills/ui-action/REFERENCE.md
index 65d4fb3e..3811a2cf 100644
--- a/.github/skills/ui-action/REFERENCE.md
+++ b/.github/skills/ui-action/REFERENCE.md
@@ -34,6 +34,29 @@ The module-only shortcut can fail dependency resolution or reuse stale bundles,
especially after source edits. If widget markers or other code changes appear to
be ignored, run the root `clean verify` command.
+### Selecting the chat renderer
+
+The plugin ships two chat renderers behind the `useBrowserBasedChatRenderer`
+preference, and probes can target either one via the `-Dprobe.renderer` launch
+property (read once by `ProbeRunner` before the ChatView opens):
+
+- omit it, or pass `-Dprobe.renderer=styledtext`, to run against the seasoned
+ StyledText renderer (the production default). These probes assert the SWT
+ widget tree (`user-turn`, `copilot-turn`, `model-info-label`).
+- pass `-Dprobe.renderer=browser` to run against the browser-based (HTML)
+ renderer. Its conversation content lives in an SWT `Browser` DOM that is opaque
+ to SWTBot widget locators, so those probes assert the `Browser` widget exists
+ and fall back to `sleep` + `screenshot` for visual verification.
+
+Because the renderer is chosen per launch, cover both by running the two probes:
+
+```bash
+# StyledText renderer (default)
+./mvnw clean verify -Dprobe.script=probe-scripts/chat-send-receive-001.json
+# Browser renderer
+./mvnw clean verify -Dprobe.script=probe-scripts/chat-send-receive-browser-001.json -Dprobe.renderer=browser
+```
+
Platform notes:
- Linux headless: prefix the command with `xvfb-run -a`.
diff --git a/base.target b/base.target
index a6fc6705..689e43e8 100644
--- a/base.target
+++ b/base.target
@@ -15,6 +15,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java
index 176bc245..e613cbcc 100644
--- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java
+++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java
@@ -53,6 +53,7 @@ private Constants() {
public static final String AUTO_BREAKPOINT_RESPONSE = "autoBreakpointResponse";
public static final String GITHUB_JOBS_VIEW_ID = "com.microsoft.copilot.eclipse.ui.jobs.JobsView";
public static final String SUPPRESS_TERMINAL_DEPENDENCY_DIALOG = "suppressTerminalDependencyDialog";
+ public static final String USE_BROWSER_BASED_CHAT_RENDERER = "useBrowserBasedChatRenderer";
// Auto-Approve settings
public static final String AUTO_APPROVE_TERMINAL_RULES = "autoApproveTerminalRules";
diff --git a/com.microsoft.copilot.eclipse.swtbot.test/probe-scripts/chat-send-receive-browser-001.json b/com.microsoft.copilot.eclipse.swtbot.test/probe-scripts/chat-send-receive-browser-001.json
new file mode 100644
index 00000000..1d6f8063
--- /dev/null
+++ b/com.microsoft.copilot.eclipse.swtbot.test/probe-scripts/chat-send-receive-browser-001.json
@@ -0,0 +1,44 @@
+[
+ { "action": "waitForIdle" },
+ { "action": "screenshot", "id": "01-startup" },
+
+ { "action": "showView", "idRef": "com.microsoft.copilot.eclipse.ui.chat.ChatView" },
+ { "action": "waitForIdle" },
+ { "action": "screenshot", "id": "02-chat-open" },
+
+ { "action": "assertExists",
+ "locator": { "by": "viewId", "id": "com.microsoft.copilot.eclipse.ui.chat.ChatView" } },
+
+ { "action": "waitFor",
+ "locator": { "by": "widgetClass", "value": "Browser" },
+ "timeoutSec": 30 },
+
+ { "action": "waitFor",
+ "locator": { "by": "styledText" },
+ "timeoutSec": 30 },
+
+ { "action": "click", "locator": { "by": "styledText" } },
+ { "action": "clearElement", "locator": { "by": "styledText" } },
+ { "action": "typeIn", "locator": { "by": "styledText" }, "text": "hello" },
+ { "action": "screenshot", "id": "03-typed" },
+
+ { "action": "dumpUi", "id": "03b-pre-model-poll" },
+ { "action": "screenshot", "id": "03c-pre-model-poll" },
+
+ { "action": "waitForMethod",
+ "locator": { "by": "widgetId", "value": "model-picker" },
+ "method": "getSelectedItemId",
+ "timeoutSec": 60 },
+
+ { "action": "waitFor",
+ "locator": { "by": "buttonWithTooltip", "tooltip": "Send" },
+ "timeoutSec": 30 },
+ { "action": "click", "locator": { "by": "buttonWithTooltip", "tooltip": "Send" } },
+
+ { "action": "sleep", "timeoutSec": 5 },
+ { "action": "screenshot", "id": "04-user-message-sent" },
+
+ { "action": "sleep", "timeoutSec": 90 },
+ { "action": "screenshot", "id": "05-agent-response" },
+ { "action": "dumpUi", "id": "06-final" }
+]
diff --git a/com.microsoft.copilot.eclipse.swtbot.test/src/com/microsoft/copilot/eclipse/swtbot/test/probe/ProbeRunner.java b/com.microsoft.copilot.eclipse.swtbot.test/src/com/microsoft/copilot/eclipse/swtbot/test/probe/ProbeRunner.java
index f64e037c..be3ff8da 100644
--- a/com.microsoft.copilot.eclipse.swtbot.test/src/com/microsoft/copilot/eclipse/swtbot/test/probe/ProbeRunner.java
+++ b/com.microsoft.copilot.eclipse.swtbot.test/src/com/microsoft/copilot/eclipse/swtbot/test/probe/ProbeRunner.java
@@ -48,6 +48,7 @@
@RunWith(SWTBotJunit4ClassRunner.class)
public class ProbeRunner {
private static final String SYSPROP = "probe.script";
+ private static final String RENDERER_SYSPROP = "probe.renderer";
private static final Path RESULTS_DIR = Paths.get("target", "probe-results");
private static final String UI_BUNDLE_ID = "com.microsoft.copilot.eclipse.ui";
@@ -77,6 +78,13 @@ private static void suppressNuisancePreferences() {
prefs.putBoolean(Constants.AUTO_SHOW_WHAT_IS_NEW, false);
prefs.put(Constants.LAST_USED_COPILOT_PLUGIN_VERSION, currentUiBundleMajorMinor());
prefs.putBoolean(Constants.SUPPRESS_TERMINAL_DEPENDENCY_DIALOG, true);
+ // Select the chat renderer per launch so the same probe flow can be run against both
+ // renderers: pass -Dprobe.renderer=browser for the browser-based (HTML) renderer, or
+ // omit it (or -Dprobe.renderer=styledtext) for the seasoned StyledText renderer. The
+ // default matches production (StyledText), so plain runs exercise the SWT widget tree.
+ boolean useBrowserRenderer =
+ "browser".equalsIgnoreCase(System.getProperty(RENDERER_SYSPROP, "styledtext"));
+ prefs.putBoolean(Constants.USE_BROWSER_BASED_CHAT_RENDERER, useBrowserRenderer);
prefs.flush();
} catch (BackingStoreException | RuntimeException e) {
System.err.println("[ProbeRunner] Failed to preset nuisance-dialog preferences: " + e);
diff --git a/com.microsoft.copilot.eclipse.terminal.api/build.properties b/com.microsoft.copilot.eclipse.terminal.api/build.properties
index f38581b9..8f50fdba 100644
--- a/com.microsoft.copilot.eclipse.terminal.api/build.properties
+++ b/com.microsoft.copilot.eclipse.terminal.api/build.properties
@@ -1,5 +1,4 @@
source.. = src/
-output.. = bin/
bin.includes = META-INF/,\
.,\
scripts/
diff --git a/com.microsoft.copilot.eclipse.ui.jobs/.settings/org.eclipse.jdt.core.prefs b/com.microsoft.copilot.eclipse.ui.jobs/.settings/org.eclipse.jdt.core.prefs
index 20cc7b58..fa415ae8 100644
--- a/com.microsoft.copilot.eclipse.ui.jobs/.settings/org.eclipse.jdt.core.prefs
+++ b/com.microsoft.copilot.eclipse.ui.jobs/.settings/org.eclipse.jdt.core.prefs
@@ -4,7 +4,7 @@ org.eclipse.jdt.core.compiler.compliance=17
org.eclipse.jdt.core.compiler.problem.assertIdentifier=error
org.eclipse.jdt.core.compiler.problem.enablePreviewFeatures=disabled
org.eclipse.jdt.core.compiler.problem.enumIdentifier=error
-org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning
+org.eclipse.jdt.core.compiler.problem.forbiddenReference=error
org.eclipse.jdt.core.compiler.problem.reportPreviewFeatures=warning
org.eclipse.jdt.core.compiler.release=enabled
org.eclipse.jdt.core.compiler.source=17
diff --git a/com.microsoft.copilot.eclipse.ui.jobs/build.properties b/com.microsoft.copilot.eclipse.ui.jobs/build.properties
index 18012dbe..db5288f2 100644
--- a/com.microsoft.copilot.eclipse.ui.jobs/build.properties
+++ b/com.microsoft.copilot.eclipse.ui.jobs/build.properties
@@ -1,5 +1,4 @@
source.. = src/
-output.. = bin/
bin.includes = META-INF/,\
.,\
plugin.xml,\
diff --git a/com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF
index 9d33676c..a587f20a 100644
--- a/com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF
+++ b/com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF
@@ -29,4 +29,5 @@ Require-Bundle: org.mockito.mockito-core;bundle-version="5.14.2",
org.eclipse.e4.core.services;bundle-version="2.4.200",
org.osgi.service.event,
com.google.gson,
- com.microsoft.copilot.eclipse.terminal.api;bundle-version="0.20.0"
+ com.microsoft.copilot.eclipse.terminal.api;bundle-version="0.20.0",
+ org.eclipse.e4.ui.css.swt.theme;bundle-version="0.14.500"
diff --git a/com.microsoft.copilot.eclipse.ui.test/plugin.xml b/com.microsoft.copilot.eclipse.ui.test/plugin.xml
index fd3fba17..f1696761 100644
--- a/com.microsoft.copilot.eclipse.ui.test/plugin.xml
+++ b/com.microsoft.copilot.eclipse.ui.test/plugin.xml
@@ -23,4 +23,29 @@
name="Editor for Refactor Rename Tests"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidgetBehaviorTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidgetBehaviorTest.java
new file mode 100644
index 00000000..f3466db9
--- /dev/null
+++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidgetBehaviorTest.java
@@ -0,0 +1,649 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+package com.microsoft.copilot.eclipse.ui.chat;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.reflect.Field;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.lsp4j.WorkDoneProgressKind;
+import org.eclipse.swt.widgets.Display;
+import org.eclipse.swt.widgets.Shell;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import com.microsoft.copilot.eclipse.core.AuthStatusManager;
+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.ConversationError;
+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.persistence.ConversationDataFactory;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData;
+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.ThinkingBlockState;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ToolCallData;
+import com.microsoft.copilot.eclipse.core.persistence.UserTurnData;
+import com.microsoft.copilot.eclipse.core.persistence.UserTurnData.MessageData;
+
+/**
+ * Behavior tests for {@link BrowserConversationWidget} covering turn lifecycle,
+ * state tracking, tool confirmation logic, and error handling.
+ *
+ *
These tests run as Eclipse plugin tests (the widget constructor requires the
+ * CopilotUi bundle to be active for icon loading). The browser page may not fully
+ * load in the headless test environment, but state-tracking logic and the script
+ * queuing path are still exercisable.
+ */
+class BrowserConversationWidgetBehaviorTest {
+
+ private Shell shell;
+ private BrowserConversationWidget widget;
+
+ @BeforeEach
+ void setUp() {
+ Display display = Display.getDefault();
+ display.syncExec(() -> {
+ shell = new Shell(display);
+ widget = new BrowserConversationWidget(shell);
+ });
+ }
+
+ @AfterEach
+ void tearDown() {
+ Display.getDefault().syncExec(() -> {
+ if (widget != null && !widget.isDisposed()) {
+ widget.dispose();
+ }
+ if (shell != null && !shell.isDisposed()) {
+ shell.dispose();
+ }
+ });
+ }
+
+ @Nested
+ class TurnLifecycleTests {
+
+ @Test
+ void processTurnEvent_thinking_setsActiveThinkingBlockId() {
+ widget.beginTurn("turn-1", true, false);
+
+ ChatProgressValue event = new ChatProgressValue();
+ event.setKind(WorkDoneProgressKind.report);
+ event.setTurnId("turn-1");
+ event.setThinking(new Thinking("think-1", "Analyzing...", null));
+ widget.processTurnEvent(event);
+
+ assertNotNull(widget.getActiveThinkingBlockId("turn-1"));
+ }
+
+ @Test
+ void processTurnEvent_multipleThinkingChunks_maintainsSameBlockId() {
+ widget.beginTurn("turn-1", true, false);
+
+ ChatProgressValue event1 = new ChatProgressValue();
+ event1.setKind(WorkDoneProgressKind.report);
+ event1.setTurnId("turn-1");
+ event1.setThinking(new Thinking("think-1", "First chunk. ", null));
+ widget.processTurnEvent(event1);
+ String blockId1 = widget.getActiveThinkingBlockId("turn-1");
+
+ ChatProgressValue event2 = new ChatProgressValue();
+ event2.setKind(WorkDoneProgressKind.report);
+ event2.setTurnId("turn-1");
+ event2.setThinking(new Thinking("think-1", "Second chunk.", null));
+ widget.processTurnEvent(event2);
+ String blockId2 = widget.getActiveThinkingBlockId("turn-1");
+
+ assertEquals(blockId1, blockId2, "Subsequent thinking chunks should use same block");
+ }
+
+ @Test
+ void processTurnEvent_thinkingSealedByReplyInSameEvent() {
+ widget.beginTurn("turn-1", true, false);
+
+ // First: establish a thinking block
+ ChatProgressValue thinkEvent = new ChatProgressValue();
+ thinkEvent.setKind(WorkDoneProgressKind.report);
+ thinkEvent.setTurnId("turn-1");
+ thinkEvent.setThinking(new Thinking("think-1", "Planning...", null));
+ widget.processTurnEvent(thinkEvent);
+ assertNotNull(widget.getActiveThinkingBlockId("turn-1"));
+
+ // Now: event with both thinking continuation AND a reply (triggers seal)
+ ChatProgressValue sealEvent = new ChatProgressValue();
+ sealEvent.setKind(WorkDoneProgressKind.report);
+ sealEvent.setTurnId("turn-1");
+ sealEvent.setThinking(new Thinking("think-1", "Done planning.", null));
+ sealEvent.setReply("Here is the answer.");
+ widget.processTurnEvent(sealEvent);
+
+ // Thinking block should be sealed (cleared from active tracking)
+ assertNull(widget.getActiveThinkingBlockId("turn-1"));
+ }
+
+ @Test
+ void processTurnEvent_end_clearsThinkingState() {
+ widget.beginTurn("turn-1", true, false);
+
+ ChatProgressValue thinkEvent = new ChatProgressValue();
+ thinkEvent.setKind(WorkDoneProgressKind.report);
+ thinkEvent.setTurnId("turn-1");
+ thinkEvent.setThinking(new Thinking("think-1", "Thinking...", null));
+ widget.processTurnEvent(thinkEvent);
+
+ ChatProgressValue endEvent = new ChatProgressValue();
+ endEvent.setKind(WorkDoneProgressKind.end);
+ endEvent.setTurnId("turn-1");
+ widget.processTurnEvent(endEvent);
+
+ assertNull(widget.getActiveThinkingBlockId("turn-1"));
+ }
+
+ @Test
+ void processTurnEvent_differentTurnId_tracksThinkingPerTurn() {
+ widget.beginTurn("turn-1", true, false);
+
+ ChatProgressValue thinkEvent = new ChatProgressValue();
+ thinkEvent.setKind(WorkDoneProgressKind.report);
+ thinkEvent.setTurnId("turn-1");
+ thinkEvent.setThinking(new Thinking("think-1", "Thinking...", null));
+ widget.processTurnEvent(thinkEvent);
+ String turn1Thinking = widget.getActiveThinkingBlockId("turn-1");
+ assertNotNull(turn1Thinking);
+
+ // A report for a different turn must not disturb turn-1's per-turn state.
+ ChatProgressValue otherEvent = new ChatProgressValue();
+ otherEvent.setKind(WorkDoneProgressKind.report);
+ otherEvent.setTurnId("turn-2");
+ otherEvent.setReply("Hello");
+ widget.processTurnEvent(otherEvent);
+
+ assertEquals(turn1Thinking, widget.getActiveThinkingBlockId("turn-1"),
+ "Each turn tracks its own thinking block independently");
+ assertNull(widget.getActiveThinkingBlockId("turn-2"),
+ "turn-2 produced only a reply, so it has no thinking block");
+ }
+
+ @Test
+ void processTurnEvent_toolCallFollowedByReply_transitionsBlockType() {
+ widget.beginTurn("turn-1", true, false);
+
+ // Tool call event (AgentToolCall/AgentRound have no setters — use reflection)
+ AgentToolCall toolCall = new AgentToolCall();
+ setField(toolCall, "id", "tc-1");
+ setField(toolCall, "name", "file_search");
+ setField(toolCall, "status", "running");
+ AgentRound round = new AgentRound();
+ setField(round, "toolCalls", List.of(toolCall));
+
+ ChatProgressValue toolEvent = new ChatProgressValue();
+ toolEvent.setKind(WorkDoneProgressKind.report);
+ toolEvent.setTurnId("turn-1");
+ setField(toolEvent, "editAgentRounds", List.of(round));
+ widget.processTurnEvent(toolEvent);
+
+ // Reply event following tool call
+ ChatProgressValue replyEvent = new ChatProgressValue();
+ replyEvent.setKind(WorkDoneProgressKind.report);
+ replyEvent.setTurnId("turn-1");
+ replyEvent.setReply("Here are the results.");
+ widget.processTurnEvent(replyEvent);
+
+ // No exception = state transition worked correctly
+ assertNull(widget.getActiveThinkingBlockId("turn-1"));
+ }
+
+ @Test
+ void processTurnEvent_errorMessageDuringStreaming() {
+ widget.beginTurn("turn-1", true, false);
+
+ // An error arrives on a report event (the streaming error path)
+ ChatProgressValue errorEvent = new ChatProgressValue();
+ errorEvent.setKind(WorkDoneProgressKind.report);
+ errorEvent.setTurnId("turn-1");
+ ConversationError error = new ConversationError();
+ error.setMessage("Context window exceeded");
+ error.setCode(400);
+ errorEvent.setConversationError(error);
+ widget.processTurnEvent(errorEvent);
+
+ // No exception; error is rendered as warning block
+ }
+
+ @Test
+ void processTurnEvent_errorOnEndEvent() {
+ widget.beginTurn("turn-1", true, false);
+
+ ChatProgressValue endEvent = new ChatProgressValue();
+ endEvent.setKind(WorkDoneProgressKind.end);
+ endEvent.setTurnId("turn-1");
+ ConversationError error = new ConversationError();
+ error.setMessage("Server disconnected");
+ error.setCode(500);
+ endEvent.setConversationError(error);
+ widget.processTurnEvent(endEvent);
+
+ // Error on end should still be processed without exception
+ }
+ }
+
+ @Nested
+ class ToolConfirmationTests {
+
+ @Test
+ void requestToolConfirmation_returnsPendingFuture() {
+ widget.beginTurn("turn-1", true, false);
+
+ ConfirmationContent content = new ConfirmationContent(
+ "Run command?", "Execute npm install",
+ List.of(ConfirmationAction.allowOnce("Allow"),
+ ConfirmationAction.skip("Deny")));
+
+ CompletableFuture future =
+ widget.requestToolConfirmation("turn-1", content, null);
+
+ assertNotNull(future);
+ assertFalse(future.isDone());
+ }
+
+ @Test
+ void cancelToolConfirmation_completesFutureWithDismiss() throws Exception {
+ widget.beginTurn("turn-1", true, false);
+
+ ConfirmationContent content = new ConfirmationContent(
+ "Run command?", "Execute npm install",
+ List.of(ConfirmationAction.allowOnce("Allow")));
+
+ CompletableFuture future =
+ widget.requestToolConfirmation("turn-1", content, null);
+
+ widget.cancelToolConfirmation("turn-1");
+
+ assertTrue(future.isDone());
+ // getResult() is the LSP wire value ("dismiss"), not the enum constant.
+ assertEquals(ToolConfirmationResult.DISMISS.getValue(),
+ future.get(1, TimeUnit.SECONDS).getResult());
+ }
+
+ @Test
+ void requestToolConfirmation_cancelsPreviousPendingConfirmation() throws Exception {
+ widget.beginTurn("turn-1", true, false);
+
+ ConfirmationContent content1 = new ConfirmationContent(
+ "First?", "First action",
+ List.of(ConfirmationAction.allowOnce("Allow")));
+ CompletableFuture future1 =
+ widget.requestToolConfirmation("turn-1", content1, null);
+
+ ConfirmationContent content2 = new ConfirmationContent(
+ "Second?", "Second action",
+ List.of(ConfirmationAction.allowOnce("Allow")));
+ CompletableFuture future2 =
+ widget.requestToolConfirmation("turn-1", content2, null);
+
+ assertTrue(future1.isDone(), "First future should be auto-cancelled");
+ assertFalse(future2.isDone(), "Second future should still be pending");
+ // getResult() is the LSP wire value ("dismiss"), not the enum constant.
+ assertEquals(ToolConfirmationResult.DISMISS.getValue(),
+ future1.get(1, TimeUnit.SECONDS).getResult());
+ }
+
+ @Test
+ void cancelToolConfirmation_whenNoPending_doesNotThrow() {
+ assertDoesNotThrow(() -> widget.cancelToolConfirmation("turn-1"));
+ }
+
+ @Test
+ void getLastSelectedConfirmationAction_returnsNullBeforeAnyConfirmation() {
+ assertNull(widget.getLastSelectedConfirmationAction());
+ }
+ }
+
+ @Nested
+ class RestoreTurnTests {
+
+ private ConversationDataFactory dataFactory;
+
+ @BeforeEach
+ void setUpFactory() {
+ AuthStatusManager mockAuth = Mockito.mock(AuthStatusManager.class);
+ Mockito.when(mockAuth.getUserName()).thenReturn("test-user");
+ dataFactory = new ConversationDataFactory(mockAuth);
+ }
+
+ @Test
+ void restoreTurn_null_doesNotThrow() {
+ assertDoesNotThrow(() -> widget.restoreTurn(null, dataFactory));
+ }
+
+ @Test
+ void restoreTurn_userTurnWithNullMessage_doesNotRender() {
+ UserTurnData userTurn = new UserTurnData();
+ userTurn.setTurnId("turn-null-msg");
+ userTurn.setMessage(null);
+ assertDoesNotThrow(() -> widget.restoreTurn(userTurn, dataFactory));
+ }
+
+ @Test
+ void restoreTurn_copilotTurnWithParentTurnId_skipsRendering() {
+ CopilotTurnData copilotTurn = new CopilotTurnData();
+ copilotTurn.setTurnId("turn-child");
+ copilotTurn.setParentTurnId("turn-parent");
+ assertDoesNotThrow(() -> widget.restoreTurn(copilotTurn, dataFactory));
+ }
+
+ @Test
+ void restoreTurn_fullCopilotTurnWithAllBlockTypes() {
+ CopilotTurnData copilotTurn = new CopilotTurnData();
+ copilotTurn.setTurnId("turn-full");
+ ReplyData reply = copilotTurn.getReply();
+ reply.setText("Here is a **formatted** reply with `code`.");
+ reply.setModelName("GPT-5 mini");
+ reply.setBillingMultiplier(1.0);
+ reply.setReasoningEffort("medium");
+
+ // Thinking block
+ ThinkingBlockData thinking = new ThinkingBlockData();
+ thinking.setId("think-full");
+ thinking.setContent("Analyzing requirements...");
+ thinking.setTitle("Planning");
+ thinking.setState(ThinkingBlockState.COMPLETED);
+
+ // Tool calls
+ ToolCallData completedTool = new ToolCallData();
+ completedTool.setId("tc-1");
+ completedTool.setName("file_search");
+ completedTool.setStatus("completed");
+ completedTool.setProgressMessage("Found 5 results");
+
+ ToolCallData errorTool = new ToolCallData();
+ errorTool.setId("tc-2");
+ errorTool.setName("edit_file");
+ errorTool.setStatus("error");
+ errorTool.setProgressMessage("Permission denied");
+
+ EditAgentRoundData round = new EditAgentRoundData();
+ round.setRoundId(1);
+ round.setThinkingBlock(thinking);
+ round.setToolCalls(List.of(completedTool, errorTool));
+ round.setReply("After tool execution, here are more details.");
+ reply.setEditAgentRounds(List.of(round));
+
+ // Error messages
+ ErrorData errorData = new ErrorData();
+ errorData.setMessage("Rate limited");
+ errorData.setCode(429);
+ ErrorMessageData errorMsg = new ErrorMessageData();
+ errorMsg.setError(errorData);
+ reply.setErrorMessages(List.of(errorMsg));
+
+ assertDoesNotThrow(() -> widget.restoreTurn(copilotTurn, dataFactory));
+ }
+
+ @Test
+ void restoreTurn_copilotTurnWithNullReplyData() {
+ CopilotTurnData copilotTurn = new CopilotTurnData();
+ copilotTurn.setTurnId("turn-no-reply");
+ // getReply() returns a new ReplyData with all nulls by default —
+ // but the text/model fields are blank so no content blocks are created
+ assertDoesNotThrow(() -> widget.restoreTurn(copilotTurn, dataFactory));
+ }
+ }
+
+ @Nested
+ class CompactingStatusTests {
+
+ @Test
+ void showAndHideCompactingStatus_tracksLastCopilotTurnId() {
+ // Begin a copilot turn so lastCopilotTurnId is set
+ widget.beginTurn("turn-1", true, false);
+
+ // These methods depend on lastCopilotTurnId being set correctly
+ assertDoesNotThrow(() -> widget.showCompactingStatusOnLatestCopilotTurn());
+ assertDoesNotThrow(() -> widget.hideCompactingStatusOnLatestCopilotTurn());
+ }
+
+ @Test
+ void showCompactingStatus_beforeAnyTurn_doesNotThrow() {
+ // No turn started yet — should handle gracefully
+ assertDoesNotThrow(() -> widget.showCompactingStatusOnLatestCopilotTurn());
+ }
+
+ @Test
+ void showCompactingStatus_afterUserTurn_usesLastCopilotTurn() {
+ widget.beginTurn("turn-1", true, false);
+ widget.beginTurn("turn-2", false, false); // user turn
+
+ // Should still reference turn-1 (last copilot turn)
+ assertDoesNotThrow(() -> widget.showCompactingStatusOnLatestCopilotTurn());
+ assertDoesNotThrow(() -> widget.hideCompactingStatusOnLatestCopilotTurn());
+ }
+ }
+
+ @Nested
+ class WidgetDisposalDuringOperationsTests {
+
+ @Test
+ void dispose_duringPendingConfirmation_doesNotThrow() {
+ widget.beginTurn("turn-1", true, false);
+
+ ConfirmationContent content = new ConfirmationContent(
+ "Run?", "Command",
+ List.of(ConfirmationAction.allowOnce("Allow")));
+ CompletableFuture future =
+ widget.requestToolConfirmation("turn-1", content, null);
+
+ // Dispose while confirmation is pending (SWT disposal must run on the UI thread).
+ assertDoesNotThrow(() -> runOnUiThread(widget::dispose));
+ // Future is left incomplete (no automatic completion on dispose)
+ // This is acceptable — the caller (ChatView) manages lifecycle
+ }
+
+ @Test
+ void processTurnEvent_afterDispose_doesNotThrow() {
+ widget.beginTurn("turn-1", true, false);
+ runOnUiThread(widget::dispose);
+
+ ChatProgressValue event = new ChatProgressValue();
+ event.setKind(WorkDoneProgressKind.report);
+ event.setTurnId("turn-1");
+ event.setReply("Should be ignored");
+
+ // executeScript checks isDisposed() — should not throw
+ assertDoesNotThrow(() -> widget.processTurnEvent(event));
+ }
+
+ @Test
+ void restoreTurn_afterDispose_doesNotThrow() {
+ runOnUiThread(widget::dispose);
+
+ AuthStatusManager mockAuth = Mockito.mock(AuthStatusManager.class);
+ Mockito.when(mockAuth.getUserName()).thenReturn("test-user");
+ ConversationDataFactory dataFactory = new ConversationDataFactory(mockAuth);
+
+ UserTurnData userTurn = new UserTurnData();
+ userTurn.setTurnId("turn-1");
+ userTurn.setMessage(new MessageData("Hello"));
+
+ assertDoesNotThrow(() -> widget.restoreTurn(userTurn, dataFactory));
+ }
+ }
+
+ @Nested
+ class RenderOperationTests {
+
+ @Test
+ void renderErrorMessage_doesNotThrow() {
+ assertDoesNotThrow(() -> widget.renderErrorMessage("Something went wrong"));
+ }
+
+ @Test
+ void renderErrorMessage_withMarkdownContent() {
+ assertDoesNotThrow(
+ () -> widget.renderErrorMessage("**Error**: `connection refused`"));
+ }
+
+ @Test
+ void startNewUserTurn_doesNotThrow() {
+ assertDoesNotThrow(
+ () -> widget.startNewUserTurn("turn-1", "How do I write tests?"));
+ }
+
+ @Test
+ void renderAgentMessage_withNullParams_doesNotThrow() {
+ assertDoesNotThrow(() -> widget.renderAgentMessage(null));
+ }
+ }
+
+ /**
+ * Subagent nesting: a subagent turn is rendered inside its parent copilot turn (driven by the
+ * parent's {@code run_subagent} tool call), mirroring the SWT {@code BaseTurnWidget}. Because
+ * streaming state is per-turn, the parent and subagent keep independent block counters, so their
+ * block ids never collide.
+ */
+ @Nested
+ class SubagentNestingTests {
+
+ @Test
+ void liveSubagentFlow_keepsParentAndSubagentStateIndependent() {
+ // Parent copilot turn starts and launches a subagent.
+ widget.beginTurn("parent", true, false);
+ widget.processTurnEvent(
+ toolCallReport("parent", null, "sub-tc", "run_subagent", "running"));
+
+ // Subagent turn streams its own thinking, nested under the parent.
+ widget.beginTurn("sub", true, false);
+ widget.processTurnEvent(thinkingReport("sub", "parent", "Subagent planning..."));
+ String subThinking = widget.getActiveThinkingBlockId("sub");
+ assertNotNull(subThinking, "Subagent turn tracks its own thinking block");
+ assertTrue(subThinking.startsWith("sub-"),
+ "Subagent block ids derive from the subagent turn id (independent counter)");
+ widget.processTurnEvent(endEvent("sub", "parent"));
+
+ // Subagent completes; the parent resumes and continues thinking.
+ widget.processTurnEvent(
+ toolCallReport("parent", null, "sub-tc", "run_subagent", "completed"));
+ widget.processTurnEvent(thinkingReport("parent", null, "Back in the parent..."));
+ String parentThinking = widget.getActiveThinkingBlockId("parent");
+ assertNotNull(parentThinking, "Parent turn keeps its own state after the subagent");
+ assertTrue(parentThinking.startsWith("parent-"),
+ "Parent block ids stay under the parent turn id");
+ assertNotEquals(subThinking, parentThinking,
+ "Parent and subagent block ids never collide");
+ }
+
+ @Test
+ void restore_subagentTurnNestedUnderParent_doesNotThrow() {
+ AuthStatusManager mockAuth = Mockito.mock(AuthStatusManager.class);
+ Mockito.when(mockAuth.getUserName()).thenReturn("test-user");
+ ConversationDataFactory dataFactory = new ConversationDataFactory(mockAuth);
+
+ // Parent turn persisted with a run_subagent tool call.
+ ToolCallData subToolCall = new ToolCallData();
+ subToolCall.setId("sub-tc");
+ subToolCall.setName("run_subagent");
+ subToolCall.setStatus("completed");
+ EditAgentRoundData parentRound = new EditAgentRoundData();
+ parentRound.setToolCalls(List.of(subToolCall));
+ parentRound.setReply("Delegating to a subagent.");
+ ReplyData parentReply = new ReplyData();
+ parentReply.setEditAgentRounds(List.of(parentRound));
+ CopilotTurnData parentTurn = new CopilotTurnData();
+ parentTurn.setTurnId("parent");
+ parentTurn.setReply(parentReply);
+
+ // Subagent turn persisted with parentTurnId + subagentToolCallId.
+ ReplyData subReply = new ReplyData();
+ subReply.setText("Subagent result.");
+ CopilotTurnData subTurn = new CopilotTurnData();
+ subTurn.setTurnId("sub");
+ subTurn.setParentTurnId("parent");
+ subTurn.setSubagentToolCallId("sub-tc");
+ subTurn.setReply(subReply);
+
+ // Parent precedes subagent in persisted order (as ChatView restores them).
+ assertDoesNotThrow(() -> {
+ widget.restoreTurn(parentTurn, dataFactory);
+ widget.restoreTurn(subTurn, dataFactory);
+ });
+ }
+
+ private ChatProgressValue toolCallReport(String turnId, String parentTurnId,
+ String toolCallId, String toolName, String status) {
+ AgentToolCall toolCall = new AgentToolCall();
+ setField(toolCall, "id", toolCallId);
+ setField(toolCall, "name", toolName);
+ setField(toolCall, "status", status);
+ AgentRound round = new AgentRound();
+ setField(round, "toolCalls", List.of(toolCall));
+ ChatProgressValue event = baseReport(turnId, parentTurnId);
+ setField(event, "editAgentRounds", List.of(round));
+ return event;
+ }
+
+ private ChatProgressValue thinkingReport(String turnId, String parentTurnId, String text) {
+ ChatProgressValue event = baseReport(turnId, parentTurnId);
+ event.setThinking(new Thinking("t", text, null));
+ return event;
+ }
+
+ private ChatProgressValue baseReport(String turnId, String parentTurnId) {
+ ChatProgressValue event = new ChatProgressValue();
+ event.setKind(WorkDoneProgressKind.report);
+ event.setTurnId(turnId);
+ if (parentTurnId != null) {
+ event.setParentTurnId(parentTurnId);
+ }
+ return event;
+ }
+
+ private ChatProgressValue endEvent(String turnId, String parentTurnId) {
+ ChatProgressValue event = new ChatProgressValue();
+ event.setKind(WorkDoneProgressKind.end);
+ event.setTurnId(turnId);
+ if (parentTurnId != null) {
+ event.setParentTurnId(parentTurnId);
+ }
+ return event;
+ }
+ }
+
+ /** Sets a private field via reflection (used for fields without public setters). */
+ private static void setField(Object target, String fieldName, Object value) {
+ try {
+ Field field = target.getClass().getDeclaredField(fieldName);
+ field.setAccessible(true);
+ field.set(target, value);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to set field " + fieldName, e);
+ }
+ }
+
+ /** Runs an action on the SWT UI thread; SWT widget disposal must happen there. */
+ private static void runOnUiThread(Runnable action) {
+ Display.getDefault().syncExec(action);
+ }
+}
+
diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidgetEscapingTests.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidgetEscapingTests.java
new file mode 100644
index 00000000..ad4ba94d
--- /dev/null
+++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidgetEscapingTests.java
@@ -0,0 +1,43 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+package com.microsoft.copilot.eclipse.ui.chat;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Pure unit tests for {@link BrowserConversationWidget#escapeForJs(String)}.
+ * No SWT Display or widget needed.
+ */
+class BrowserConversationWidgetEscapingTests {
+
+ @Test
+ void returnsEmptyForNull() {
+ assertEquals("", BrowserConversationWidget.escapeForJs(null));
+ }
+
+ @ParameterizedTest(name = "escapeForJs({0}) = {1}")
+ @MethodSource("escapeForJsCases")
+ void escapesCharactersCorrectly(String input, String expected) {
+ assertEquals(expected, BrowserConversationWidget.escapeForJs(input));
+ }
+
+ static Stream escapeForJsCases() {
+ return Stream.of(
+ Arguments.of("hello", "hello"),
+ Arguments.of("it's", "it\\'s"),
+ Arguments.of("back\\slash", "back\\\\slash"),
+ Arguments.of("line1\nline2", "line1\\nline2"),
+ Arguments.of("tab\there", "tab\\there"),
+ Arguments.of("cr\r", "cr\\r"),
+ Arguments.of("all\\\n\r\t'", "all\\\\\\n\\r\\t\\'")
+ );
+ }
+}
diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidgetTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidgetTest.java
new file mode 100644
index 00000000..63f89c38
--- /dev/null
+++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidgetTest.java
@@ -0,0 +1,60 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+package com.microsoft.copilot.eclipse.ui.chat;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import org.eclipse.swt.widgets.Display;
+import org.eclipse.swt.widgets.Shell;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Basic platform integration test for {@link BrowserConversationWidget}.
+ * Verifies that the widget can be created and disposed without error in a real
+ * Eclipse platform environment (OSGi bundles active, Display available).
+ */
+class BrowserConversationWidgetTest {
+
+ private Shell shell;
+ private BrowserConversationWidget widget;
+
+ @BeforeEach
+ void setUp() {
+ Display display = Display.getDefault();
+ display.syncExec(() -> {
+ shell = new Shell(display);
+ widget = new BrowserConversationWidget(shell);
+ });
+ }
+
+ @AfterEach
+ void tearDown() {
+ Display.getDefault().syncExec(() -> {
+ if (widget != null && !widget.isDisposed()) {
+ widget.dispose();
+ }
+ if (shell != null && !shell.isDisposed()) {
+ shell.dispose();
+ }
+ });
+ }
+
+ @Test
+ void widgetCreatesSuccessfully() {
+ assertNotNull(widget);
+ assertNotNull(widget.getControl());
+ assertFalse(widget.isDisposed());
+ }
+
+ @Test
+ void widgetDisposesCleanly() {
+ // SWT widgets must be disposed on the UI thread, the way real callers do.
+ assertDoesNotThrow(() -> Display.getDefault().syncExec(() -> widget.dispose()));
+ }
+}
+
diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ChatErrorMessagesTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ChatErrorMessagesTest.java
new file mode 100644
index 00000000..00eb9289
--- /dev/null
+++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ChatErrorMessagesTest.java
@@ -0,0 +1,56 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+package com.microsoft.copilot.eclipse.ui.chat;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+
+import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatProgressValue;
+import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationError;
+import com.microsoft.copilot.eclipse.ui.i18n.Messages;
+
+/**
+ * Unit tests for {@link ChatErrorMessages#resolveDisplayMessage}, which both conversation widgets
+ * rely on to display identical, normalized error text.
+ */
+class ChatErrorMessagesTest {
+
+ @Test
+ void resolveDisplayMessage_nullValue_returnsEmpty() {
+ assertEquals("", ChatErrorMessages.resolveDisplayMessage(null));
+ }
+
+ @Test
+ void resolveDisplayMessage_stripsRequestIdSuffix() {
+ assertEquals("Something went wrong.",
+ ChatErrorMessages.resolveDisplayMessage(error("Something went wrong. | Request ID: abc-123", null)));
+ }
+
+ @Test
+ void resolveDisplayMessage_stripsGitHubRequestIdSuffix() {
+ assertEquals("Rate limit reached.",
+ ChatErrorMessages.resolveDisplayMessage(error("Rate limit reached. GitHub Request ID: xyz.789.", null)));
+ }
+
+ @Test
+ void resolveDisplayMessage_leavesPlainMessageUnchanged() {
+ assertEquals("Plain error.", ChatErrorMessages.resolveDisplayMessage(error("Plain error.", null)));
+ }
+
+ @Test
+ void resolveDisplayMessage_modelNotSupported_mapsToFriendlyMessage() {
+ assertEquals(Messages.chat_model_unsupported_message,
+ ChatErrorMessages.resolveDisplayMessage(error("raw model error", "model_not_supported")));
+ }
+
+ private static ChatProgressValue error(String message, String reason) {
+ ConversationError error = new ConversationError();
+ error.setMessage(message);
+ error.setReason(reason);
+ ChatProgressValue value = new ChatProgressValue();
+ value.setConversationError(error);
+ return value;
+ }
+}
diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ConversationHtmlBlockFactoryTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ConversationHtmlBlockFactoryTest.java
new file mode 100644
index 00000000..2a890403
--- /dev/null
+++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ConversationHtmlBlockFactoryTest.java
@@ -0,0 +1,761 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+package com.microsoft.copilot.eclipse.ui.chat;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+
+import com.google.gson.Gson;
+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;
+
+
+class ConversationHtmlBlockFactoryTest {
+
+ private static final Gson GSON = new Gson();
+
+ private ConversationHtmlBlockFactory factory;
+
+ @BeforeEach
+ void setUp() {
+ factory = new ConversationHtmlBlockFactory();
+ }
+
+ @Nested
+ class EscapeHtmlTests {
+
+ @Test
+ void returnsEmptyStringForNull() {
+ assertEquals("", ConversationHtmlBlockFactory.escapeHtml(null));
+ }
+
+ @ParameterizedTest(name = "escapeHtml(\"{0}\") = \"{1}\"")
+ @CsvSource({
+ "'");
+ assertFalse(html.contains("", "completed",
+ "path with & special ");
+ String html = factory.createToolCallHtmlBlock("t1-tc-4", tc);
+
+ assertTrue(html.contains("<script>"), "Tool name should be escaped");
+ assertTrue(html.contains("& special <chars>"),
+ "Progress should be escaped");
+ }
+
+ @Test
+ void restoredToolCallBlock_matchesLiveToolCallBehavior() {
+ ToolCallData tc = new ToolCallData();
+ tc.setName("readFile");
+ tc.setStatus("completed");
+ tc.setProgressMessage("src/main.java");
+
+ String html = factory.createRestoredToolCallHtmlBlock("t1-tc-5", tc);
+
+ assertTrue(html.contains("tc-completed"));
+ assertTrue(html.contains("✓"));
+ assertTrue(html.contains("readFile"));
+ assertTrue(html.contains("src/main.java"));
+ }
+ }
+
+ @Nested
+ class ModelInfoBlockTests {
+
+ @Test
+ void rendersModelNameOnly_whenBillingIsOneAndNoEffort() {
+ String html = factory.createModelInfoHtmlBlock("b1", "GPT-5 mini", 1.0, null);
+
+ assertTrue(html.contains("GPT-5 mini"), "Should show model name");
+ assertFalse(html.contains("billing-multiplier"),
+ "Should omit billing when multiplier is 1.0");
+ assertFalse(html.contains("reasoning-effort"),
+ "Should omit effort when null");
+ }
+
+ @Test
+ void includesBillingMultiplier_whenNotOne() {
+ String html = factory.createModelInfoHtmlBlock("b1", "gpt-4o", 2.0, null);
+
+ assertTrue(html.contains("(2.0x)"), "Should show multiplier");
+ assertTrue(html.contains("billing-multiplier"));
+ }
+
+ @Test
+ void includesCapitalizedReasoningEffort() {
+ String html = factory.createModelInfoHtmlBlock("b1", "GPT-5", 1.0, "medium");
+
+ assertTrue(html.contains("- Medium"), "Should capitalize and prefix with dash");
+ assertTrue(html.contains("reasoning-effort"));
+ }
+
+ @Test
+ void omitsBillingMultiplier_whenZero() {
+ String html = factory.createModelInfoHtmlBlock("b1", "Model", 0, "high");
+
+ assertFalse(html.contains("billing-multiplier"),
+ "Should omit billing when multiplier is 0");
+ assertTrue(html.contains("- High"));
+ }
+
+ @Test
+ void omitsEffort_whenEmpty() {
+ String html = factory.createModelInfoHtmlBlock("b1", "Model", 1.0, "");
+
+ assertFalse(html.contains("reasoning-effort"),
+ "Should omit effort when empty string");
+ }
+ }
+
+ @Nested
+ class ConfirmationBlockTests {
+
+ @Test
+ void rendersFullConfirmation_withCommandAndButtons() {
+ ConfirmationContent content = new ConfirmationContent(
+ "Run in terminal?",
+ "This will execute a command.",
+ List.of(
+ ConfirmationAction.allowOnce("Allow"),
+ ConfirmationAction.skip("Deny")));
+
+ Map input = Map.of(
+ "command", "npm install",
+ "explanation", "Installs dependencies");
+
+ String html = factory.createConfirmationHtmlBlock("confirm-1", content, input);
+
+ assertTrue(html.contains("Run in terminal?"), "Should show title");
+ assertTrue(html.contains("This will execute a command."), "Should show message");
+ assertTrue(html.contains("npm install"), "Should show command");
+ assertTrue(html.contains("Installs dependencies"), "Should show explanation");
+ assertTrue(html.contains("btn-primary"), "Primary button should have class");
+ assertTrue(html.contains("window.acceptToolAction(0)"),
+ "Accept button should call acceptToolAction");
+ assertTrue(html.contains("window.dismissToolAction()"),
+ "Deny button should call dismissToolAction");
+ assertTrue(html.contains("Allow"), "Should show accept label");
+ assertTrue(html.contains("Deny"), "Should show deny label");
+ }
+
+ @Test
+ void omitsOptionalFields_whenAbsent() {
+ ConfirmationContent content = new ConfirmationContent(
+ "Confirm action?", null, List.of());
+
+ String html = factory.createConfirmationHtmlBlock("confirm-2", content, "not-a-map");
+
+ assertTrue(html.contains("Confirm action?"), "Should show title");
+ assertFalse(html.contains("confirmation-message"),
+ "Should omit message div when null");
+ assertFalse(html.contains("confirmation-command"),
+ "Should omit command when input is not a Map");
+ }
+
+ @Test
+ void escapesHtmlInTitleAndMessage() {
+ ConfirmationContent content = new ConfirmationContent(
+ "Title", "msg with & special", List.of());
+
+ String html = factory.createConfirmationHtmlBlock("confirm-3", content, null);
+
+ assertTrue(html.contains("<b>Title</b>"),
+ "Title should be escaped");
+ assertTrue(html.contains("msg with & special"),
+ "Message should be escaped");
+ }
+ }
+
+ @Nested
+ class ThinkingBlockTests {
+
+ @Test
+ void activeThinkingBlock_isOpenWithSpinner() {
+ String html = factory.createThinkingHtmlBlock("think-1", "Analyzing code...");
+
+ assertTrue(html.contains(""), "Should be open during streaming");
+ assertTrue(html.contains("thinking-spinner"), "Should show spinner");
+ assertTrue(html.contains("Thinking…"), "Should have 'Thinking...' summary");
+ assertTrue(html.contains("Analyzing code..."), "Should include thinking text");
+ assertTrue(html.contains("class=\"thinking-block\""));
+ }
+
+ @Test
+ void sealedThinkingBlock_isClosedWithCustomTitle() {
+ String html = factory.createSealedThinkingHtmlBlock(
+ "think-2", "Done.", "Analysis Complete");
+
+ assertTrue(html.contains(""), "Should have ");
+ assertFalse(html.contains(""), "Should NOT be open");
+ assertFalse(html.contains("thinking-spinner"), "Should NOT show spinner");
+ assertTrue(html.contains("Analysis Complete"), "Should show custom title");
+ assertTrue(html.contains("Done."), "Should have content");
+ }
+
+ @Test
+ void sealedThinkingBlock_usesDefaultTitle_whenBlank() {
+ String html = factory.createSealedThinkingHtmlBlock("think-3", "text", null);
+ assertTrue(html.contains("Thinking…"),
+ "Should fall back to default title when null");
+
+ html = factory.createSealedThinkingHtmlBlock("think-4", "text", "");
+ assertTrue(html.contains("Thinking…"),
+ "Should fall back to default title when empty");
+ }
+
+ @Test
+ void restoredThinkingBlock_delegatesToSealed() {
+ ThinkingBlockData data = new ThinkingBlockData("tb-1", "Analyzing...");
+ data.setTitle("Code Analysis");
+
+ String html = factory.createRestoredThinkingHtmlBlock("think-5", data);
+
+ assertTrue(html.contains("Code Analysis"));
+ assertTrue(html.contains(""));
+ assertFalse(html.contains(""));
+ assertTrue(html.contains("Analyzing..."));
+ }
+
+ @Test
+ void escapesHtmlInThinkingContent() {
+ String html = factory.createThinkingHtmlBlock("think-6", "if (a < b) { }");
+ assertTrue(html.contains("a < b"), "Should escape HTML in thinking text");
+ }
+ }
+
+ @Nested
+ class TurnContainerAndOtherBlockTests {
+
+ @Test
+ void createTurnContainerHtmlBlock_copilotTurn() {
+ String html = factory.createTurnContainerHtmlBlock(
+ "turn-123", true, "data:image/png;base64,ABC", "GitHub Copilot");
+
+ assertTrue(html.contains("id=\"turn-123-copilot\""));
+ assertTrue(html.contains("turn-copilot"));
+ assertTrue(html.contains("turn-avatar"));
+ assertTrue(html.contains("GitHub Copilot"));
+ assertTrue(html.contains("id=\"turn-123-copilot-content\""));
+ }
+
+ @Test
+ void createTurnContainerHtmlBlock_userTurn() {
+ String html = factory.createTurnContainerHtmlBlock(
+ "turn-456", false, null, "TestUser");
+
+ assertTrue(html.contains("id=\"turn-456-user\""));
+ assertTrue(html.contains("turn-user"));
+ assertTrue(html.contains("TestUser"));
+ assertTrue(html.contains("id=\"turn-456-user-content\""));
+ assertFalse(html.contains("turn-avatar"),
+ "Should omit avatar img when dataUri is null");
+ }
+
+ @Test
+ void sameTurnId_producesDifferentContainerIds() {
+ String user = factory.createTurnContainerHtmlBlock("t1", false, null, "U");
+ String copilot = factory.createTurnContainerHtmlBlock("t1", true, null, "C");
+
+ assertTrue(user.contains("id=\"t1-user\""));
+ assertTrue(copilot.contains("id=\"t1-copilot\""));
+ assertFalse(user.contains("-copilot"));
+ assertFalse(copilot.contains("-user\""));
+ }
+
+ @Test
+ void createCopilotReplyHtmlBlock_rendersMarkdown() {
+ String html = factory.createCopilotReplyHtmlBlock("b1", "**hello**");
+
+ assertTrue(html.contains("id=\"b1\""));
+ assertTrue(html.contains("class=\"response\""));
+ assertTrue(html.contains("hello"));
+ }
+
+ @Test
+ void createUserRequestHtmlBlock_rendersMarkdown() {
+ String html = factory.createUserRequestHtmlBlock("b2", "How do I **sort**?");
+
+ assertTrue(html.contains("id=\"b2\""));
+ assertTrue(html.contains("class=\"user-request\""));
+ assertTrue(html.contains("sort"));
+ }
+
+ @Test
+ void createErrorMessageHtmlBlock() {
+ String html = factory.createErrorMessageHtmlBlock("e1", "Rate limit exceeded");
+
+ assertTrue(html.contains("id=\"e1\""));
+ assertTrue(html.contains("class=\"warning-message\""));
+ assertTrue(html.contains("Rate limit exceeded"));
+ }
+
+ @Test
+ void createWarningMessageHtmlBlockWithActions() {
+ List actions = List.of(
+ new QuotaActions.QuotaAction("Upgrade Plan", "Upgrade tooltip",
+ "https://example.com/upgrade", true),
+ new QuotaActions.QuotaAction("Enable Usage", "Usage tooltip",
+ "https://example.com/usage", false));
+
+ String html = factory.createWarningMessageHtmlBlock("w1", "Quota exceeded", actions);
+
+ assertTrue(html.contains("id=\"w1\""));
+ assertTrue(html.contains("class=\"warning-message\""));
+ assertTrue(html.contains("Quota exceeded"));
+ assertTrue(html.contains("class=\"warning-actions\""));
+ assertTrue(html.contains("btn-confirm btn-primary"));
+ assertTrue(html.contains("Upgrade Plan"));
+ assertTrue(html.contains("Enable Usage"));
+ assertTrue(html.contains("copilotAction('openLink','"));
+ }
+
+ @Test
+ void createWarningMessageHtmlBlockWithoutActions() {
+ String html = factory.createWarningMessageHtmlBlock("w2", "Generic error", List.of());
+
+ assertTrue(html.contains("id=\"w2\""));
+ assertTrue(html.contains("class=\"warning-message\""));
+ assertTrue(html.contains("Generic error"));
+ assertFalse(html.contains("warning-actions"));
+ }
+
+ @Test
+ void createStreamingIndicatorHtmlBlock() {
+ String html = factory.createStreamingIndicatorHtmlBlock("s1");
+
+ assertTrue(html.contains("id=\"s1\""));
+ assertTrue(html.contains("class=\"streaming-indicator\""));
+ assertTrue(html.contains("class=\"dot\""));
+ }
+
+ @Test
+ void createCompactingStatusHtmlBlock() {
+ String html = factory.createCompactingStatusHtmlBlock("c1");
+
+ assertTrue(html.contains("id=\"c1\""));
+ assertTrue(html.contains("class=\"compacting-status\""));
+ assertTrue(html.contains("Compacting"));
+ }
+
+ @Test
+ void createAgentMessageHtmlBlock() {
+ String html = factory.createAgentMessageHtmlBlock(
+ "msg-1", "PR Created", "Description", "https://github.com/pr/1");
+
+ assertTrue(html.contains("id=\"msg-1\""));
+ assertTrue(html.contains("class=\"message-card agent-message\""));
+ assertTrue(html.contains("PR Created"));
+ assertTrue(html.contains("Description"));
+ assertTrue(html.contains("copilotAction('openLink','https://github.com/pr/1')"));
+ }
+ }
+
+ @Nested
+ class BlockIdTests {
+
+ @Test
+ void userAndCopilotContainerIds_doNotCollide() {
+ assertEquals("t1-user", ConversationHtmlBlockFactory.userTurnContainerId("t1"));
+ assertEquals("t1-copilot",
+ ConversationHtmlBlockFactory.copilotTurnContainerId("t1"));
+ }
+
+ @Test
+ void contentBlockId_scopedByRole() {
+ assertEquals("t1-user-content",
+ ConversationHtmlBlockFactory.contentBlockId("t1", false));
+ assertEquals("t1-copilot-content",
+ ConversationHtmlBlockFactory.contentBlockId("t1", true));
+ }
+
+ @Test
+ void otherIdFormats() {
+ assertEquals("t1-5", ConversationHtmlBlockFactory.copilotChildBlockId("t1", 5));
+ assertEquals("t1-tc-3", ConversationHtmlBlockFactory.toolCallBlockId("t1", 3));
+ assertEquals("t1-compacting", ConversationHtmlBlockFactory.compactingBlockId("t1"));
+ assertEquals("t1-model-info", ConversationHtmlBlockFactory.modelInfoBlockId("t1"));
+ assertEquals("t1-streaming",
+ ConversationHtmlBlockFactory.streamingIndicatorId("t1"));
+ assertEquals("t1-confirm",
+ ConversationHtmlBlockFactory.confirmationBlockId("t1"));
+ }
+ }
+
+ // Creates an AgentToolCall using Gson (class has no setters).
+ private static AgentToolCall agentToolCall(String name, String status,
+ String progressMessage) {
+ String json = GSON.toJson(Map.of(
+ "name", name,
+ "status", status,
+ "progressMessage", progressMessage != null ? progressMessage : ""));
+ AgentToolCall tc = GSON.fromJson(json, AgentToolCall.class);
+ // Gson sets empty string; clear to null if that's what we want
+ if (progressMessage == null) {
+ // Use Gson again with explicit null
+ String jsonNull = "{\"name\":\"" + name + "\",\"status\":\"" + status + "\"}";
+ tc = GSON.fromJson(jsonNull, AgentToolCall.class);
+ }
+ return tc;
+ }
+
+ @Nested
+ class SubagentBlockTests {
+
+ @Test
+ void subagentBlockId_combinesParentTurnAndToolCall() {
+ assertEquals("turn-1-subagent-tc-9",
+ ConversationHtmlBlockFactory.subagentBlockId("turn-1", "tc-9"));
+ }
+
+ @Test
+ void subagentContentAreaId_suffixesBlockIdWithContent() {
+ assertEquals("turn-1-subagent-tc-9-content",
+ ConversationHtmlBlockFactory.subagentContentAreaId("turn-1", "tc-9"));
+ }
+
+ @Test
+ void createSubagentBlockHtmlBlock_nestsContentAreaInsideBorderedBlock() {
+ String blockId = ConversationHtmlBlockFactory.subagentBlockId("turn-1", "tc-9");
+ String contentAreaId = ConversationHtmlBlockFactory.subagentContentAreaId("turn-1", "tc-9");
+ String html = factory.createSubagentBlockHtmlBlock(
+ blockId, contentAreaId, "data:image/png;base64,AAAA", "Investigated the failing test");
+
+ assertTrue(html.contains("id=\"turn-1-subagent-tc-9\""), "Outer block carries its id");
+ assertTrue(html.contains("class=\"message-card subagent-message-block\""),
+ "Shares SWT subagent CSS class for parity");
+ assertTrue(html.contains("id=\"turn-1-subagent-tc-9-content\""),
+ "Inner content area carries its id");
+ assertTrue(html.contains("class=\"subagent-content\""), "Inner content area is styled");
+ assertTrue(html.contains("class=\"block-title\""), "Card has a header");
+ assertTrue(html.contains("class=\"subagent-avatar\""), "Header shows the copilot avatar");
+ assertTrue(html.contains("Investigated the failing test"), "Header shows the title");
+ // Content area must be nested inside the outer block.
+ assertTrue(html.indexOf(blockId) < html.indexOf(contentAreaId),
+ "Content area is nested inside the outer block");
+ }
+
+ @Test
+ void createSubagentBlockHtmlBlock_fallsBackToDefaultTitle() {
+ String blockId = ConversationHtmlBlockFactory.subagentBlockId("turn-1", "tc-9");
+ String contentAreaId = ConversationHtmlBlockFactory.subagentContentAreaId("turn-1", "tc-9");
+ String html = factory.createSubagentBlockHtmlBlock(blockId, contentAreaId, null, null);
+
+ assertTrue(html.contains("Subagent"), "Falls back to a default title");
+ assertFalse(html.contains("class=\"subagent-avatar\""), "No avatar img when uri is blank");
+ }
+ }
+}
diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ConversationRendererVisualDemoSideBySide.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ConversationRendererVisualDemoSideBySide.java
new file mode 100644
index 00000000..3843247e
--- /dev/null
+++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ConversationRendererVisualDemoSideBySide.java
@@ -0,0 +1,559 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+package com.microsoft.copilot.eclipse.ui.chat;
+
+import java.util.List;
+import java.util.Map;
+
+import org.eclipse.core.commands.AbstractHandler;
+import org.eclipse.core.commands.ExecutionEvent;
+import org.eclipse.core.commands.ExecutionException;
+import org.eclipse.e4.ui.css.swt.theme.IThemeEngine;
+import org.eclipse.lsp4j.WorkDoneProgressKind;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.browser.Browser;
+import org.eclipse.swt.custom.SashForm;
+import org.eclipse.swt.layout.FillLayout;
+import org.eclipse.swt.layout.GridData;
+import org.eclipse.swt.layout.GridLayout;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Display;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.Shell;
+import org.mockito.Mockito;
+import org.eclipse.ui.PlatformUI;
+
+import com.microsoft.copilot.eclipse.core.AuthStatusManager;
+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.ChatProgressValue;
+import com.microsoft.copilot.eclipse.core.lsp.protocol.Thinking;
+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.ThinkingBlockState;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ToolCallData;
+import com.microsoft.copilot.eclipse.core.persistence.UserTurnData;
+import com.microsoft.copilot.eclipse.core.persistence.UserTurnData.MessageData;
+import com.microsoft.copilot.eclipse.ui.chat.services.AvatarService;
+import com.microsoft.copilot.eclipse.ui.chat.services.ChatFontService;
+import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager;
+
+/**
+ * Side-by-side visual comparison of {@link BrowserConversationWidget} (left) and
+ * {@link StyledTextConversationWidget} (right) rendering identical conversation content.
+ *
+ *
How to run: Launch the test Eclipse Application (include the
+ * {@code com.microsoft.copilot.eclipse.ui.test} bundle), then open the Copilot menu and select
+ * "Compare Chat View Rendering Alternatives".
+ *
+ *
Both widgets require a running Eclipse workbench (PlatformUI, OSGi bundle classloaders).
+ * This handler cannot run as a standalone Java application.
+ */
+public class ConversationRendererVisualDemoSideBySide extends AbstractHandler {
+
+ @Override
+ public Object execute(ExecutionEvent event) throws ExecutionException {
+ openSideBySide(Display.getCurrent());
+ return null;
+ }
+
+ /**
+ * Opens the side-by-side comparison shell.
+ */
+ public static void openSideBySide(Display display) {
+ Shell shell = new Shell(display);
+ shell.setText("Conversation Renderer \u2014 Side-by-Side Comparison");
+ shell.setSize(1600, 1000);
+ shell.setLayout(new GridLayout(1, false));
+
+ // --- Theme toggle buttons ---
+ Composite toolbar = new Composite(shell, SWT.NONE);
+ toolbar.setLayoutData(new GridData(SWT.FILL, SWT.TOP, true, false));
+ toolbar.setLayout(new GridLayout(3, false));
+ Label themeLabel = new Label(toolbar, SWT.NONE);
+ themeLabel.setText("Theme:");
+ Button darkButton = new Button(toolbar, SWT.PUSH);
+ darkButton.setText("Dark");
+ Button lightButton = new Button(toolbar, SWT.PUSH);
+ lightButton.setText("Light");
+
+ // --- SashForm with both widgets ---
+ SashForm sash = new SashForm(shell, SWT.HORIZONTAL);
+ sash.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true));
+
+ // --- Left: Browser-based renderer ---
+ Composite leftPane = createLabeledPane(sash,
+ "BrowserConversationWidget (HTML/CSS)");
+ BrowserConversationWidget browserWidget = new BrowserConversationWidget(leftPane);
+
+ // --- Right: StyledText-based renderer ---
+ Composite rightPane = createLabeledPane(sash,
+ "StyledTextConversationWidget (SWT StyledText)");
+ ChatServiceManager demoServiceManager = createDemoChatServiceManager();
+ StyledTextConversationWidget[] styledTextHolder = {
+ new StyledTextConversationWidget(rightPane, demoServiceManager)};
+
+ sash.setWeights(50, 50);
+
+ // Theme toggle listeners — recreate StyledText widget to pick up new theme colors
+ darkButton.addListener(SWT.Selection, e -> {
+ Browser browser = (Browser) browserWidget.getControl();
+ browser.execute("window.setTheme('dark')");
+ switchEclipseTheme("org.eclipse.e4.ui.css.theme.e4_dark");
+ styledTextHolder[0] = recreateStyledTextWidget(
+ rightPane, demoServiceManager, styledTextHolder[0]);
+ });
+ lightButton.addListener(SWT.Selection, e -> {
+ Browser browser = (Browser) browserWidget.getControl();
+ browser.execute("window.setTheme('light')");
+ switchEclipseTheme("org.eclipse.e4.ui.css.theme.e4_default");
+ styledTextHolder[0] = recreateStyledTextWidget(
+ rightPane, demoServiceManager, styledTextHolder[0]);
+ });
+
+ // Feed identical content to both widgets after the browser page has loaded.
+ // BrowserConversationWidget needs its page loaded before insertHtmlBlockChild works,
+ // so we schedule the content injection asynchronously.
+ display.asyncExec(() -> feedDemoContent(browserWidget, styledTextHolder[0]));
+
+ shell.open();
+ }
+
+ // --- Demo content constants (shared between both rendering paths) ---
+
+ private static final String USER_MSG_1 =
+ "How do I create a **GFM table** in Markdown? Also show me a code example.";
+
+ private static final String COPILOT_REPLY_TABLE = """
+ Here's a GFM table example:
+
+ | Feature | StyledText | Browser |
+ |----------------|:----------:|:-------:|
+ | Tables | \u274c | \u2705 |
+ | Code blocks | \u2705 | \u2705 |
+ | Task lists | \u274c | \u2705 |
+ | Inline code | \u2705 | \u2705 |
+ | Bold/Italic | \u2705 | \u2705 |
+
+ And here's a Java code example:
+
+ ```java
+ public class HelloWorld {
+ public static void main(String[] args) {
+ System.out.println("Hello, world!");
+ if (args.length > 0) {
+ System.out.println("Args: " + String.join(", ", args));
+ }
+ }
+ }
+ ```
+
+ You can also use **inline code** like `System.out.println()` in your text.
+
+ Here's a task list:
+
+ - [x] Create table structure
+ - [x] Add code block
+ - [ ] Test rendering
+ - [ ] Submit PR""";
+
+ private static final String THINKING_CONTENT =
+ "The user wants to know about GFM tables and code examples. "
+ + "I'll provide a table example and a fenced code block with Java.";
+
+ private static final String THINKING_TITLE = "Planning response";
+
+ private static final String USER_MSG_2 =
+ "Thanks! Can you also show me how bold and italic text render?";
+
+ private static final String COPILOT_REPLY_FORMATTING = """
+ Sure! Here are some formatting examples:
+
+ - **Bold text** is wrapped in double asterisks
+ - *Italic text* uses single asterisks
+ - ***Bold and italic*** uses triple asterisks
+ - ~~Strikethrough~~ uses tildes
+
+ > This is a blockquote that can span
+ > multiple lines.
+
+ And a numbered list:
+
+ 1. First item
+ 2. Second item
+ 3. Third item""";
+
+ private static final String ERROR_MSG = "Let me try a different approach...";
+ private static final String ERROR_DETAIL =
+ "Context window exceeded. The conversation is too long.";
+
+ private static final String USER_MSG_FIX_TEST =
+ "The login test is failing. Investigate it with a subagent and open a PR with the fix.";
+ private static final String USER_MSG_BUILD =
+ "Great. Now run the build to make sure everything still compiles.";
+ private static final String USER_MSG_ERROR =
+ "Summarize this entire 500-page design document in a single response.";
+ private static final String USER_MSG_QUOTA =
+ "Generate the full implementation using the most capable premium model.";
+ private static final String USER_MSG_INVESTIGATE =
+ "Before you make changes, investigate the current setup and run whatever tools you need.";
+ private static final String USER_MSG_LIVE =
+ "Walk me through the migration approach step by step.";
+
+ private static final String SUBAGENT_THINKING_CONTENT =
+ "The failing test points at the login flow. I'll delegate a focused investigation to a "
+ + "subagent, then summarize the finding and open a pull request.";
+ private static final String BUILD_REPLY_TEXT =
+ "I'll run the build now. This needs to execute a terminal command:";
+
+ private static final String AGENT_MSG_TITLE = "Pull Request Created";
+ private static final String AGENT_MSG_DESC =
+ "Implemented the requested feature with comprehensive unit tests and integration tests. "
+ + "All existing tests continue to pass. The PR includes documentation updates as well.";
+ private static final String AGENT_MSG_PR_LINK =
+ "https://github.com/microsoft/copilot-for-eclipse/pull/330";
+
+ private static final String CONFIRM_TITLE = "Run command in terminal?";
+ private static final String CONFIRM_MESSAGE =
+ "Copilot wants to execute a shell command.";
+ private static final String CONFIRM_COMMAND = "npm install && npm run build";
+ private static final String CONFIRM_EXPLANATION =
+ "Install dependencies and build the project";
+
+ private static final String THINKING_ACTIVE_TEXT =
+ "Analyzing the project structure to determine the best approach. "
+ + "I need to check if there are existing test files and "
+ + "understand the module dependencies before making changes...";
+
+ private static final String QUOTA_REPLY_TEXT = "I was unable to complete the request.";
+ private static final String QUOTA_ERROR_MSG =
+ "You've used all your premium requests for the month. "
+ + "Consider upgrading your plan for continued access.";
+
+ private static final String SUBAGENT_PARENT_REPLY =
+ "I'll delegate the investigation to a subagent.";
+ private static final String SUBAGENT_REPLY_TEXT =
+ "Searched the codebase and found the failing assertion in `LoginServiceTest`. "
+ + "The mock returns `null` where a session token is expected.";
+
+ private static Composite createLabeledPane(Composite parent, String title) {
+ Composite pane = new Composite(parent, SWT.NONE);
+ pane.setLayout(new GridLayout(1, false));
+ Label label = new Label(pane, SWT.NONE);
+ label.setText(title);
+ label.setLayoutData(new GridData(SWT.FILL, SWT.TOP, true, false));
+ Composite content = new Composite(pane, SWT.NONE);
+ content.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true));
+ content.setLayout(new FillLayout());
+ return content;
+ }
+
+ /**
+ * Creates a mock {@link ChatServiceManager} that reuses the real {@link AvatarService}
+ * and {@link AuthStatusManager} from the running Eclipse instance (for real user avatar),
+ * plus a real {@link ChatFontService}.
+ */
+ private static ChatServiceManager createDemoChatServiceManager() {
+ ChatServiceManager mockManager = Mockito.mock(ChatServiceManager.class);
+
+ // Use the real AuthStatusManager from the running Copilot plugin
+ AuthStatusManager realAuth = CopilotCore.getPlugin().getAuthStatusManager();
+ Mockito.when(mockManager.getAuthStatusManager()).thenReturn(realAuth);
+
+ // Use a real AvatarService backed by the real auth (loads GitHub avatar)
+ AvatarService realAvatarService = new AvatarService(realAuth);
+ Mockito.when(mockManager.getAvatarService()).thenReturn(realAvatarService);
+
+ // ChatFontService — real instance (no OSGi dependencies in constructor)
+ ChatFontService fontService = new ChatFontService();
+ Mockito.when(mockManager.getChatFontService()).thenReturn(fontService);
+
+ return mockManager;
+ }
+
+ /**
+ * Feeds identical synthetic conversation data to both widgets. The conversation interleaves user
+ * turns with copilot turns (so every copilot turn gets its own header). History (restored) turns
+ * and live (streamed) turns are kept in separate turns: restoring a turn and then streaming into
+ * the same turn id would duplicate the browser turn container and collide child-block ids. The
+ * active (non-sealed) thinking block is therefore a live-only turn placed mid-conversation (never
+ * the trailing round, which would otherwise reserve empty space below it in StyledText). Covers
+ * all block types: text with formatting, tables, code blocks, task lists, thinking blocks (sealed
+ * and active), tool calls (all statuses), tool confirmation, error and quota warnings, subagent
+ * nesting, agent messages, and model info.
+ */
+ private static void feedDemoContent(
+ BrowserConversationWidget browser, StyledTextConversationWidget styledText) {
+ feedWidgets(browser, styledText);
+ }
+
+ /**
+ * Feeds demo content to one or both widgets. When {@code browser} is {@code null}, only the
+ * StyledText widget is populated (used after theme-switch recreation).
+ */
+ private static void feedWidgets(
+ BrowserConversationWidget browser, StyledTextConversationWidget styledText) {
+
+ // ConversationDataFactory needs an AuthStatusManager for tool call conversion
+ AuthStatusManager mockAuth = Mockito.mock(AuthStatusManager.class);
+ Mockito.when(mockAuth.getUserName()).thenReturn("demo-user");
+ ConversationDataFactory dataFactory = new ConversationDataFactory(mockAuth);
+
+ // The conversation interleaves user turns with copilot turns so every copilot turn gets its own
+ // header. History (restored) turns and the single live (streamed) turn are kept separate.
+
+ // --- Exchange 1 (turns 1-2): table + code + task list (reply only) ---
+ restoreUser(browser, styledText, dataFactory, "turn-1", USER_MSG_1);
+
+ CopilotTurnData tableTurn = new CopilotTurnData();
+ tableTurn.setTurnId("turn-2");
+ ReplyData tableReply = tableTurn.getReply();
+ tableReply.setText(COPILOT_REPLY_TABLE);
+ tableReply.setModelName("GPT-5 mini");
+ tableReply.setBillingMultiplier(1.0);
+ tableReply.setReasoningEffort("medium");
+ restoreBoth(browser, styledText, tableTurn, dataFactory);
+
+ // --- Exchange 2 (turns 3-4): markdown formatting examples ---
+ restoreUser(browser, styledText, dataFactory, "turn-3", USER_MSG_2);
+
+ CopilotTurnData formattingTurn = new CopilotTurnData();
+ formattingTurn.setTurnId("turn-4");
+ ReplyData formattingReply = formattingTurn.getReply();
+ formattingReply.setText(COPILOT_REPLY_FORMATTING);
+ formattingReply.setModelName("Claude Opus 4");
+ formattingReply.setBillingMultiplier(2.5);
+ formattingReply.setReasoningEffort("high");
+ restoreBoth(browser, styledText, formattingTurn, dataFactory);
+
+ // --- Exchange 3 (turns 5-6): sealed thinking block with tool-call examples (restored) ---
+ restoreUser(browser, styledText, dataFactory, "turn-5", USER_MSG_INVESTIGATE);
+
+ ThinkingBlockData thinkingBlock = new ThinkingBlockData();
+ thinkingBlock.setId("turn-6-thinking");
+ thinkingBlock.setContent(THINKING_CONTENT);
+ thinkingBlock.setTitle(THINKING_TITLE);
+ thinkingBlock.setState(ThinkingBlockState.COMPLETED);
+
+ EditAgentRoundData thinkingRound = new EditAgentRoundData();
+ thinkingRound.setRoundId(1);
+ thinkingRound.setThinkingBlock(thinkingBlock);
+ thinkingRound.setToolCalls(List.of(
+ toolCall("tc-1", "file_search", "completed", "Found 3 results in src/"),
+ toolCall("tc-2", "run_in_terminal", "cancelled", "User cancelled"),
+ toolCall("tc-3", "edit_file", "error", "Permission denied: /etc/hosts"),
+ toolCall("tc-4", "grep_search", "running", "Searching...")));
+
+ CopilotTurnData thinkingTurn = new CopilotTurnData();
+ thinkingTurn.setTurnId("turn-6");
+ ReplyData thinkingReply = thinkingTurn.getReply();
+ thinkingReply.setEditAgentRounds(List.of(thinkingRound));
+ thinkingReply.setModelName("GPT-5 mini");
+ thinkingReply.setBillingMultiplier(1.0);
+ thinkingReply.setReasoningEffort("medium");
+ restoreBoth(browser, styledText, thinkingTurn, dataFactory);
+
+ // --- Exchange 4 (turns 7-8): active (non-sealed) thinking block via the live API ---
+ // Kept in its own live-only turn (never mixed with a restored turn, which would duplicate the
+ // browser turn container and collide child-block ids). Positioned mid-conversation so it is not
+ // the trailing round — a short trailing active-thinking turn would reserve empty scroller space
+ // below it in the StyledText windowed renderer.
+ restoreUser(browser, styledText, dataFactory, "turn-7", USER_MSG_LIVE);
+
+ if (browser != null) {
+ browser.beginTurn("turn-8", true, false);
+ }
+ styledText.beginTurn("turn-8", true, false);
+
+ ChatProgressValue thinkingEvent = new ChatProgressValue();
+ thinkingEvent.setKind(WorkDoneProgressKind.report);
+ thinkingEvent.setTurnId("turn-8");
+ thinkingEvent.setThinking(new Thinking("turn-8-active", THINKING_ACTIVE_TEXT, null));
+ if (browser != null) {
+ browser.processTurnEvent(thinkingEvent);
+ }
+ styledText.processTurnEvent(thinkingEvent);
+
+ // --- Exchange 5 (turns 9-10): reply + thinking + nested subagent + PR agent message ---
+ restoreUser(browser, styledText, dataFactory, "turn-9", USER_MSG_FIX_TEST);
+
+ ThinkingBlockData subThinking = new ThinkingBlockData();
+ subThinking.setId("turn-10-thinking");
+ subThinking.setContent(SUBAGENT_THINKING_CONTENT);
+ subThinking.setTitle("Delegating investigation");
+ subThinking.setState(ThinkingBlockState.COMPLETED);
+
+ EditAgentRoundData subRound = new EditAgentRoundData();
+ subRound.setRoundId(1);
+ subRound.setThinkingBlock(subThinking);
+ subRound.setToolCalls(List.of(
+ toolCall("tc-subagent", "run_subagent", "completed", "Investigated the failing test")));
+
+ CopilotTurnData subParentTurn = new CopilotTurnData();
+ subParentTurn.setTurnId("turn-10");
+ ReplyData subParentReply = subParentTurn.getReply();
+ subParentReply.setText(SUBAGENT_PARENT_REPLY);
+ subParentReply.setEditAgentRounds(List.of(subRound));
+ AgentMessageData prMessage = new AgentMessageData();
+ prMessage.setTitle(AGENT_MSG_TITLE);
+ prMessage.setDescription(AGENT_MSG_DESC);
+ prMessage.setPrLink(AGENT_MSG_PR_LINK);
+ prMessage.setAgentSlug("github-copilot-coding-agent");
+ subParentReply.setAgentMessages(List.of(prMessage));
+ subParentReply.setModelName("Claude Opus 4");
+ subParentReply.setBillingMultiplier(2.5);
+ subParentReply.setReasoningEffort("high");
+ restoreBoth(browser, styledText, subParentTurn, dataFactory);
+
+ // Nested subagent turn (parentTurnId + subagentToolCallId route it into the subagent block).
+ CopilotTurnData subChildTurn = new CopilotTurnData();
+ subChildTurn.setTurnId("turn-10-sub");
+ subChildTurn.setParentTurnId("turn-10");
+ subChildTurn.setSubagentToolCallId("tc-subagent");
+ subChildTurn.getReply().setText(SUBAGENT_REPLY_TEXT);
+ restoreBoth(browser, styledText, subChildTurn, dataFactory);
+
+ // --- Exchange 6 (turns 11-12): running tool with a live tool-confirmation request ---
+ restoreUser(browser, styledText, dataFactory, "turn-11", USER_MSG_BUILD);
+
+ CopilotTurnData confirmTurn = new CopilotTurnData();
+ confirmTurn.setTurnId("turn-12");
+ ReplyData confirmReply = confirmTurn.getReply();
+ confirmReply.setText(BUILD_REPLY_TEXT);
+ EditAgentRoundData confirmRound = new EditAgentRoundData();
+ confirmRound.setRoundId(1);
+ confirmRound.setToolCalls(List.of(
+ toolCall("tc-confirm", "run_in_terminal", "running", "Awaiting confirmation...")));
+ confirmReply.setEditAgentRounds(List.of(confirmRound));
+ confirmReply.setModelName("GPT-5 mini");
+ confirmReply.setBillingMultiplier(1.0);
+ restoreBoth(browser, styledText, confirmTurn, dataFactory);
+
+ // Show tool confirmation UI (live interaction on the copilot turn just restored)
+ ConfirmationContent confirmation = new ConfirmationContent(
+ CONFIRM_TITLE, CONFIRM_MESSAGE,
+ List.of(
+ ConfirmationAction.allowOnce("Allow"),
+ ConfirmationAction.skip("Deny")));
+ Map confirmInput = Map.of(
+ "command", CONFIRM_COMMAND,
+ "explanation", CONFIRM_EXPLANATION);
+ if (browser != null) {
+ browser.requestToolConfirmation("turn-12", confirmation, confirmInput);
+ }
+ styledText.requestToolConfirmation("turn-12", confirmation, confirmInput);
+
+ // --- Exchange 7 (turns 13-14): agent message with a non-quota error warning (400) ---
+ restoreUser(browser, styledText, dataFactory, "turn-13", USER_MSG_ERROR);
+
+ CopilotTurnData errorTurn = new CopilotTurnData();
+ errorTurn.setTurnId("turn-14");
+ ReplyData errorReply = errorTurn.getReply();
+ errorReply.setText(ERROR_MSG);
+ ErrorData errorData = new ErrorData();
+ errorData.setMessage(ERROR_DETAIL);
+ errorData.setCode(400);
+ ErrorMessageData errorMessageData = new ErrorMessageData();
+ errorMessageData.setError(errorData);
+ errorReply.setErrorMessages(List.of(errorMessageData));
+ errorReply.setModelName("GPT-5 mini");
+ errorReply.setBillingMultiplier(1.0);
+ restoreBoth(browser, styledText, errorTurn, dataFactory);
+
+ // --- Exchange 8 (turns 15-16): quota-exceeded warning (402) with action buttons ---
+ restoreUser(browser, styledText, dataFactory, "turn-15", USER_MSG_QUOTA);
+
+ CopilotTurnData quotaTurn = new CopilotTurnData();
+ quotaTurn.setTurnId("turn-16");
+ ReplyData quotaReply = quotaTurn.getReply();
+ quotaReply.setText(QUOTA_REPLY_TEXT);
+ ErrorData quotaError = new ErrorData();
+ quotaError.setMessage(QUOTA_ERROR_MSG);
+ quotaError.setCode(402);
+ ErrorMessageData quotaMessageData = new ErrorMessageData();
+ quotaMessageData.setError(quotaError);
+ quotaReply.setErrorMessages(List.of(quotaMessageData));
+ quotaReply.setModelName("Claude Opus 4");
+ quotaReply.setBillingMultiplier(2.5);
+ quotaReply.setReasoningEffort("high");
+ restoreBoth(browser, styledText, quotaTurn, dataFactory);
+ }
+
+ /** Restores a turn on both widgets; skips browser if null. */
+ private static void restoreBoth(BrowserConversationWidget browser,
+ StyledTextConversationWidget styledText, AbstractTurnData turn,
+ ConversationDataFactory dataFactory) {
+ if (browser != null) {
+ browser.restoreTurn(turn, dataFactory);
+ }
+ styledText.restoreTurn(turn, dataFactory);
+ }
+
+ /** Restores a user turn on both widgets; skips browser if null. */
+ private static void restoreUser(BrowserConversationWidget browser,
+ StyledTextConversationWidget styledText, ConversationDataFactory dataFactory,
+ String turnId, String message) {
+ UserTurnData userTurn = new UserTurnData();
+ userTurn.setTurnId(turnId);
+ userTurn.setMessage(new MessageData(message));
+ restoreBoth(browser, styledText, userTurn, dataFactory);
+ }
+
+ /** Creates a tool call with the given id, name, status and progress message. */
+ private static ToolCallData toolCall(String id, String name, String status, String progress) {
+ ToolCallData toolCall = new ToolCallData();
+ toolCall.setId(id);
+ toolCall.setName(name);
+ toolCall.setStatus(status);
+ toolCall.setProgressMessage(progress);
+ return toolCall;
+ }
+
+ /**
+ * Disposes the old StyledText widget and creates a fresh one with updated theme colors.
+ * The StyledText-based widget reads theme colors at construction time and does not
+ * dynamically refresh them, so recreation is necessary on theme change.
+ */
+ private static StyledTextConversationWidget recreateStyledTextWidget(
+ Composite parent, ChatServiceManager serviceManager,
+ StyledTextConversationWidget oldWidget) {
+ if (oldWidget != null && !oldWidget.getControl().isDisposed()) {
+ oldWidget.getControl().dispose();
+ }
+ StyledTextConversationWidget newWidget =
+ new StyledTextConversationWidget(parent, serviceManager);
+ parent.requestLayout();
+
+ // Re-feed demo content to the new widget (browser-only stub as first arg)
+ parent.getDisplay().asyncExec(() -> feedStyledTextOnly(newWidget));
+ return newWidget;
+ }
+
+ /**
+ * Feeds demo content to a single StyledText widget (used after theme-switch recreation).
+ */
+ private static void feedStyledTextOnly(StyledTextConversationWidget styledText) {
+ feedWidgets(null, styledText);
+ }
+
+ /**
+ * Switches the Eclipse platform CSS theme (affects StyledText colors).
+ */
+ private static void switchEclipseTheme(String themeId) {
+ IThemeEngine themeEngine =
+ (IThemeEngine) PlatformUI.getWorkbench().getService(IThemeEngine.class);
+ if (themeEngine != null) {
+ themeEngine.setTheme(themeId, true);
+ }
+ }
+}
diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/UiUtilsThemeTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/UiUtilsThemeTest.java
new file mode 100644
index 00000000..88e94a83
--- /dev/null
+++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/UiUtilsThemeTest.java
@@ -0,0 +1,178 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+package com.microsoft.copilot.eclipse.ui.utils;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.when;
+
+import org.eclipse.core.runtime.Platform;
+import org.eclipse.core.runtime.preferences.IPreferencesService;
+import org.eclipse.e4.ui.css.swt.theme.ITheme;
+import org.eclipse.e4.ui.css.swt.theme.IThemeEngine;
+import org.eclipse.ui.IWorkbench;
+import org.eclipse.ui.PlatformUI;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.osgi.framework.Bundle;
+
+/**
+ * Unit tests for {@link UiUtils#isDarkTheme()}.
+ *
+ *
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 {
+
+ private static final String COPILOT_AVATAR_PATH =
+ "/icons/chat/chat_message_copilot_avatar.png";
+ private static final String USER_AVATAR_PATH = "/icons/chat/chat_message_user_avatar.png";
+ private static final String COPILOT_DISPLAY_NAME = "GitHub Copilot";
+ private static final String GITHUB_AVATAR_URL =
+ "https://avatars.githubusercontent.com/%s?s=24&v=4";
+
+ private final Browser browser;
+ private final ConversationHtmlBlockFactory htmlFactory;
+
+ // 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;
+
+ private boolean pageLoaded;
+ private final Queue pendingScripts = new LinkedList<>();
+ private String copilotAvatarDataUri;
+ private String userAvatarDataUri;
+ private String userName;
+
+ /** 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
+ */
+ public BrowserConversationWidget(Composite parent) {
+ browser = new Browser(parent, SWT.NONE);
+ browser.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true));
+ htmlFactory = new ConversationHtmlBlockFactory();
+
+ // Load avatar icons as base64 data URIs
+ Bundle bundle = CopilotUi.getPlugin().getBundle();
+ this.copilotAvatarDataUri = loadIconAsDataUri(bundle, COPILOT_AVATAR_PATH);
+ this.userAvatarDataUri = loadIconAsDataUri(bundle, USER_AVATAR_PATH);
+
+ // Load code block action icons from Eclipse platform bundles
+ Bundle uiBundle = org.eclipse.core.runtime.Platform.getBundle("org.eclipse.ui");
+ String copyUri = loadIconAsDataUri(uiBundle, "icons/full/etool16/copy_edit.png");
+ Bundle textEditorBundle = org.eclipse.core.runtime.Platform.getBundle(
+ UiConstants.WORKBENCH_TEXTEDITOR);
+ String insertUri = loadIconAsDataUri(textEditorBundle, UiConstants.INSERT_ICON);
+ htmlFactory.setCodeBlockIcons(copyUri, insertUri);
+
+ registerBrowserFunctions();
+
+ browser.addProgressListener(new ProgressAdapter() {
+ @Override
+ public void completed(ProgressEvent event) {
+ pageLoaded = true;
+ String theme = UiUtils.isDarkTheme() ? "dark" : "light";
+ browser.execute("window.setTheme('" + theme + "')");
+ while (!pendingScripts.isEmpty()) {
+ browser.execute(pendingScripts.poll());
+ }
+ }
+ });
+
+ 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) {
+ // 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 ? COPILOT_DISPLAY_NAME : getUserDisplayName());
+ insertHtmlBlockChild("chat-container", 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());
+ insertHtmlBlockChild("chat-container", turnHtml);
+ String contentId = ConversationHtmlBlockFactory.contentBlockId(turnId, false);
+ String blockId = contentId + "-0";
+ String blockHtml = htmlFactory.createUserRequestHtmlBlock(blockId, message);
+ insertHtmlBlockChild(contentId, blockHtml);
+ }
+
+ @Override
+ public void scrollToBottom() {
+ executeScript("window.scrollTo(0, document.body.scrollHeight)");
+ }
+
+ @Override
+ public void refreshScrollerLayout() {
+ // Browser handles its own layout; no action needed
+ }
+
+ @Override
+ public void renderErrorMessage(String content) {
+ String errorId = "error-" + System.currentTimeMillis();
+ String renderedContent = htmlFactory.renderMarkdown(content);
+ String errorHtml = htmlFactory.createErrorTurnHtmlBlock(errorId, renderedContent);
+ insertHtmlBlockChild("chat-container", 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);
+ insertHtmlBlockChild(ConversationHtmlBlockFactory.contentBlockId(turnId, true), html);
+ }
+ }
+
+ @Override
+ public void hideCompactingStatusOnLatestCopilotTurn() {
+ String turnId = lastCopilotTurnId != null ? lastCopilotTurnId : currentTurnId;
+ if (turnId != null) {
+ removeHtmlBlock(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, COPILOT_DISPLAY_NAME);
+ insertHtmlBlockChild("chat-container", 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 (CopilotCore.getPlugin().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);
+ insertHtmlBlockChild(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());
+ insertHtmlBlockChild(ConversationHtmlBlockFactory.copilotTurnContainerId(turnId), html);
+ }
+
+ @Override
+ public CompletableFuture requestToolConfirmation(
+ String turnId, ConfirmationContent content, Object input) {
+ // Cancel any existing pending confirmation
+ cancelToolConfirmation(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);
+ insertHtmlBlockBefore(
+ ConversationHtmlBlockFactory.copilotTurnContainerId(turnId), html, modelInfoId);
+ scrollToBottom();
+
+ return future;
+ }
+
+ @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);
+ executeScript("window.removeBlock && window.removeBlock('" + escapeForJs(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());
+ insertHtmlBlockChild(state.contentAreaId, blockHtml);
+ } else {
+ state.currentThinkingText.append(thinking.text());
+ updateThinkingBodyText(state.currentChildBlockId, 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)
+ collapseThinkingBlock(blockId);
+
+ // 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;
+ }
+
+ /**
+ * Collapses a thinking block by removing the {@code open} attribute from its
+ * {@code } element and removing the spinner element.
+ */
+ private void collapseThinkingBlock(String blockId) {
+ String bulbSvgJs = ConversationHtmlBlockFactory.BULB_SVG
+ .replace("\"", "\\\"").replace("'", "\\'");
+ String script = "var b=document.getElementById('" + escapeForJs(blockId) + "');"
+ + "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='" + bulbSvgJs + "';"
+ + "s.parentNode.replaceChild(icon.firstChild,s);}}";
+ executeScript(script);
+ }
+
+ /**
+ * Updates the text content of a thinking block's body and auto-scrolls to the bottom
+ * if the user hasn't scrolled up. Avoids replacing the entire block (which resets scroll).
+ */
+ private void updateThinkingBodyText(String blockId, String text) {
+ String script = "var b=document.getElementById('" + escapeForJs(blockId) + "');"
+ + "if(b){var bd=b.querySelector('.thinking-body');if(bd){"
+ + "bd.textContent='" + escapeForJs(text) + "';"
+ + "var thr=60;if(bd.scrollHeight-(bd.scrollTop+bd.clientHeight)<=thr)"
+ + "bd.scrollTop=bd.scrollHeight;}}";
+ executeScript(script);
+ }
+
+ /**
+ * Updates the summary/title text of a thinking block without replacing the entire block.
+ */
+ private void updateThinkingBlockTitle(String blockId, String title) {
+ String script = "var b=document.getElementById('" + escapeForJs(blockId) + "');"
+ + "if(b){var s=b.querySelector('summary');if(s)s.textContent='"
+ + escapeForJs(title) + "';}";
+ executeScript(script);
+ }
+
+ /**
+ * 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;
+ }
+ String[] extractedTitles = extractBoldTitles(thinkingContent);
+ boolean hasTitles = extractedTitles.length > 0;
+ var params = new GenerateThinkingTitleParams(hasTitles ? null : thinkingContent,
+ hasTitles ? extractedTitles : null);
+ ls.generateThinkingTitle(params)
+ .thenAccept(resp -> {
+ if (resp != null && StringUtils.isNotBlank(resp.title())) {
+ updateThinkingBlockTitle(blockId, resp.title());
+ persistThinkingTitle(turnId, blockId, resp.title());
+ }
+ })
+ .exceptionally(ex -> null);
+ }
+
+ private void persistThinkingTitle(String turnId, String thinkingBlockId, String title) {
+ if (conversationId == null) {
+ return;
+ }
+ var chatServiceManager = CopilotUi.getPlugin().getChatServiceManager();
+ if (chatServiceManager != null) {
+ chatServiceManager.getPersistenceManager()
+ .updateThinkingBlockTitle(conversationId, turnId, thinkingBlockId, title);
+ }
+ }
+
+ /** Extracts {@code **Title**} patterns from thinking text (same regex as ThinkingBlock). */
+ private static final Pattern TITLE_PATTERN =
+ Pattern.compile("(?:^|\\n)\\*\\*([^*\\r\\n]+?)\\*\\*(?=\\r?\\n|$)");
+
+ private static String[] extractBoldTitles(String text) {
+ Matcher matcher = TITLE_PATTERN.matcher(text);
+ List titles = new ArrayList<>();
+ while (matcher.find()) {
+ String title = matcher.group(1).trim();
+ if (!title.isEmpty()) {
+ titles.add(title);
+ }
+ }
+ return titles.toArray(String[]::new);
+ }
+
+ /**
+ * 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());
+ insertHtmlBlockChild(state.contentAreaId, blockHtml);
+ } else {
+ state.currentReplyText.append(replyChunk);
+ String blockHtml = htmlFactory.createCopilotReplyHtmlBlock(
+ state.currentChildBlockId, state.currentReplyText.toString());
+ replaceHtmlBlock(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);
+ insertHtmlBlockChild(state.contentAreaId, blockHtml);
+ } else {
+ String blockHtml = htmlFactory.createToolCallHtmlBlock(
+ state.currentChildBlockId, toolCall);
+ replaceHtmlBlock(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);
+ insertHtmlBlockChild(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;
+ insertHtmlBlockChild(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 (code != 402 || QuotaActions.isByokQuotaExceeded(code, modelProviderName)) {
+ return List.of();
+ }
+ try {
+ CheckQuotaResult quotaStatus =
+ CopilotCore.getPlugin().getAuthStatusManager().getQuotaStatus();
+ if (!quotaStatus.tokenBasedBillingEnabled()) {
+ return List.of();
+ }
+ CopilotPlan plan = quotaStatus.copilotPlan();
+ boolean overageEnabled = quotaStatus.premiumInteractions() != null
+ && quotaStatus.premiumInteractions().overagePermitted();
+ Boolean canUpgradePlan = quotaStatus.canUpgradePlan();
+ return QuotaActions.forPlan(plan, overageEnabled, canUpgradePlan);
+ } 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());
+ replaceHtmlBlock(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++);
+ insertHtmlBlockChild(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++);
+ insertHtmlBlockChild(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++);
+ insertHtmlBlockChild(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++);
+ insertHtmlBlockChild(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++);
+ insertHtmlBlockChild(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++);
+ insertHtmlBlockChild(contentId,
+ htmlFactory.createAgentMessageHtmlBlock(blockId,
+ agentMsg.getTitle(), agentMsg.getDescription(),
+ agentMsg.getPrLink()));
+ }
+ }
+ }
+ }
+
+ private void insertHtmlBlockChild(String parentId, String html) {
+ executeScript("window.insertBlock('" + escapeForJs(parentId) + "', '"
+ + escapeForJs(html) + "')");
+ }
+
+ private void insertHtmlBlockBefore(String parentId, String html, String beforeId) {
+ executeScript("window.insertBlockBefore('" + escapeForJs(parentId) + "', '"
+ + escapeForJs(html) + "', '" + escapeForJs(beforeId) + "')");
+ }
+
+
+ private void replaceHtmlBlock(String blockId, String html) {
+ executeScript("window.replaceBlock('" + escapeForJs(blockId) + "', '"
+ + escapeForJs(html) + "')");
+ }
+
+ private void removeHtmlBlock(String blockId) {
+ executeScript("window.removeBlock('" + escapeForJs(blockId) + "')");
+ }
+
+ private void showStreamingIndicator(String turnId) {
+ TurnStreamState state = stateFor(turnId);
+ String indicatorId = ConversationHtmlBlockFactory.streamingIndicatorId(turnId);
+ String html = htmlFactory.createStreamingIndicatorHtmlBlock(indicatorId);
+ insertHtmlBlockChild(state.contentAreaId, html);
+ state.streamingIndicatorVisible = true;
+ }
+
+ private void removeStreamingIndicator(String turnId) {
+ TurnStreamState state = turnStates.get(turnId);
+ if (state == null || !state.streamingIndicatorVisible) {
+ return;
+ }
+ removeHtmlBlock(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() {
+ if (userName != null && !userName.isEmpty()) {
+ return userName;
+ }
+ try {
+ return CopilotCore.getPlugin().getAuthStatusManager().getUserName();
+ } catch (Exception e) {
+ return "User";
+ }
+ }
+
+ /**
+ * Returns the user avatar URI. Uses the GitHub avatar URL if the user is signed in,
+ * otherwise falls back to the bundled default user icon.
+ */
+ private String getUserAvatarUri() {
+ try {
+ String user = CopilotCore.getPlugin().getAuthStatusManager().getUserName();
+ if (StringUtils.isNotBlank(user)) {
+ return String.format(GITHUB_AVATAR_URL, user);
+ }
+ } catch (Exception e) {
+ // Fall through to default
+ }
+ return userAvatarDataUri;
+ }
+
+ private static String loadIconAsDataUri(Bundle bundle, String path) {
+ try {
+ URL entry = bundle.getEntry(path);
+ if (entry == null) {
+ return "";
+ }
+ try (InputStream is = entry.openStream()) {
+ byte[] bytes = is.readAllBytes();
+ return "data:image/png;base64," + Base64.getEncoder().encodeToString(bytes);
+ }
+ } catch (IOException e) {
+ CopilotCore.LOGGER.error("Failed to load icon: " + path, e);
+ return "";
+ }
+ }
+
+ private void registerBrowserFunctions() {
+ new BrowserFunction(browser, "copyToClipboard") {
+ @Override
+ public Object function(Object[] arguments) {
+ if (arguments.length > 0 && arguments[0] instanceof String code) {
+ Display.getDefault().asyncExec(() -> {
+ Clipboard clipboard =
+ new Clipboard(Display.getDefault());
+ clipboard.setContents(
+ new Object[]{code},
+ new Transfer[]{
+ TextTransfer.getInstance()});
+ clipboard.dispose();
+ });
+ }
+ return null;
+ }
+ };
+
+ new BrowserFunction(browser, "insertAtCursor") {
+ @Override
+ public Object function(Object[] arguments) {
+ if (arguments.length > 0 && arguments[0] instanceof String code) {
+ Display.getDefault().asyncExec(() -> {
+ try {
+ IEditorPart editor = PlatformUI.getWorkbench()
+ .getActiveWorkbenchWindow().getActivePage().getActiveEditor();
+ if (editor instanceof ITextEditor textEditor) {
+ IDocument doc = textEditor.getDocumentProvider().getDocument(
+ textEditor.getEditorInput());
+ ITextSelection sel = (ITextSelection) textEditor
+ .getSelectionProvider().getSelection();
+ doc.replace(sel.getOffset(), sel.getLength(), code);
+ }
+ } catch (Exception e) {
+ CopilotCore.LOGGER.error("Failed to insert code at cursor", e);
+ }
+ });
+ }
+ return null;
+ }
+ };
+
+ new BrowserFunction(browser, "acceptToolAction") {
+ @Override
+ public Object function(Object[] arguments) {
+ if (arguments.length > 0 && arguments[0] instanceof Double actionIndex) {
+ resolveConfirmation(actionIndex.intValue(), true);
+ }
+ return null;
+ }
+ };
+
+ new BrowserFunction(browser, "dismissToolAction") {
+ @Override
+ public Object function(Object[] arguments) {
+ resolveConfirmation(-1, false);
+ return null;
+ }
+ };
+
+ new BrowserFunction(browser, "copilotAction") {
+ @Override
+ public Object function(Object[] arguments) {
+ if (arguments.length < 2) {
+ return null;
+ }
+ String action = String.valueOf(arguments[0]);
+ String param = String.valueOf(arguments[1]);
+ switch (action) {
+ case "openLink":
+ UiUtils.openLink(param);
+ break;
+ case "openJobList":
+ UiUtils.openE4Part(Constants.GITHUB_JOBS_VIEW_ID);
+ break;
+ default:
+ break;
+ }
+ 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);
+ }
+ });
+ }
+
+ /** 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");
+ }
+}
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..532e5af2 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
@@ -69,19 +69,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 +110,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;
@@ -297,8 +291,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 +322,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 +412,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 +430,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());
}
@@ -786,8 +782,17 @@ 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);
+ }
+ return new StyledTextConversationWidget(parent, this.chatServiceManager);
}
/**
@@ -836,9 +841,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 +860,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 +890,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 +939,20 @@ 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. This is
+ // renderer-agnostic domain logic, so it lives here rather than inside a specific widget.
+ 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 +994,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 +1236,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 +1308,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 +1330,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 +1449,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 +1501,36 @@ public String getSubagentConversationId() {
}
/**
- * Get the current chat content viewer.
+ * Returns the active conversation widget.
+ */
+ 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.
*/
- public ChatContentViewer getChatContentViewer() {
- return this.chatContentViewer;
+ 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()));
+ }
+ }
+ }
}
/**
@@ -1673,9 +1709,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 +1920,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 +1937,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);
- }
- }
+ if (turn == null || conversationWidget == null || conversationWidget.isDisposed()) {
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) {
- 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..e9e02c63
--- /dev/null
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ConversationHtmlBlockFactory.java
@@ -0,0 +1,541 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+package com.microsoft.copilot.eclipse.ui.chat;
+
+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 {
+
+ // --- SVG icon constants (shared with BrowserConversationWidget) ---
+
+ /** Lightbulb icon used for sealed (completed) thinking blocks. */
+ static final String BULB_SVG = "";
+
+ /** Pull request icon used in agent message blocks. */
+ static final String PR_SVG = "";
+
+ /** Terminal/command icon used in tool confirmation blocks. */
+ static final String TERMINAL_SVG = "";
+
+ /** Warning triangle icon used in quota warning and error blocks. */
+ static final String WARNING_SVG = "";
+
+ 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();
+ }
+
+ /** 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";
+ StringBuilder html = new StringBuilder();
+ html.append("
");
+ return html.toString();
+ }
+
+ /**
+ * 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) {
+ StringBuilder html = new StringBuilder();
+ html.append("
");
+ html.append("
");
+ if (StringUtils.isNotBlank(avatarDataUri)) {
+ html.append("");
+ }
+ html.append(escapeHtml(StringUtils.isNotBlank(title) ? title : "Subagent"))
+ .append("
");
+ html.append("");
+ html.append("
");
+ return html.toString();
+ }
+
+ /** Creates a collapsible thinking block (open by default during streaming, with spinner). */
+ public String createThinkingHtmlBlock(String blockId, String thinkingText) {
+ StringBuilder html = new StringBuilder();
+ html.append("
");
+ return html.toString();
+ }
+
+ /**
+ * 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 "
"
+ + renderedHtml + "
";
+ }
+
+ /** Creates a user request block with rendered Markdown content. */
+ public String createUserRequestHtmlBlock(String blockId, String markdownText) {
+ String renderedHtml = renderMarkdown(markdownText);
+ return "
"
+ + 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) {
+ StringBuilder html = new StringBuilder();
+ html.append("
");
+ return html.toString();
+ }
+
+ /** 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);
+ }
+
+ /**
+ * Post-processes rendered HTML to inject Copy/Insert action buttons into code blocks.
+ * Uses the platform icons loaded via {@link #setCodeBlockIcons}.
+ */
+ public String injectCodeBlockButtons(String html) {
+ String copyImg = copyIconDataUri.isEmpty() ? ""
+ : "";
+ String insertImg = insertIconDataUri.isEmpty() ? ""
+ : "";
+ String buttons = "
"
+ + ""
+ + "
";
+ return html.replace("", buttons + "");
+ }
+
+ /** 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/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/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/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/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 11a791ef..dcc3dcf7 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
@@ -51,6 +51,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 6b43717c..3ea6edba 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
@@ -39,6 +39,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 7582fd7a..3fa03091 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
@@ -60,6 +60,8 @@ public class Messages extends NLS {
public static String preferences_page_restart_question;
public static String preferences_page_sub_agent;
public static String preferences_page_sub_agent_note_content;
+ 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 f041db59..734170fe 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
@@ -99,6 +99,8 @@ preferences_page_watched_files_note_content= Allow the use of @workspace in Ask
preferences_page_restart_question=You need to restart Eclipse to apply the changes. Would you like to restart now?
preferences_page_sub_agent= Enable sub-agent
preferences_page_sub_agent_note_content= Allow Copilot to use sub-agents for complex multi-step tasks.
+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..32675ccc 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,74 @@ public static void applyChatFont(Control control) {
/**
* Returns true if Eclipse is currently using a dark theme.
*
+ *
The active e4 CSS theme is queried reflectively so that this bundle needs no compile-time
+ * dependency on the friend-restricted {@code org.eclipse.e4.ui.css.swt.theme} package (which
+ * would otherwise raise a discouraged-access warning and force a hard bundle dependency). When
+ * e4 CSS theming is unavailable — for example in a minimal RCP application without the theming
+ * bundle — the method falls back to the persisted {@code themeid} preference and finally defaults
+ * to light.
+ *
* @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;
+ Boolean activeThemeDark = activeThemeIsDark();
+ if (activeThemeDark != null) {
+ return activeThemeDark;
}
- return false;
+ return persistedThemeIsDark();
+ }
+
+ /**
+ * Reflectively resolves whether the active e4 CSS theme is a dark theme.
+ *
+ *
The theme bundle's own class loader is used to load the provisional, friend-restricted
+ * {@code IThemeEngine}/{@code ITheme} types, so this bundle keeps no compile-time reference to
+ * them and the theming dependency can stay optional. Any failure (theming bundle absent, service
+ * unavailable, or a linkage/reflection error) is treated as "cannot determine".
+ *
+ * @return {@code Boolean.TRUE} or {@code Boolean.FALSE} when the active theme could be resolved,
+ * or {@code null} when e4 CSS theming is unavailable so the caller can fall back
+ */
+ private static Boolean activeThemeIsDark() {
+ 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);
+ if (themeId == null) {
+ return null;
+ }
+ return themeId.toString().toLowerCase().contains("dark");
+ } catch (ReflectiveOperationException | RuntimeException | LinkageError e) {
+ return null;
+ }
+ }
+
+ /**
+ * Fallback theme detection based on the persisted e4 CSS {@code themeid} preference.
+ *
+ *
This is less accurate than the active-theme query (the persisted value can lag the runtime
+ * theme, e.g. when following the OS theme), but it needs no theming bundle at all and never
+ * throws, so it is a safe last resort.
+ *
+ * @return true if the persisted theme id denotes a dark theme, false otherwise (including light
+ * and unknown)
+ */
+ private static boolean persistedThemeIsDark() {
+ String themeId = Platform.getPreferencesService()
+ .getString("org.eclipse.e4.ui.css.swt.theme", "themeid", "", null);
+ return themeId.toLowerCase().contains("dark");
}
/**
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+