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));
+
+ ChatServiceManager demoServiceManager = createDemoChatServiceManager();
+
+ // --- Left: Browser-based renderer ---
+ Composite leftPane = createLabeledPane(sash,
+ "BrowserConversationWidget (HTML/CSS)");
+ BrowserConversationWidget browserWidget =
+ new BrowserConversationWidget(leftPane, demoServiceManager);
+
+ // --- Right: StyledText-based renderer ---
+ Composite rightPane = createLabeledPane(sash,
+ "StyledTextConversationWidget (SWT StyledText)");
+ 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 = "Start-Sleep -Seconds 5";
+ private static final String CONFIRM_COMMAND_NAME = "Start-Sleep";
+ private static final String CONFIRM_EXPLANATION =
+ "Pause the script for five seconds";
+
+ 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} from the
+ * running Eclipse instance (for a real user avatar) and a real {@link ChatFontService}, but
+ * returns a deterministic {@link CheckQuotaResult} so the quota-exceeded turn reliably renders its
+ * "Upgrade Plan" action button regardless of the live account's plan or billing state.
+ *
+ *
The auth manager is a {@link Mockito#spy(Object) spy} of the real one: identity-related calls
+ * (e.g. {@code getUserName()}) still return real data, while {@code getQuotaStatus()} is stubbed to
+ * a {@code free} plan with token-based billing enabled and {@code canUpgradePlan = true}, which
+ * {@link QuotaActions#forPlan} maps to a single primary "Upgrade Plan" action.
+ */
+ private static ChatServiceManager createDemoChatServiceManager() {
+ ChatServiceManager mockManager = Mockito.mock(ChatServiceManager.class);
+
+ // Spy the real AuthStatusManager so identity (avatar, user name) stays real, but override the
+ // quota status with a deterministic value so the demo's Upgrade Plan button always appears.
+ AuthStatusManager realAuth = CopilotCore.getPlugin().getAuthStatusManager();
+ AuthStatusManager spyAuth = Mockito.spy(realAuth);
+ CheckQuotaResult demoQuota = new CheckQuotaResult(
+ null, null, null, null, null, CopilotPlan.free, true, Boolean.TRUE);
+ Mockito.doReturn(demoQuota).when(spyAuth).getQuotaStatus();
+ Mockito.when(mockManager.getAuthStatusManager()).thenReturn(spyAuth);
+
+ // 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).
+ // Mirror the real run_in_terminal confirmation built by TerminalConfirmationHandler:
+ // a primary "Allow Once" plus the session/global command-name, exact-command and
+ // allow-all accept actions (rendered inside the split-button drop-down), and a
+ // separate "Skip" dismiss button. Labels/scopes match production so the demo reflects
+ // real behavior instead of a generic Allow/Deny pair.
+ String cmdLabel = "'" + CONFIRM_COMMAND_NAME + "'";
+ ConfirmationContent confirmation = new ConfirmationContent(
+ CONFIRM_TITLE, CONFIRM_MESSAGE,
+ List.of(
+ ConfirmationAction.allowOnce(Messages.confirmation_action_allowOnce),
+ demoAcceptAction(
+ NLS.bind(Messages.confirmation_action_allowNamesSession, cmdLabel),
+ ConfirmationActionScope.SESSION),
+ demoAcceptAction(
+ NLS.bind(Messages.confirmation_action_alwaysAllowNames, cmdLabel),
+ ConfirmationActionScope.GLOBAL),
+ demoAcceptAction(Messages.confirmation_action_allowExactSession,
+ ConfirmationActionScope.SESSION),
+ demoAcceptAction(Messages.confirmation_action_alwaysAllowExact,
+ ConfirmationActionScope.GLOBAL),
+ demoAcceptAction(Messages.confirmation_action_allowAllCommands,
+ ConfirmationActionScope.SESSION),
+ ConfirmationAction.skip(Messages.confirmation_action_skip)));
+ 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;
+ }
+
+ /**
+ * Builds a non-primary accept action for the demo confirmation, mirroring the
+ * session/global allow actions produced by {@code TerminalConfirmationHandler}.
+ * Only the label, accept flag and (non-)primary flag affect rendering; the scope
+ * is carried for realism.
+ */
+ private static ConfirmationAction demoAcceptAction(String label,
+ ConfirmationActionScope scope) {
+ return new ConfirmationAction(label, true, scope, null, false);
+ }
+
+ /**
+ * 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/chat/SvgIconsTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/SvgIconsTest.java
new file mode 100644
index 00000000..11c55e0f
--- /dev/null
+++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/SvgIconsTest.java
@@ -0,0 +1,140 @@
+// 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.LinkedHashMap;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.junit.jupiter.api.Test;
+
+class SvgIconsTest {
+
+ private static Map attrs(String... pairs) {
+ Map map = new LinkedHashMap<>();
+ for (int i = 0; i + 1 < pairs.length; i += 2) {
+ map.put(pairs[i], pairs[i + 1]);
+ }
+ return map;
+ }
+
+ @Test
+ void applyRootAttributes_injectsMissingAttributes() {
+ String svg = "";
+ String result = SvgIcons.applyRootAttributes(svg,
+ attrs("class", "thinking-icon", "width", "14", "height", "14", "fill", "none"));
+
+ assertTrue(result.contains("class=\"thinking-icon\""), result);
+ assertTrue(result.contains("width=\"14\""), result);
+ assertTrue(result.contains("height=\"14\""), result);
+ assertTrue(result.contains("fill=\"none\""), result);
+ }
+
+ @Test
+ void applyRootAttributes_overridesExistingAttribute() {
+ String svg = "";
+ String result = SvgIcons.applyRootAttributes(svg, attrs("fill", "currentColor"));
+
+ assertTrue(result.contains("fill=\"currentColor\""), result);
+ assertFalse(result.contains("#000000"), result);
+ }
+
+ @Test
+ void applyRootAttributes_stripsXmlnsDeclaration() {
+ String svg = "";
+ String result = SvgIcons.applyRootAttributes(svg, attrs("width", "14"));
+
+ assertFalse(result.contains("xmlns"), result);
+ }
+
+ @Test
+ void applyRootAttributes_stripsXmlProlog() {
+ String svg = "\n";
+ String result = SvgIcons.applyRootAttributes(svg, attrs("width", "14"));
+
+ assertTrue(result.startsWith("";
+ String result = SvgIcons.applyRootAttributes(svg, attrs("width", "14"));
+
+ assertTrue(result.contains("viewBox=\"0 0 24 24\""), result);
+ assertTrue(result.contains(""), result);
+ assertTrue(result.contains(""), result);
+ assertTrue(result.endsWith(""), result);
+ }
+
+ @Test
+ void applyRootAttributes_nullInput_returnsEmptyString() {
+ assertEquals("", SvgIcons.applyRootAttributes(null, attrs("width", "14")));
+ }
+
+ @Test
+ void applyRootAttributes_nonSvgInput_returnedUnchanged() {
+ String notSvg = "just some text";
+ assertEquals(notSvg, SvgIcons.applyRootAttributes(notSvg, attrs("width", "14")));
+ }
+
+ @Test
+ void applyRootAttributes_style_addedWhenMissing() {
+ String svg = "";
+ String result = SvgIcons.applyRootAttributes(svg,
+ attrs("style", "vertical-align: middle; margin-right: 4px;"));
+
+ assertTrue(result.contains("style=\"vertical-align: middle; margin-right: 4px;\""), result);
+ }
+
+ @Test
+ void applyRootAttributes_style_addsMissingSubPropertiesKeepingExisting() {
+ String svg = "";
+ String result = SvgIcons.applyRootAttributes(svg,
+ attrs("style", "vertical-align: middle; margin-right: 4px;"));
+
+ String style = styleOf(result);
+ assertEquals("0.5", declaration(style, "opacity"));
+ assertEquals("block", declaration(style, "display"));
+ assertEquals("middle", declaration(style, "vertical-align"));
+ assertEquals("4px", declaration(style, "margin-right"));
+ }
+
+ @Test
+ void applyRootAttributes_style_overridesExistingSubPropertiesKeepingOthers() {
+ String svg = "";
+ String result = SvgIcons.applyRootAttributes(svg,
+ attrs("style", "vertical-align: middle; margin-right: 4px;"));
+
+ String style = styleOf(result);
+ assertEquals("middle", declaration(style, "vertical-align"));
+ assertEquals("0.5", declaration(style, "opacity"));
+ assertEquals("4px", declaration(style, "margin-right"));
+ }
+
+ private static String styleOf(String svg) {
+ Matcher matcher = Pattern.compile("style=\"([^\"]*)\"").matcher(svg);
+ assertTrue(matcher.find(), "no style attribute in: " + svg);
+ return matcher.group(1);
+ }
+
+ private static String declaration(String style, String property) {
+ for (String declaration : style.split(";")) {
+ int colon = declaration.indexOf(':');
+ if (colon >= 0 && declaration.substring(0, colon).trim().equals(property)) {
+ return declaration.substring(colon + 1).trim();
+ }
+ }
+ return null;
+ }
+}
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..c4d13014
--- /dev/null
+++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/UiUtilsThemeTest.java
@@ -0,0 +1,209 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+package com.microsoft.copilot.eclipse.ui.utils;
+
+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 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.e4.ui.css.swt.theme.ITheme;
+import org.eclipse.e4.ui.css.swt.theme.IThemeEngine;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.graphics.RGB;
+import org.eclipse.swt.widgets.Display;
+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()} and {@link UiUtils#isDark(RGB)}.
+ *
+ *
The owning widget keeps all SWT widget lifecycle: it creates and disposes the {@link Browser}
+ * and registers the {@code ProgressListener}, then passes the browser to this bridge. The bridge
+ * owns the page-load gating state (whether the page has loaded plus the queues of scripts and
+ * result-producing tasks deferred until it has); the widget's load listener calls
+ * {@link #notifyPageLoaded()} to flip the flag and flush those queues.
+ *
+ *
All domain reactions to JavaScript callbacks are delegated to {@link JavaCallbacks} so this
+ * bridge stays free of clipboard, editor, workbench and logging dependencies.
+ */
+public class BrowserConversationJavaJsBridge {
+
+ /**
+ * Java call-back functions invoked by browser-side JavaScript. Implemented by the owning widget
+ * so all domain behavior stays there. Each method corresponds to one registered
+ * {@link BrowserFunction}. This does not cover the {@link #executeScriptForResult} result path,
+ * which uses a per-call {@link Consumer}.
+ */
+ public interface JavaCallbacks {
+
+ /** Copies {@code code} to the system clipboard. */
+ void copyToClipboard(String code);
+
+ /** Inserts {@code code} at the cursor of the active text editor. */
+ void insertAtCursor(String code);
+
+ /** Accepts the pending tool confirmation with the given action index. */
+ void acceptToolAction(int actionIndex);
+
+ /** Dismisses the pending tool confirmation. */
+ void dismissToolAction();
+
+ /** Handles a generic copilot action (e.g. {@code openLink}, {@code openJobList}). */
+ void copilotAction(String action, String param);
+
+ /** Routes a browser-side (JavaScript) error message to the Eclipse error log. */
+ void logError(String message);
+ }
+
+ private final Browser browser;
+ private final JavaCallbacks callbacks;
+
+ private boolean pageLoaded;
+ private final Queue pendingScripts = new LinkedList<>();
+ /**
+ * Result-producing evaluations deferred until the page has loaded and {@link #pendingScripts}
+ * have flushed. Used so that operations needing a real return value (e.g. verifying a tool
+ * confirmation card was inserted) reflect the actual post-load DOM instead of an optimistic
+ * assumption made while the page was still loading.
+ */
+ private final Queue pendingResultTasks = new LinkedList<>();
+
+ /**
+ * Creates the bridge over an already-created {@link Browser} and registers the Java call-back
+ * functions. The browser's lifecycle (creation, layout, disposal and load-listener registration)
+ * remains the responsibility of the owning widget.
+ *
+ * @param browser the chat browser to drive (created and owned by the widget)
+ * @param callbacks the domain callbacks invoked by browser-side JavaScript
+ */
+ public BrowserConversationJavaJsBridge(Browser browser, JavaCallbacks callbacks) {
+ this.browser = browser;
+ this.callbacks = callbacks;
+ registerBrowserFunctions();
+ }
+
+ /**
+ * Marks the page as loaded and flushes any scripts and result-producing tasks queued while it was
+ * still loading. Must be called on the UI thread from the widget's page-load listener.
+ */
+ public void notifyPageLoaded() {
+ pageLoaded = true;
+ while (!pendingScripts.isEmpty()) {
+ browser.execute(pendingScripts.poll());
+ }
+ // Run deferred result-producing evaluations only after the DOM built by the queued scripts
+ // above exists, so their outcome reflects the real post-load DOM.
+ while (!pendingResultTasks.isEmpty()) {
+ pendingResultTasks.poll().run();
+ }
+ }
+
+ /** Applies the light/dark theme by invoking the {@code window.setTheme} helper. */
+ public void setDarkTheme(boolean dark) {
+ browser.execute("window.setTheme('" + (dark ? "dark" : "light") + "')");
+ }
+
+ /** Scrolls the chat viewport to the bottom and re-arms auto-scroll (see chat-view.html). */
+ public void scrollToBottom() {
+ executeScript("window.forceScrollToBottom()");
+ }
+
+ /** Appends {@code html} as the last child of the element with {@code parentId}. */
+ public void insertBlock(String parentId, String html) {
+ executeScript(insertBlockScript(parentId, html));
+ }
+
+ /**
+ * Inserts {@code html} before the sibling {@code beforeId} within {@code parentId} and reports
+ * whether the DOM insertion actually happened to {@code onResult} (always on the UI thread).
+ */
+ public void insertBlockBefore(String parentId, String html, String beforeId,
+ Consumer onResult) {
+ executeScriptForResult(insertBlockBeforeScript(parentId, html, beforeId), onResult);
+ }
+
+ /** Replaces the element {@code blockId} with {@code html}. */
+ public void replaceBlock(String blockId, String html) {
+ executeScript(replaceBlockScript(blockId, html));
+ }
+
+ /** Removes the element with {@code blockId}. */
+ public void removeBlock(String blockId) {
+ executeScript(removeBlockScript(blockId));
+ }
+
+ /**
+ * Collapses a thinking block by removing the {@code open} attribute from its {@code }
+ * element and replacing the spinner with the given bulb SVG icon.
+ */
+ public void collapseThinkingBlock(String blockId, String bulbSvg) {
+ String bulbSvgJs = bulbSvg.replace("\"", "\\\"").replace("'", "\\'");
+ String script = """
+ var b=document.getElementById('%s');\
+ if(b){var d=b.querySelector('details');if(d)d.removeAttribute('open');\
+ var s=b.querySelector('.thinking-spinner');\
+ if(s){var icon=document.createElement('span');\
+ icon.innerHTML='%s';\
+ s.parentNode.replaceChild(icon.firstChild,s);}}"""
+ .formatted(escapeForJs(blockId), bulbSvgJs);
+ executeScript(script);
+ }
+
+ /**
+ * Updates the rendered (Markdown -> HTML) body of a thinking block and auto-scrolls to the
+ * bottom if the user hasn't scrolled up. Avoids replacing the entire block (which resets scroll).
+ * The caller passes pre-rendered HTML so live streaming matches the sealed/restored rendering.
+ */
+ public void updateThinkingBodyText(String blockId, String bodyHtml) {
+ String script = """
+ var b=document.getElementById('%s');\
+ if(b){var bd=b.querySelector('.thinking-body');if(bd){\
+ bd.innerHTML='%s';\
+ var thr=60;if(bd.scrollHeight-(bd.scrollTop+bd.clientHeight)<=thr)\
+ bd.scrollTop=bd.scrollHeight;}}"""
+ .formatted(escapeForJs(blockId), escapeForJs(bodyHtml));
+ executeScript(script);
+ }
+
+ /**
+ * Updates the summary/title of a thinking block without replacing the entire block. The caller
+ * passes pre-rendered inline HTML (Markdown -> HTML) so bold/inline formatting is honored.
+ */
+ public void updateThinkingBlockTitle(String blockId, String titleHtml) {
+ String script = """
+ var b=document.getElementById('%s');\
+ if(b){var s=b.querySelector('summary');if(s)s.innerHTML='%s';}"""
+ .formatted(escapeForJs(blockId), escapeForJs(titleHtml));
+ executeScript(script);
+ }
+
+ private void registerBrowserFunctions() {
+ new BrowserFunction(browser, "copyToClipboard") {
+ @Override
+ public Object function(Object[] arguments) {
+ if (arguments.length > 0 && arguments[0] instanceof String code) {
+ callbacks.copyToClipboard(code);
+ }
+ return null;
+ }
+ };
+
+ new BrowserFunction(browser, "insertAtCursor") {
+ @Override
+ public Object function(Object[] arguments) {
+ if (arguments.length > 0 && arguments[0] instanceof String code) {
+ callbacks.insertAtCursor(code);
+ }
+ return null;
+ }
+ };
+
+ new BrowserFunction(browser, "acceptToolAction") {
+ @Override
+ public Object function(Object[] arguments) {
+ if (arguments.length > 0 && arguments[0] instanceof Double actionIndex) {
+ callbacks.acceptToolAction(actionIndex.intValue());
+ }
+ return null;
+ }
+ };
+
+ new BrowserFunction(browser, "dismissToolAction") {
+ @Override
+ public Object function(Object[] arguments) {
+ callbacks.dismissToolAction();
+ return null;
+ }
+ };
+
+ new BrowserFunction(browser, "copilotAction") {
+ @Override
+ public Object function(Object[] arguments) {
+ if (arguments.length < 2) {
+ return null;
+ }
+ callbacks.copilotAction(String.valueOf(arguments[0]), String.valueOf(arguments[1]));
+ return null;
+ }
+ };
+
+ // Routes browser-side (JavaScript) error diagnostics to the Eclipse error log so they are
+ // visible to the user; the browser console is not surfaced anywhere in the IDE.
+ new BrowserFunction(browser, "copilotLogError") {
+ @Override
+ public Object function(Object[] arguments) {
+ if (arguments.length > 0 && arguments[0] != null) {
+ callbacks.logError(String.valueOf(arguments[0]));
+ }
+ return null;
+ }
+ };
+ }
+
+ private void executeScript(String script) {
+ if (browser.isDisposed()) {
+ return;
+ }
+ Display.getDefault().asyncExec(() -> {
+ if (browser.isDisposed()) {
+ return;
+ }
+ if (pageLoaded) {
+ browser.execute(script);
+ } else {
+ pendingScripts.add(script);
+ }
+ });
+ }
+
+ /**
+ * Evaluates a JavaScript expression and reports whether the call was successful. The
+ * {@code expression} must evaluate to a boolean indicating success (e.g. a call to a
+ * {@code window.*} helper that returns {@code true}/{@code false}). The {@code onResult} callback
+ * is always invoked on the UI thread. Evaluation is deferred behind any previously queued scripts
+ * to preserve ordering. While the page is still loading, both the execution and the result
+ * capture are deferred until after the initial load completes and {@link #pendingScripts} have
+ * flushed, so the reported outcome reflects the real post-load DOM rather than an optimistic
+ * assumption. This lets callers such as the tool-confirmation flow reliably detect a genuine
+ * insertion failure (and fall back to dismiss) without ever guessing success.
+ */
+ private void executeScriptForResult(String expression, Consumer onResult) {
+ if (browser.isDisposed()) {
+ onResult.accept(false);
+ return;
+ }
+ Display.getDefault().asyncExec(() -> {
+ if (browser.isDisposed()) {
+ onResult.accept(false);
+ return;
+ }
+ if (!pageLoaded) {
+ // Defer both the execution and its result capture until the page has loaded and queued
+ // scripts have flushed, so success/failure reflects the real DOM instead of a guess.
+ pendingResultTasks.add(() -> onResult.accept(evaluateForBoolean(expression)));
+ return;
+ }
+ onResult.accept(evaluateForBoolean(expression));
+ });
+ }
+
+ /**
+ * Executes {@code expression} via {@link Browser#evaluate(String)} and returns whether it
+ * evaluated to boolean {@code true}. Must be called on the UI thread with the page loaded. Any
+ * evaluation failure is logged and reported as {@code false}.
+ */
+ private boolean evaluateForBoolean(String expression) {
+ try {
+ Object result = browser.evaluate("return " + expression + ";");
+ return Boolean.TRUE.equals(result);
+ } catch (RuntimeException e) {
+ CopilotCore.LOGGER.error("Failed to evaluate browser script: " + expression, e);
+ return false;
+ }
+ }
+
+ /** Escapes a string for safe embedding in a single-quoted JavaScript string literal. */
+ public static String escapeForJs(String text) {
+ if (text == null) {
+ return "";
+ }
+ return text.replace("\\", "\\\\")
+ .replace("'", "\\'")
+ .replace("\n", "\\n")
+ .replace("\r", "\\r")
+ .replace("\t", "\\t");
+ }
+
+ /**
+ * Builds the JavaScript call that appends {@code html} as the last child of the element with
+ * {@code parentId}. The invoked helper returns whether the insertion was performed.
+ */
+ public static String insertBlockScript(String parentId, String html) {
+ return "window.insertBlock('" + escapeForJs(parentId) + "', '" + escapeForJs(html) + "')";
+ }
+
+ /**
+ * Builds the JavaScript call that inserts {@code html} before the sibling {@code beforeId} within
+ * the element with {@code parentId}. The invoked helper returns whether the insertion was done.
+ */
+ public static String insertBlockBeforeScript(String parentId, String html, String beforeId) {
+ return "window.insertBlockBefore('" + escapeForJs(parentId) + "', '"
+ + escapeForJs(html) + "', '" + escapeForJs(beforeId) + "')";
+ }
+
+ /** Builds the JavaScript call that replaces the element {@code blockId} with {@code html}. */
+ public static String replaceBlockScript(String blockId, String html) {
+ return "window.replaceBlock('" + escapeForJs(blockId) + "', '" + escapeForJs(html) + "')";
+ }
+
+ /** Builds the JavaScript call that removes the element with {@code blockId}. */
+ public static String removeBlockScript(String blockId) {
+ return "window.removeBlock('" + escapeForJs(blockId) + "')";
+ }
+}
diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidget.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidget.java
new file mode 100644
index 00000000..d562c7d7
--- /dev/null
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidget.java
@@ -0,0 +1,1089 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+package com.microsoft.copilot.eclipse.ui.chat;
+
+import java.net.URL;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+
+import org.apache.commons.lang3.StringUtils;
+import org.eclipse.core.runtime.FileLocator;
+import org.eclipse.jface.dialogs.MessageDialog;
+import org.eclipse.jface.text.BadLocationException;
+import org.eclipse.jface.text.IDocument;
+import org.eclipse.jface.text.ITextSelection;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.browser.Browser;
+import org.eclipse.swt.browser.ProgressAdapter;
+import org.eclipse.swt.browser.ProgressEvent;
+import org.eclipse.swt.dnd.Clipboard;
+import org.eclipse.swt.dnd.TextTransfer;
+import org.eclipse.swt.dnd.Transfer;
+import org.eclipse.swt.layout.GridData;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Display;
+import org.eclipse.ui.IEditorPart;
+import org.eclipse.ui.texteditor.IDocumentProvider;
+import org.eclipse.ui.texteditor.ITextEditor;
+import org.osgi.framework.Bundle;
+
+import com.microsoft.copilot.eclipse.core.Constants;
+import com.microsoft.copilot.eclipse.core.CopilotCore;
+import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction;
+import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent;
+import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentRound;
+import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentToolCall;
+import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatProgressValue;
+import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolConfirmationResult;
+import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolConfirmationResult.ToolConfirmationResult;
+import com.microsoft.copilot.eclipse.core.lsp.protocol.Thinking;
+import com.microsoft.copilot.eclipse.core.lsp.protocol.codingagent.CodingAgentMessageRequestParams;
+import com.microsoft.copilot.eclipse.core.persistence.AbstractTurnData;
+import com.microsoft.copilot.eclipse.core.persistence.ConversationDataFactory;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.AgentMessageData;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.EditAgentRoundData;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ErrorData;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ErrorMessageData;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ReplyData;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ThinkingBlockData;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ToolCallData;
+import com.microsoft.copilot.eclipse.core.persistence.UserTurnData;
+import com.microsoft.copilot.eclipse.core.utils.BundleUtils;
+import com.microsoft.copilot.eclipse.ui.CopilotUi;
+import com.microsoft.copilot.eclipse.ui.UiConstants;
+import com.microsoft.copilot.eclipse.ui.chat.QuotaActions.QuotaAction;
+import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager;
+import com.microsoft.copilot.eclipse.ui.utils.SwtUtils;
+import com.microsoft.copilot.eclipse.ui.utils.UiUtils;
+
+/**
+ * {@link IConversationWidget} implementation that renders copilot chat turns in an
+ * Eclipse-internal web {@link Browser} widget using an HTML/JavaScript frontend.
+ *
+ *
This widget creates, updates, and removes HTML code blocks wrapped in DIV
+ * blocks with unique IDs. It delegates HTML code generation to {@link ConversationHtmlBlockFactory}
+ * and the commonmark Java library for rendering Markdown code,
+ * including GitHub-flavoured Markdown tables.
+ * The {@link Browser} receives the HTML code based on an HTML template (chat-view.html)
+ * and offers a simple API for adding, replacing, or removing HTML div blocks with a certain ID.
+ *
+ *
HTML block ID scheme: turn containers use the server-assigned turn ID.
+ * Child blocks within a turn use {@code turnId-N} where N is a sequential counter.
+ * The CSS class on each child block identifies its type
+ * (thinking-block, tool-call, response, etc.).
+ */
+public class BrowserConversationWidget
+ implements IConversationWidget, BrowserConversationJavaJsBridge.JavaCallbacks {
+
+ private 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;
+ /**
+ * Chat services used for quota resolution (and token-based-billing detection). Injected so the
+ * browser renderer resolves quota from the same source as the StyledText renderer instead of the
+ * global singleton, which keeps the two renderers consistent and makes quota behavior testable.
+ */
+ private final ChatServiceManager serviceManager;
+
+ // Turn streaming state (per-turn, keyed by turnId).
+ private final Map turnStates = new HashMap<>();
+ private String currentTurnId;
+ private String lastCopilotTurnId;
+ private String conversationId;
+
+ // Subagent nesting (single level, mirrors SWT BaseTurnWidget). Set while the parent's
+ // run_subagent tool call is running; consumed by the subagent's beginTurn.
+ private String activeSubagentContentAreaId;
+ // Maps a run_subagent tool call id to its nested content-area id (used on restore).
+ private final Map subagentContentAreaByToolCallId = new HashMap<>();
+
+ // Tool confirmation state
+ private CompletableFuture pendingConfirmationFuture;
+ private String pendingConfirmationTurnId;
+ private List pendingConfirmationActions;
+ private ConfirmationAction lastSelectedAction;
+
+ /** Java↔JavaScript bridge: creates/runs JS and registers Java call-back functions. */
+ private final BrowserConversationJavaJsBridge bridge;
+ private String copilotAvatarDataUri;
+ 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
+ * @param serviceManager the chat services used to resolve quota status; may be {@code null}, in
+ * which case quota-driven action buttons are simply not rendered
+ */
+ public BrowserConversationWidget(Composite parent, ChatServiceManager serviceManager) {
+ this.serviceManager = serviceManager;
+ 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);
+
+ bridge = new BrowserConversationJavaJsBridge(browser, this);
+
+ browser.addProgressListener(new ProgressAdapter() {
+ @Override
+ public void completed(ProgressEvent event) {
+ bridge.setDarkTheme(UiUtils.isDarkTheme());
+ bridge.notifyPageLoaded();
+ }
+ });
+
+ try {
+ URL fileUrl = bundle.getEntry("resources/html/chat-view.html");
+ URL resolvedUrl = FileLocator.toFileURL(fileUrl);
+ browser.setUrl(resolvedUrl.toExternalForm());
+ } catch (Exception e) {
+ CopilotCore.LOGGER.error("Failed to load chat-view.html in browser widget", e);
+ }
+ }
+
+ @Override
+ public Control getControl() {
+ return browser;
+ }
+
+ @Override
+ public boolean isDisposed() {
+ return browser.isDisposed();
+ }
+
+ @Override
+ public void dispose() {
+ if (!browser.isDisposed()) {
+ browser.dispose();
+ }
+ }
+
+ @Override
+ public void requestLayout() {
+ browser.requestLayout();
+ }
+
+ @Override
+ public void setConversationId(String conversationId) {
+ this.conversationId = conversationId;
+ }
+
+ @Override
+ public void beginTurn(String turnId, boolean isCopilot, boolean isHistory) {
+ // Idempotency guard: the turn container may already have been created, e.g. by
+ // ensureCopilotTurnContainer() when a tool confirmation arrived before this begin event.
+ // Re-creating it would insert a duplicate container into the DOM.
+ if (turnStates.containsKey(turnId)) {
+ currentTurnId = turnId;
+ if (isCopilot) {
+ lastCopilotTurnId = turnId;
+ }
+ return;
+ }
+ // Subagent begin: nest inside the parent's active subagent block instead of creating a
+ // top-level turn container. Its child blocks are routed to the nested content area.
+ if (isCopilot && activeSubagentContentAreaId != null) {
+ currentTurnId = turnId;
+ turnStates.put(turnId, new TurnStreamState(activeSubagentContentAreaId));
+ return;
+ }
+ currentTurnId = turnId;
+ if (isCopilot) {
+ lastCopilotTurnId = turnId;
+ }
+ turnStates.put(turnId,
+ new TurnStreamState(ConversationHtmlBlockFactory.contentBlockId(turnId, isCopilot)));
+ String turnHtml = htmlFactory.createTurnContainerHtmlBlock(
+ turnId, isCopilot,
+ isCopilot ? copilotAvatarDataUri : getUserAvatarUri(),
+ isCopilot ? COPILOT_DISPLAY_NAME : getUserDisplayName());
+ bridge.insertBlock(ConversationHtmlBlockFactory.CHAT_CONTAINER_ID, turnHtml);
+ if (isCopilot && !isHistory) {
+ showStreamingIndicator(turnId);
+ }
+ }
+
+ @Override
+ public void processTurnEvent(ChatProgressValue value) {
+ String turnId = value.getTurnId();
+
+ switch (value.getKind()) {
+ case report -> {
+ currentTurnId = turnId;
+ ensureTurnState(turnId, value);
+ removeStreamingIndicator(turnId);
+ processThinking(turnId, value);
+ processAgentRounds(turnId, value);
+ processReply(turnId, extractReplyChunk(value));
+ }
+ case end -> {
+ removeStreamingIndicator(turnId);
+ finalizeTurn(turnId);
+ }
+ default -> {
+ // begin is handled by beginTurn(), called separately by ChatView
+ }
+ }
+
+ // Handle error messages (can arrive on any event kind, including report and end)
+ String errorMessage = ChatErrorMessages.resolveDisplayMessage(value);
+ if (StringUtils.isNotEmpty(errorMessage)) {
+ processErrorMessage(turnId, errorMessage, value.getCode(), value.getErrorModelProviderName());
+ }
+ }
+
+ @Override
+ public void startNewUserTurn(String turnId, String message) {
+ String turnHtml = htmlFactory.createTurnContainerHtmlBlock(
+ turnId, false, getUserAvatarUri(), getUserDisplayName());
+ bridge.insertBlock(ConversationHtmlBlockFactory.CHAT_CONTAINER_ID, turnHtml);
+ String contentId = ConversationHtmlBlockFactory.contentBlockId(turnId, false);
+ String blockId = contentId + "-0";
+ String blockHtml = htmlFactory.createUserRequestHtmlBlock(blockId, message);
+ bridge.insertBlock(contentId, blockHtml);
+ // Re-arm auto-scroll for the new turn and jump to the bottom so the user turn is visible and
+ // the streamed reply is followed. insertBlock's own scroll respects the auto-scroll flag,
+ // which the user may have turned off by scrolling up during the previous turn.
+ scrollToBottom();
+ }
+
+ @Override
+ public void scrollToBottom() {
+ bridge.scrollToBottom();
+ }
+
+ @Override
+ public void refreshScrollerLayout() {
+ // Browser handles its own layout; no action needed
+ }
+
+ @Override
+ public void renderErrorMessage(String content) {
+ String errorId = ConversationHtmlBlockFactory.errorBlockId();
+ String renderedContent = htmlFactory.renderMarkdown(content);
+ String errorHtml = htmlFactory.createErrorTurnHtmlBlock(errorId, renderedContent);
+ bridge.insertBlock(ConversationHtmlBlockFactory.CHAT_CONTAINER_ID, errorHtml);
+ // Scroll the newly inserted error banner into view so the user notices it, mirroring the
+ // SWT renderer's showControl-based scroll for error banners.
+ scrollToBottom();
+ }
+
+ @Override
+ public void showCompactingStatusOnLatestCopilotTurn() {
+ String turnId = lastCopilotTurnId != null ? lastCopilotTurnId : currentTurnId;
+ if (turnId != null) {
+ String blockId = ConversationHtmlBlockFactory.compactingBlockId(turnId);
+ String html = htmlFactory.createCompactingStatusHtmlBlock(blockId);
+ bridge.insertBlock(ConversationHtmlBlockFactory.contentBlockId(turnId, true), html);
+ }
+ }
+
+ @Override
+ public void hideCompactingStatusOnLatestCopilotTurn() {
+ String turnId = lastCopilotTurnId != null ? lastCopilotTurnId : currentTurnId;
+ if (turnId != null) {
+ bridge.removeBlock(ConversationHtmlBlockFactory.compactingBlockId(turnId));
+ }
+ }
+
+ @Override
+ public String getActiveThinkingBlockId(String turnId) {
+ TurnStreamState state = turnId != null ? turnStates.get(turnId) : null;
+ return state != null ? state.currentThinkingBlockId : null;
+ }
+
+ @Override
+ public void restoreTurn(AbstractTurnData turn, ConversationDataFactory dataFactory) {
+ if (turn == null) {
+ return;
+ }
+ // Subagent turn: render nested inside the parent turn's subagent block.
+ if (turn instanceof CopilotTurnData copilotTurn
+ && StringUtils.isNotBlank(copilotTurn.getParentTurnId())) {
+ restoreSubagentTurn(copilotTurn);
+ return;
+ }
+ if (turn instanceof UserTurnData userTurn) {
+ if (userTurn.getMessage() != null
+ && StringUtils.isNotBlank(userTurn.getMessage().getText())) {
+ startNewUserTurn(turn.getTurnId(), userTurn.getMessage().getText());
+ }
+ return;
+ }
+ if (turn instanceof CopilotTurnData copilotTurn) {
+ String turnId = turn.getTurnId();
+ lastCopilotTurnId = turnId;
+ String turnHtml = htmlFactory.createTurnContainerHtmlBlock(
+ turnId, true, copilotAvatarDataUri, COPILOT_DISPLAY_NAME);
+ bridge.insertBlock(ConversationHtmlBlockFactory.CHAT_CONTAINER_ID, turnHtml);
+ restoreCopilotTurnContent(turnId,
+ ConversationHtmlBlockFactory.contentBlockId(turnId, true), copilotTurn);
+ ReplyData replyData = copilotTurn.getReply();
+ if (replyData != null && StringUtils.isNotBlank(replyData.getModelName())) {
+ renderModelInfo(turnId, replyData.getModelName(),
+ replyData.getBillingMultiplier(), replyData.getReasoningEffort());
+ }
+ }
+ }
+
+ /**
+ * Restores a subagent turn into the nested subagent block that was opened while restoring the
+ * parent turn's {@code run_subagent} tool call. Falls back to the parent's content area when the
+ * subagent tool-call id is missing (mirrors the SWT blank-toolCallId path).
+ */
+ private void restoreSubagentTurn(CopilotTurnData copilotTurn) {
+ String toolCallId = copilotTurn.getSubagentToolCallId();
+ String contentAreaId = StringUtils.isNotBlank(toolCallId)
+ ? subagentContentAreaByToolCallId.get(toolCallId) : null;
+ if (contentAreaId == null) {
+ contentAreaId =
+ ConversationHtmlBlockFactory.contentBlockId(copilotTurn.getParentTurnId(), true);
+ }
+ restoreCopilotTurnContent(copilotTurn.getTurnId(), contentAreaId, copilotTurn);
+ }
+
+ @Override
+ public void renderModelInfo(String turnId, String modelName, double billingMultiplier,
+ String reasoningEffort) {
+ // When token-based billing is enabled, the per-turn billing multiplier is no longer
+ // a meaningful price signal — hide it, matching StyledText widget behavior.
+ double effectiveMultiplier = billingMultiplier;
+ try {
+ if (serviceManager != null && serviceManager.getAuthStatusManager()
+ .getQuotaStatus().tokenBasedBillingEnabled()) {
+ effectiveMultiplier = 0;
+ }
+ } catch (Exception e) {
+ // Defensive: if quota status unavailable, fall back to showing multiplier
+ }
+ String blockId = ConversationHtmlBlockFactory.modelInfoBlockId(turnId);
+ String html = htmlFactory.createModelInfoHtmlBlock(
+ blockId, modelName, effectiveMultiplier, reasoningEffort);
+ bridge.insertBlock(ConversationHtmlBlockFactory.copilotTurnContainerId(turnId), html);
+ }
+
+ @Override
+ public void renderAgentMessage(CodingAgentMessageRequestParams params) {
+ if (params == null) {
+ return;
+ }
+ String turnId = params.getTurnId();
+ if (StringUtils.isBlank(turnId)) {
+ return;
+ }
+ String blockId = ConversationHtmlBlockFactory.agentMessageBlockId(turnId);
+ String html = htmlFactory.createAgentMessageHtmlBlock(
+ blockId, params.getTitle(), params.getDescription(), params.getPrLink());
+ bridge.insertBlock(ConversationHtmlBlockFactory.copilotTurnContainerId(turnId), html);
+ }
+
+ @Override
+ public CompletableFuture requestToolConfirmation(
+ String turnId, ConfirmationContent content, Object input) {
+ // Cancel any existing pending confirmation
+ cancelToolConfirmation(turnId);
+
+ // Safety net: without actionable buttons the card can never be resolved by the user, so
+ // returning a pending future would stall the agent indefinitely. Dismiss immediately instead.
+ if (content == null || content.getActions() == null || content.getActions().isEmpty()) {
+ return CompletableFuture.completedFuture(
+ new LanguageModelToolConfirmationResult(ToolConfirmationResult.DISMISS));
+ }
+
+ // The confirmation request may arrive before the turn's begin event (e.g. when the terminal
+ // call is the turn's first action), so the copilot turn container may not exist yet. Ensure it
+ // exists before inserting the card; otherwise the insertion silently no-ops and the agent hangs.
+ ensureCopilotTurnContainer(turnId);
+
+ CompletableFuture future = new CompletableFuture<>();
+ pendingConfirmationFuture = future;
+ pendingConfirmationTurnId = turnId;
+ pendingConfirmationActions = content.getActions();
+ lastSelectedAction = null;
+
+ String blockId = ConversationHtmlBlockFactory.confirmationBlockId(turnId);
+ String html = htmlFactory.createConfirmationHtmlBlock(blockId, content, input);
+ // Insert before the model-info block so confirmation appears above it
+ String modelInfoId = ConversationHtmlBlockFactory.modelInfoBlockId(turnId);
+ insertConfirmationBlock(turnId, html, modelInfoId, future);
+ scrollToBottom();
+
+ return future;
+ }
+
+ /**
+ * Creates the copilot turn DOM container for {@code turnId} if {@link #beginTurn} has not run for
+ * it yet. Delegates to {@code beginTurn}, which is idempotent, so a subsequent real begin event
+ * for the same turn will not create a duplicate container.
+ */
+ private void ensureCopilotTurnContainer(String turnId) {
+ if (turnStates.containsKey(turnId)) {
+ return;
+ }
+ beginTurn(turnId, true, false);
+ }
+
+ /**
+ * Inserts the confirmation card and verifies the DOM insertion actually happened. If the insertion
+ * could not be performed (e.g. the target container is missing), the pending confirmation is
+ * dismissed as a fallback so the agent never stalls waiting on a card that was never rendered.
+ */
+ private void insertConfirmationBlock(String turnId, String html, String beforeId,
+ CompletableFuture future) {
+ bridge.insertBlockBefore(ConversationHtmlBlockFactory.copilotTurnContainerId(turnId), html,
+ beforeId, success -> {
+ if (!success && future == pendingConfirmationFuture && !future.isDone()) {
+ CopilotCore.LOGGER.error(new IllegalStateException(
+ "Failed to insert tool confirmation card for turn " + turnId
+ + "; dismissing to avoid a stall"));
+ resolveConfirmation(-1, false);
+ }
+ });
+ }
+
+ @Override
+ public void cancelToolConfirmation(String turnId) {
+ if (pendingConfirmationFuture != null && !pendingConfirmationFuture.isDone()) {
+ pendingConfirmationFuture.complete(
+ new LanguageModelToolConfirmationResult(ToolConfirmationResult.DISMISS));
+ }
+ pendingConfirmationFuture = null;
+ pendingConfirmationActions = null;
+ pendingConfirmationTurnId = null;
+ removeConfirmationBlock(turnId);
+ }
+
+ @Override
+ public ConfirmationAction getLastSelectedConfirmationAction() {
+ return lastSelectedAction;
+ }
+
+ private void resolveConfirmation(int actionIndex, boolean accepted) {
+ if (pendingConfirmationFuture == null || pendingConfirmationFuture.isDone()) {
+ return;
+ }
+ if (accepted && pendingConfirmationActions != null
+ && actionIndex >= 0 && actionIndex < pendingConfirmationActions.size()) {
+ lastSelectedAction = pendingConfirmationActions.get(actionIndex);
+ }
+ ToolConfirmationResult result = accepted
+ ? ToolConfirmationResult.ACCEPT : ToolConfirmationResult.DISMISS;
+ pendingConfirmationFuture.complete(new LanguageModelToolConfirmationResult(result));
+ pendingConfirmationFuture = null;
+ pendingConfirmationActions = null;
+ if (pendingConfirmationTurnId != null) {
+ removeConfirmationBlock(pendingConfirmationTurnId);
+ pendingConfirmationTurnId = null;
+ }
+ }
+
+ private void removeConfirmationBlock(String turnId) {
+ if (turnId == null) {
+ return;
+ }
+ String blockId = ConversationHtmlBlockFactory.confirmationBlockId(turnId);
+ bridge.removeBlock(blockId);
+ }
+
+ /**
+ * Processes a thinking chunk: starts a new thinking block or updates the current one.
+ * Seals the thinking block if renderable output follows in the same event.
+ */
+ private void processThinking(String turnId, ChatProgressValue value) {
+ Thinking thinking = value.getThinking();
+ boolean hasThinking = thinking != null && thinking.text() != null
+ && !thinking.text().isBlank();
+
+ TurnStreamState state = stateFor(turnId);
+ if (hasThinking) {
+ if (state.currentBlockType != ChildBlockType.THINKING) {
+ state.currentThinkingText = new StringBuilder(thinking.text());
+ state.currentBlockType = ChildBlockType.THINKING;
+ state.currentChildBlockId = ConversationHtmlBlockFactory.copilotChildBlockId(
+ turnId, state.childBlockCounter++);
+ state.currentThinkingBlockId = state.currentChildBlockId;
+ String blockHtml = htmlFactory.createThinkingHtmlBlock(
+ state.currentChildBlockId, thinking.text());
+ bridge.insertBlock(state.contentAreaId, blockHtml);
+ } else {
+ state.currentThinkingText.append(thinking.text());
+ bridge.updateThinkingBodyText(state.currentChildBlockId,
+ htmlFactory.renderMarkdown(state.currentThinkingText.toString()));
+ }
+ }
+
+ // Seal thinking if renderable output follows in the same event
+ if (hasRenderableOutput(value) && state.currentBlockType == ChildBlockType.THINKING) {
+ sealCurrentThinkingBlock(turnId);
+ }
+ }
+
+ /**
+ * Seals the current thinking block: collapses it, removes the spinner, and requests a title
+ * from the language server. Mirrors the behavior of {@code ThinkingTurnWidget.sealThinking()}.
+ * Uses targeted DOM manipulation instead of replacing the entire block.
+ */
+ private void sealCurrentThinkingBlock(String turnId) {
+ TurnStreamState state = stateFor(turnId);
+ if (state.currentThinkingBlockId == null || state.currentThinkingText == null) {
+ state.currentBlockType = null;
+ return;
+ }
+ String blockId = state.currentThinkingBlockId;
+ String thinkingContent = state.currentThinkingText.toString();
+ state.currentBlockType = null;
+
+ // Collapse details, remove spinner (targeted DOM ops, no full re-render)
+ bridge.collapseThinkingBlock(blockId, SvgIcons.get(SvgIcons.Icon.THINKING_BULB));
+
+ // Request title from language server (async)
+ requestThinkingTitle(blockId, thinkingContent, turnId);
+
+ // The block is sealed; it is no longer the turn's active thinking block.
+ state.currentThinkingBlockId = null;
+ state.currentThinkingText = null;
+ }
+
+ /**
+ * Requests a thinking block title from the language server and updates the summary on response.
+ * Follows the same logic as {@code ThinkingTurnWidget}: extracts bold titles from thinking text
+ * and sends either extractedTitles or the full content to the server.
+ */
+ private void requestThinkingTitle(String blockId, String thinkingContent, String turnId) {
+ var ls = CopilotCore.getPlugin().getCopilotLanguageServer();
+ if (ls == null || StringUtils.isBlank(thinkingContent)) {
+ return;
+ }
+ var params = ThinkingTitles.buildTitleParams(
+ thinkingContent, ThinkingTitles.extractTitles(thinkingContent));
+ ls.generateThinkingTitle(params)
+ .thenAccept(resp -> {
+ if (resp != null && StringUtils.isNotBlank(resp.title())) {
+ bridge.updateThinkingBlockTitle(blockId, htmlFactory.renderMarkdownInline(resp.title()));
+ persistThinkingTitle(turnId, blockId, resp.title());
+ }
+ })
+ .exceptionally(ex -> null);
+ }
+
+ private void persistThinkingTitle(String turnId, String thinkingBlockId, String title) {
+ if (serviceManager != null) {
+ ThinkingTitles.persistTitle(serviceManager.getPersistenceManager(),
+ conversationId, turnId, thinkingBlockId, title);
+ }
+ }
+
+ /**
+ * Processes agent rounds: handles tool calls and agent-round replies.
+ */
+ private void processAgentRounds(String turnId, ChatProgressValue value) {
+ List agentRounds = value.getAgentRounds();
+ if (agentRounds == null || agentRounds.isEmpty()) {
+ return;
+ }
+ AgentRound round = agentRounds.get(0);
+ if (round.getToolCalls() != null && !round.getToolCalls().isEmpty()) {
+ AgentToolCall toolCall = round.getToolCalls().get(0);
+ processToolCall(turnId, toolCall);
+ }
+ }
+
+ /**
+ * Processes a reply chunk: starts a new response block or updates the current one.
+ */
+ private void processReply(String turnId, String replyChunk) {
+ if (replyChunk == null || replyChunk.isEmpty()) {
+ return;
+ }
+ TurnStreamState state = stateFor(turnId);
+ if (state.currentBlockType != ChildBlockType.RESPONSE) {
+ state.currentReplyText = new StringBuilder(replyChunk);
+ state.currentBlockType = ChildBlockType.RESPONSE;
+ state.currentChildBlockId = ConversationHtmlBlockFactory.copilotChildBlockId(
+ turnId, state.childBlockCounter++);
+ String blockHtml = htmlFactory.createCopilotReplyHtmlBlock(
+ state.currentChildBlockId, state.currentReplyText.toString());
+ bridge.insertBlock(state.contentAreaId, blockHtml);
+ } else {
+ state.currentReplyText.append(replyChunk);
+ String blockHtml = htmlFactory.createCopilotReplyHtmlBlock(
+ state.currentChildBlockId, state.currentReplyText.toString());
+ bridge.replaceBlock(state.currentChildBlockId, blockHtml);
+ }
+ }
+
+ /**
+ * Processes a tool call: creates a new tool call block or updates an existing one. The
+ * {@code run_subagent} tool call is not rendered as a normal block — it drives subagent nesting
+ * (mirrors {@code BaseTurnWidget} in the SWT renderer).
+ */
+ private void processToolCall(String turnId, AgentToolCall toolCall) {
+ if (toolCall != null && "run_subagent".equalsIgnoreCase(toolCall.getName())) {
+ handleSubagentToolCall(turnId, toolCall);
+ return;
+ }
+ TurnStreamState state = stateFor(turnId);
+ String toolCallId = toolCall.getId();
+ boolean isSameToolCall = state.currentBlockType == ChildBlockType.TOOL_CALL
+ && state.currentChildBlockId != null
+ && toolCallId != null && toolCallId.equals(state.currentToolCallId);
+
+ if (!isSameToolCall) {
+ state.currentBlockType = ChildBlockType.TOOL_CALL;
+ state.currentToolCallId = toolCallId;
+ String blockId =
+ ConversationHtmlBlockFactory.toolCallBlockId(turnId, state.childBlockCounter++);
+ state.currentChildBlockId = blockId;
+ String blockHtml = htmlFactory.createToolCallHtmlBlock(blockId, toolCall);
+ bridge.insertBlock(state.contentAreaId, blockHtml);
+ } else {
+ String blockHtml = htmlFactory.createToolCallHtmlBlock(
+ state.currentChildBlockId, toolCall);
+ bridge.replaceBlock(state.currentChildBlockId, blockHtml);
+ }
+ }
+
+ /**
+ * Handles the parent turn's {@code run_subagent} tool call. On {@code running} it opens a nested
+ * subagent block under the parent's content area; on completion/cancellation/error it closes the
+ * active nesting. Mirrors {@code BaseTurnWidget.handleSubagentToolCall} in the SWT renderer.
+ */
+ private void handleSubagentToolCall(String parentTurnId, AgentToolCall toolCall) {
+ String status = toolCall.getStatus() == null ? "" : toolCall.getStatus().toLowerCase();
+ String toolCallId = toolCall.getId();
+ switch (status) {
+ case "running" -> {
+ if (activeSubagentContentAreaId == null && StringUtils.isNotBlank(toolCallId)) {
+ activeSubagentContentAreaId = openSubagentBlock(parentTurnId, toolCallId,
+ subagentTitle(toolCall.getProgressMessage(), toolCall.getName()));
+ }
+ }
+ case "completed", "cancelled", "error" -> activeSubagentContentAreaId = null;
+ default -> {
+ // pending/unknown: nothing to do yet
+ }
+ }
+ }
+
+ /**
+ * Inserts a nested subagent block under the given parent turn's content area (idempotent per
+ * tool-call id) and returns the id of its inner content area, where the subagent's child blocks
+ * are inserted.
+ */
+ private String openSubagentBlock(String parentTurnId, String toolCallId, String title) {
+ String existing = subagentContentAreaByToolCallId.get(toolCallId);
+ if (existing != null) {
+ return existing;
+ }
+ String blockId = ConversationHtmlBlockFactory.subagentBlockId(parentTurnId, toolCallId);
+ String contentAreaId =
+ ConversationHtmlBlockFactory.subagentContentAreaId(parentTurnId, toolCallId);
+ String html = htmlFactory.createSubagentBlockHtmlBlock(
+ blockId, contentAreaId, copilotAvatarDataUri, title);
+ bridge.insertBlock(ConversationHtmlBlockFactory.contentBlockId(parentTurnId, true), html);
+ subagentContentAreaByToolCallId.put(toolCallId, contentAreaId);
+ return contentAreaId;
+ }
+
+ /** Derives the subagent card title: progress message, else tool name, else "Subagent". */
+ private static String subagentTitle(String progressMessage, String name) {
+ if (StringUtils.isNotBlank(progressMessage)) {
+ return progressMessage;
+ }
+ if (StringUtils.isNotBlank(name)) {
+ return name;
+ }
+ return "Subagent";
+ }
+
+ /**
+ * Processes a streaming error message: renders a warning block with the error text and,
+ * for quota-exceeded (402) errors, plan-driven action buttons.
+ */
+ private void processErrorMessage(String turnId, String message, int code,
+ String modelProviderName) {
+ List actions = resolveQuotaActions(code, modelProviderName);
+ TurnStreamState state = stateFor(turnId);
+ String blockId =
+ ConversationHtmlBlockFactory.copilotChildBlockId(turnId, state.childBlockCounter++);
+ state.currentBlockType = null;
+ bridge.insertBlock(state.contentAreaId,
+ htmlFactory.createWarningMessageHtmlBlock(blockId, message, actions));
+ // Scroll the newly inserted warning banner (e.g. quota-exceeded / 402 with plan-driven
+ // action buttons) into view so the user notices the required action, mirroring the SWT
+ // renderer's showControl-based scroll for warn banners.
+ scrollToBottom();
+ }
+
+ /**
+ * Resolves the quota action buttons appropriate for an error code. Returns an empty list
+ * for non-quota errors or when the required auth status is unavailable.
+ */
+ private List resolveQuotaActions(int code, String modelProviderName) {
+ if (serviceManager == null) {
+ return List.of();
+ }
+ try {
+ return QuotaActions.forQuotaStatus(
+ serviceManager.getAuthStatusManager().getQuotaStatus(), code, modelProviderName);
+ } catch (Exception e) {
+ CopilotCore.LOGGER.error("Failed to resolve quota actions", e);
+ return List.of();
+ }
+ }
+
+ /** Finalizes a turn: seals thinking, final response render, and resets its transient pointers. */
+ private void finalizeTurn(String turnId) {
+ TurnStreamState state = turnStates.get(turnId);
+ if (state == null) {
+ return;
+ }
+ // Seal any active thinking block before the turn ends
+ if (state.currentBlockType == ChildBlockType.THINKING) {
+ sealCurrentThinkingBlock(turnId);
+ } else if (state.currentBlockType == ChildBlockType.RESPONSE
+ && state.currentReplyText != null && state.currentReplyText.length() > 0) {
+ String blockHtml = htmlFactory.createCopilotReplyHtmlBlock(
+ state.currentChildBlockId, state.currentReplyText.toString());
+ bridge.replaceBlock(state.currentChildBlockId, blockHtml);
+ }
+ state.resetTransient();
+ }
+
+ private void restoreCopilotTurnContent(String turnId, String contentId,
+ CopilotTurnData copilotTurn) {
+ ReplyData replyData = copilotTurn.getReply();
+ if (replyData == null) {
+ return;
+ }
+
+ int blockIdx = 0;
+
+ if (StringUtils.isNotBlank(replyData.getText())) {
+ String blockId = ConversationHtmlBlockFactory.copilotChildBlockId(turnId, blockIdx++);
+ bridge.insertBlock(contentId,
+ htmlFactory.createCopilotReplyHtmlBlock(blockId, replyData.getText()));
+ }
+
+ if (replyData.getEditAgentRounds() != null) {
+ for (EditAgentRoundData round : replyData.getEditAgentRounds()) {
+ ThinkingBlockData thinkingBlock = round.getThinkingBlock();
+ if (thinkingBlock != null && StringUtils.isNotBlank(thinkingBlock.getContent())) {
+ String blockId = ConversationHtmlBlockFactory.copilotChildBlockId(turnId, blockIdx++);
+ bridge.insertBlock(contentId,
+ htmlFactory.createRestoredThinkingHtmlBlock(blockId, thinkingBlock));
+ }
+ // Reply text renders before tool calls so it appears above them (and above any nested
+ // subagent card opened by a run_subagent tool call), matching StyledTextConversationWidget.
+ if (StringUtils.isNotBlank(round.getReply())) {
+ String blockId = ConversationHtmlBlockFactory.copilotChildBlockId(turnId, blockIdx++);
+ bridge.insertBlock(contentId,
+ htmlFactory.createCopilotReplyHtmlBlock(blockId, round.getReply()));
+ }
+ if (round.getToolCalls() != null) {
+ for (ToolCallData tc : round.getToolCalls()) {
+ // A run_subagent tool call opens a nested block; the subagent turn (restored
+ // afterwards) renders into it rather than showing a normal tool-call block.
+ if (tc != null && "run_subagent".equalsIgnoreCase(tc.getName())
+ && StringUtils.isNotBlank(tc.getId())) {
+ openSubagentBlock(turnId, tc.getId(),
+ subagentTitle(tc.getProgressMessage(), tc.getName()));
+ continue;
+ }
+ String blockId = ConversationHtmlBlockFactory.copilotChildBlockId(turnId, blockIdx++);
+ bridge.insertBlock(contentId,
+ htmlFactory.createRestoredToolCallHtmlBlock(blockId, tc));
+ }
+ }
+ }
+ }
+
+ if (replyData.getErrorMessages() != null) {
+ for (ErrorMessageData errorMessageData : replyData.getErrorMessages()) {
+ ErrorData errorData = errorMessageData.getError();
+ String errorMessage = errorData != null
+ ? errorData.getMessage() : "An error occurred";
+ int errorCode = errorData != null ? errorData.getCode() : 0;
+ String modelProviderName = errorData != null ? errorData.getModelProviderName() : null;
+ List actions = resolveQuotaActions(errorCode, modelProviderName);
+ String blockId = ConversationHtmlBlockFactory.copilotChildBlockId(turnId, blockIdx++);
+ bridge.insertBlock(contentId,
+ htmlFactory.createWarningMessageHtmlBlock(blockId, errorMessage, actions));
+ }
+ }
+
+ if (replyData.getAgentMessages() != null) {
+ for (AgentMessageData agentMsg : replyData.getAgentMessages()) {
+ if (StringUtils.equals(agentMsg.getAgentSlug(),
+ UiConstants.GITHUB_COPILOT_CODING_AGENT_SLUG)) {
+ String blockId = ConversationHtmlBlockFactory.copilotChildBlockId(turnId, blockIdx++);
+ bridge.insertBlock(contentId,
+ htmlFactory.createAgentMessageHtmlBlock(blockId,
+ agentMsg.getTitle(), agentMsg.getDescription(),
+ agentMsg.getPrLink()));
+ }
+ }
+ }
+ }
+
+ private void showStreamingIndicator(String turnId) {
+ TurnStreamState state = stateFor(turnId);
+ String indicatorId = ConversationHtmlBlockFactory.streamingIndicatorId(turnId);
+ String html = htmlFactory.createStreamingIndicatorHtmlBlock(indicatorId);
+ bridge.insertBlock(state.contentAreaId, html);
+ state.streamingIndicatorVisible = true;
+ }
+
+ private void removeStreamingIndicator(String turnId) {
+ TurnStreamState state = turnStates.get(turnId);
+ if (state == null || !state.streamingIndicatorVisible) {
+ return;
+ }
+ bridge.removeBlock(ConversationHtmlBlockFactory.streamingIndicatorId(turnId));
+ state.streamingIndicatorVisible = false;
+ }
+
+ private boolean hasRenderableOutput(ChatProgressValue value) {
+ if (StringUtils.isNotBlank(value.getReply())) {
+ return true;
+ }
+ if (value.getAgentRounds() != null && !value.getAgentRounds().isEmpty()) {
+ AgentRound round = value.getAgentRounds().get(0);
+ if (round.getReply() != null && !round.getReply().isEmpty()) {
+ return true;
+ }
+ if (round.getToolCalls() != null && !round.getToolCalls().isEmpty()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private String extractReplyChunk(ChatProgressValue value) {
+ if (value.getAgentRounds() != null && !value.getAgentRounds().isEmpty()) {
+ return value.getAgentRounds().get(0).getReply();
+ }
+ return value.getReply();
+ }
+
+ /** Returns the streaming state for a turn, creating a default top-level one if absent. */
+ private TurnStreamState stateFor(String turnId) {
+ return turnStates.computeIfAbsent(turnId,
+ id -> new TurnStreamState(ConversationHtmlBlockFactory.contentBlockId(id, true)));
+ }
+
+ /**
+ * Ensures a streaming state exists for a turn seen in a {@code report} event. Normally the state
+ * is created by {@link #beginTurn}; this defensively creates one (nested when a subagent is
+ * active) if the begin event was not observed.
+ */
+ private TurnStreamState ensureTurnState(String turnId, ChatProgressValue value) {
+ TurnStreamState state = turnStates.get(turnId);
+ if (state != null) {
+ return state;
+ }
+ boolean nested = value != null && StringUtils.isNotBlank(value.getParentTurnId())
+ && activeSubagentContentAreaId != null;
+ String contentAreaId = nested
+ ? activeSubagentContentAreaId
+ : ConversationHtmlBlockFactory.contentBlockId(turnId, true);
+ state = new TurnStreamState(contentAreaId);
+ turnStates.put(turnId, state);
+ return state;
+ }
+
+ private String getUserDisplayName() {
+ if (userName != null && !userName.isEmpty()) {
+ return userName;
+ }
+ try {
+ return serviceManager.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 = serviceManager.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) {
+ return BundleUtils.readResourceAsDataUri(bundle, path, "image/png");
+ }
+
+ @Override
+ public void copyToClipboard(String code) {
+ Display.getDefault().asyncExec(() -> {
+ Clipboard clipboard = new Clipboard(Display.getDefault());
+ clipboard.setContents(
+ new Object[]{code},
+ new Transfer[]{TextTransfer.getInstance()});
+ clipboard.dispose();
+ });
+ }
+
+ @Override
+ public void insertAtCursor(String code) {
+ Display.getDefault().asyncExec(() -> {
+ if (browser.isDisposed()) {
+ return;
+ }
+ IEditorPart editor = SwtUtils.getActiveEditorPart();
+ if (editor == null) {
+ showInsertError("Cannot Insert", "No active editor found.");
+ return;
+ }
+
+ ITextEditor textEditor = editor instanceof ITextEditor te
+ ? te
+ : editor.getAdapter(ITextEditor.class);
+ if (textEditor == null) {
+ CopilotCore.LOGGER
+ .error(new IllegalStateException("The active editor doesn't support text insertion."));
+ showInsertError("Cannot Insert", "The active editor doesn't support text insertion.");
+ return;
+ }
+
+ IDocumentProvider provider = textEditor.getDocumentProvider();
+ IDocument doc = provider == null ? null : provider.getDocument(textEditor.getEditorInput());
+ if (doc == null) {
+ CopilotCore.LOGGER
+ .error(new IllegalStateException("Failed to get the document from the active editor."));
+ showInsertError("Cannot Insert", "Failed to get the document from the active editor.");
+ return;
+ }
+
+ try {
+ ITextSelection sel = (ITextSelection) textEditor.getSelectionProvider().getSelection();
+ int offset = sel.getOffset();
+ doc.replace(offset, sel.getLength(), code);
+
+ // Set the cursor position after the inserted text
+ textEditor.selectAndReveal(offset + code.length(), 0);
+ } catch (BadLocationException e) {
+ CopilotCore.LOGGER.error("Failed to insert code at cursor", e);
+ showInsertError("Insert Failed", "An error occurred while inserting the code: "
+ + e.getMessage());
+ }
+ });
+ }
+
+ /**
+ * Shows a modal error dialog for a failed "Insert into editor" action. This mirrors the user
+ * feedback the StyledText renderer ({@link SourceViewerComposite}) already provides for the same
+ * failure cases, so a code block that cannot be inserted no longer fails silently in the browser
+ * renderer. The modal dialog restores keyboard focus to the previously focused control (the chat
+ * view's browser) on its own when it is dismissed, so no explicit focus handling is needed here.
+ *
+ * @param title the dialog title
+ * @param message the human-readable reason the insertion could not be performed
+ */
+ private void showInsertError(String title, String message) {
+ if (browser.isDisposed()) {
+ return;
+ }
+ MessageDialog.openError(browser.getShell(), title, message);
+ }
+
+ @Override
+ public void acceptToolAction(int actionIndex) {
+ resolveConfirmation(actionIndex, true);
+ }
+
+ @Override
+ public void dismissToolAction() {
+ resolveConfirmation(-1, false);
+ }
+
+ @Override
+ public void copilotAction(String action, String param) {
+ switch (action) {
+ case "openLink":
+ UiUtils.openLink(param);
+ break;
+ case "openJobList":
+ UiUtils.openE4Part(Constants.GITHUB_JOBS_VIEW_ID);
+ break;
+ default:
+ break;
+ }
+ }
+
+ @Override
+ public void logError(String message) {
+ CopilotCore.LOGGER.error(new IllegalStateException("Browser chat renderer: " + message));
+ }
+}
diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java
index b3db64b4..c7e8c6b8 100644
--- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java
@@ -3,17 +3,14 @@
package com.microsoft.copilot.eclipse.ui.chat;
-import java.util.ArrayList;
import java.util.HashMap;
import java.util.IdentityHashMap;
-import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
-import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
import org.eclipse.e4.core.services.events.IEventBroker;
@@ -34,13 +31,9 @@
import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentToolCall;
import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatProgressValue;
import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel;
-import com.microsoft.copilot.eclipse.core.lsp.protocol.TodoItem;
-import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolSpecificData;
import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CheckQuotaResult;
import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CopilotPlan;
-import com.microsoft.copilot.eclipse.ui.CopilotUi;
import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager;
-import com.microsoft.copilot.eclipse.ui.chat.services.TodoListService;
import com.microsoft.copilot.eclipse.ui.i18n.Messages;
import com.microsoft.copilot.eclipse.ui.swt.CssConstants;
import com.microsoft.copilot.eclipse.ui.utils.MenuUtils;
@@ -59,13 +52,6 @@ public class ChatContentViewer extends Composite {
private static final int SCROLL_THRESHOLD = 100;
- /**
- * Matches the trailing "| Request ID: ..." and "GitHub Request ID: ..." segments that the
- * language server appends to user-facing error messages.
- */
- private static final Pattern REQUEST_ID_SUFFIX =
- Pattern.compile("\\s*\\|?\\s*(?:GitHub\\s+)?Request\\s+ID:\\s*\\S+\\.?", Pattern.CASE_INSENSITIVE);
-
private ChatServiceManager serviceManager;
private String conversationId;
@@ -253,8 +239,6 @@ private void doProcessTurnEvent(ChatProgressValue value) {
return;
}
- ChatServiceManager chatServiceManager = CopilotUi.getPlugin().getChatServiceManager();
-
if (value.getKind() == WorkDoneProgressKind.report) {
if (turnWidget instanceof ThinkingTurnWidget thinkingTurn) {
thinkingTurn.setConversationContext(conversationId, value.getTurnId());
@@ -277,9 +261,6 @@ private void doProcessTurnEvent(ChatProgressValue value) {
if (agentRound.getToolCalls() != null && !agentRound.getToolCalls().isEmpty()) {
AgentToolCall toolCall = agentRound.getToolCalls().get(0);
turnWidget.appendToolCallStatus(toolCall);
-
- // Extract and process todo list from tool result details
- processTodoListFromToolCall(chatServiceManager, value.getConversationId(), toolCall);
}
} else {
// Handle chat mode responses
@@ -294,15 +275,7 @@ private void doProcessTurnEvent(ChatProgressValue value) {
turnWidget.flushMessageBuffer();
}
- String errMsg = value.getErrorMessage();
- if (StringUtils.isNotEmpty(errMsg)) {
- errMsg = REQUEST_ID_SUFFIX.matcher(errMsg).replaceAll(StringUtils.EMPTY).trim();
- }
- String reason = value.getErrorReason();
- if (StringUtils.isNotEmpty(reason) && reason.equals("model_not_supported")) {
- // TODO: add enable button for better UX.
- errMsg = Messages.chat_model_unsupported_message;
- }
+ String errMsg = ChatErrorMessages.resolveDisplayMessage(value);
if (StringUtils.isNotEmpty(errMsg)) {
// TODO: Remove this legacy fallback after TBB is officially released.
// When the language server has not enabled token-based billing yet, fall back to the
@@ -398,36 +371,6 @@ private static boolean hasRenderableAgentRound(ChatProgressValue value) {
return false;
}
- /**
- * Process todo list from tool call result. Extracts todo list data from the tool-specific data
- * and updates the TodoListService.
- *
- * @param chatServiceManager the chat service manager
- * @param conversationId the conversation ID
- * @param toolCall the agent tool call containing tool-specific data
- */
- private void processTodoListFromToolCall(ChatServiceManager chatServiceManager, String conversationId,
- AgentToolCall toolCall) {
- if (chatServiceManager == null || conversationId == null || toolCall == null) {
- return;
- }
-
- ToolSpecificData toolSpecificData = toolCall.getToolSpecificData();
- if (toolSpecificData == null || toolSpecificData.getTodoList() == null) {
- return;
- }
-
- TodoListService todoListService = chatServiceManager.getTodoListService();
- if (todoListService == null) {
- return;
- }
-
- List todos = toolSpecificData.getTodoList();
- if (todos != null) {
- todoListService.setTodoList(new ArrayList<>(todos));
- }
- }
-
/**
* Shows the compacting status on the latest Copilot turn after flushing any buffered reply text.
*/
diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatErrorMessages.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatErrorMessages.java
new file mode 100644
index 00000000..640b14e9
--- /dev/null
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatErrorMessages.java
@@ -0,0 +1,54 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+package com.microsoft.copilot.eclipse.ui.chat;
+
+import java.util.regex.Pattern;
+
+import org.apache.commons.lang3.StringUtils;
+
+import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatProgressValue;
+import com.microsoft.copilot.eclipse.ui.i18n.Messages;
+
+/**
+ * Normalizes user-facing error messages carried by {@link ChatProgressValue} so that both the SWT
+ * ({@link ChatContentViewer}) and browser ({@link BrowserConversationWidget}) conversation widgets
+ * display identical text.
+ */
+public final class ChatErrorMessages {
+
+ /** Server reason string indicating the selected model is not supported. */
+ private static final String REASON_MODEL_NOT_SUPPORTED = "model_not_supported";
+
+ /**
+ * Matches the trailing "| Request ID: ..." and "GitHub Request ID: ..." segments that the
+ * language server appends to user-facing error messages.
+ */
+ private static final Pattern REQUEST_ID_SUFFIX = Pattern.compile(
+ "\\s*\\|?\\s*(?:GitHub\\s+)?Request\\s+ID:\\s*\\S+\\.?", Pattern.CASE_INSENSITIVE);
+
+ private ChatErrorMessages() {
+ }
+
+ /**
+ * Resolves the display error message for a progress event: strips the trailing request-ID suffix
+ * and maps the {@code model_not_supported} reason to a friendly message. Returns the original
+ * message (which may be blank) when no normalization applies.
+ *
+ * @param value the progress event
+ * @return the normalized message to display, possibly blank
+ */
+ public static String resolveDisplayMessage(ChatProgressValue value) {
+ if (value == null) {
+ return StringUtils.EMPTY;
+ }
+ String message = value.getErrorMessage();
+ if (StringUtils.isNotEmpty(message)) {
+ message = REQUEST_ID_SUFFIX.matcher(message).replaceAll(StringUtils.EMPTY).trim();
+ }
+ if (REASON_MODEL_NOT_SUPPORTED.equals(value.getErrorReason())) {
+ message = Messages.chat_model_unsupported_message;
+ }
+ return message;
+ }
+}
diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java
index c5aa62c6..6cd81b0a 100644
--- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java
@@ -9,6 +9,7 @@
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
@@ -25,6 +26,7 @@
import org.eclipse.e4.core.services.events.IEventBroker;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jface.preference.IPreferenceStore;
+import org.eclipse.jface.util.IPropertyChangeListener;
import org.eclipse.lsp4e.LSPEclipseUtils;
import org.eclipse.lsp4j.Range;
import org.eclipse.lsp4j.WorkspaceFolder;
@@ -69,19 +71,13 @@
import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotStatusResult;
import com.microsoft.copilot.eclipse.core.lsp.protocol.RateLimitWarningParams;
import com.microsoft.copilot.eclipse.core.lsp.protocol.TodoItem;
+import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolSpecificData;
import com.microsoft.copilot.eclipse.core.lsp.protocol.Turn;
import com.microsoft.copilot.eclipse.core.lsp.protocol.codingagent.CodingAgentMessageRequestParams;
import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.QuotaWarningParams;
import com.microsoft.copilot.eclipse.core.persistence.AbstractTurnData;
import com.microsoft.copilot.eclipse.core.persistence.ConversationPersistenceManager;
import com.microsoft.copilot.eclipse.core.persistence.ConversationXmlData;
-import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData;
-import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.AgentMessageData;
-import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.EditAgentRoundData;
-import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ErrorData;
-import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ErrorMessageData;
-import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ReplyData;
-import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ToolCallData;
import com.microsoft.copilot.eclipse.core.persistence.UserTurnData;
import com.microsoft.copilot.eclipse.terminal.api.IRunInTerminalTool;
import com.microsoft.copilot.eclipse.terminal.api.TerminalServiceManager;
@@ -116,7 +112,7 @@ public class ChatView extends ViewPart implements ChatProgressListener, MessageL
private Composite contentWrapper;
private HandoffContainer handoffContainer;
private ActionBar actionBar;
- private ChatContentViewer chatContentViewer;
+ private IConversationWidget conversationWidget;
private Composite loadingViewer;
private Composite noSubscriptionViewer;
private Composite beforeLoginWelcomeViewer;
@@ -155,6 +151,9 @@ public class ChatView extends ViewPart implements ChatProgressListener, MessageL
private EventHandler compressionStartedHandler;
private EventHandler compressionCompletedHandler;
+ /** Listener that rebuilds the conversation page when the chat renderer preference is toggled. */
+ private IPropertyChangeListener rendererPreferenceListener;
+
// Context activation for chat view keyboard shortcuts
private static final String CHAT_VIEW_CONTEXT = "com.microsoft.copilot.eclipse.chatViewContext";
@@ -297,8 +296,8 @@ public void done(IJobChangeEvent event) {
}
// Clear existing content by recreating the chat content viewer
- if (chatContentViewer != null) {
- chatContentViewer.dispose();
+ if (conversationWidget != null) {
+ conversationWidget.dispose();
createConversationPage();
}
@@ -328,7 +327,9 @@ public void done(IJobChangeEvent event) {
}
// Scroll to bottom after restoring all turns
- SwtUtils.invokeOnDisplayThreadAsync(this::scrollContentToBottom, chatContentViewer);
+ if (conversationWidget != null) {
+ conversationWidget.scrollToBottom();
+ }
// Hide chat history and show restored conversation
hideChatHistory();
@@ -416,10 +417,10 @@ public void done(IJobChangeEvent event) {
return;
}
SwtUtils.invokeOnDisplayThreadAsync(() -> {
- if (this.chatContentViewer == null || this.chatContentViewer.isDisposed()) {
+ if (this.conversationWidget == null || this.conversationWidget.isDisposed()) {
return;
}
- this.chatContentViewer.showCompactingStatusOnLatestCopilotTurn();
+ this.conversationWidget.showCompactingStatusOnLatestCopilotTurn();
}, parent);
};
this.eventBroker.subscribe(CopilotEventConstants.TOPIC_CHAT_COMPRESSION_STARTED,
@@ -434,10 +435,10 @@ public void done(IJobChangeEvent event) {
return;
}
SwtUtils.invokeOnDisplayThreadAsync(() -> {
- if (this.chatContentViewer == null || this.chatContentViewer.isDisposed()) {
+ if (this.conversationWidget == null || this.conversationWidget.isDisposed()) {
return;
}
- this.chatContentViewer.hideCompactingStatusOnLatestCopilotTurn();
+ this.conversationWidget.hideCompactingStatusOnLatestCopilotTurn();
if (params.contextInfo() != null) {
this.chatServiceManager.getContextWindowService().updateContextSize(params.contextInfo());
}
@@ -448,6 +449,9 @@ public void done(IJobChangeEvent event) {
// Register part listener to activate/deactivate chat view context for keyboard shortcuts
registerPartListener();
+
+ // Rebuild the conversation page in place when the chat renderer preference is toggled.
+ registerRendererPreferenceListener();
}
/**
@@ -786,8 +790,90 @@ private void createLoadingPage() {
*/
private void createConversationPage() {
clearChatView(this.contentWrapper);
- this.chatContentViewer = new ChatContentViewer(this.contentWrapper, SWT.NONE, this.chatServiceManager);
- this.chatContentViewer.requestLayout();
+ this.conversationWidget = createConversationWidget(this.contentWrapper);
+ this.conversationWidget.requestLayout();
+ }
+
+ private IConversationWidget createConversationWidget(Composite parent) {
+ boolean useBrowser = CopilotUi.getPlugin().getPreferenceStore()
+ .getBoolean(Constants.USE_BROWSER_BASED_CHAT_RENDERER);
+ if (useBrowser) {
+ return new BrowserConversationWidget(parent, this.chatServiceManager);
+ }
+ return new StyledTextConversationWidget(parent, this.chatServiceManager);
+ }
+
+ /**
+ * Register a preference listener that rebuilds the conversation page in place when the
+ * {@link Constants#USE_BROWSER_BASED_CHAT_RENDERER} preference changes, so a renderer switch takes
+ * effect on an already-open chat view without requiring an IDE restart.
+ */
+ private void registerRendererPreferenceListener() {
+ IPreferenceStore preferenceStore = CopilotUi.getPlugin().getPreferenceStore();
+ this.rendererPreferenceListener = event -> {
+ if (!Constants.USE_BROWSER_BASED_CHAT_RENDERER.equals(event.getProperty())) {
+ return;
+ }
+ if (Objects.equals(event.getOldValue(), event.getNewValue())) {
+ return;
+ }
+ SwtUtils.invokeOnDisplayThreadAsync(this::reloadConversationRenderer, parent);
+ };
+ preferenceStore.addPropertyChangeListener(this.rendererPreferenceListener);
+ }
+
+ /**
+ * Rebuilds the conversation page using the renderer currently selected by the
+ * {@link Constants#USE_BROWSER_BASED_CHAT_RENDERER} preference, preserving the active conversation.
+ *
+ *
The current conversation (if any) is reloaded from persistence so the visible history is not
+ * lost. The reload is skipped while a response is streaming, because the in-flight turn is not yet
+ * persisted and would be dropped by the widget swap; the new renderer then takes effect the next
+ * time the page is rebuilt. Must be invoked on the UI thread.
+ */
+ public void reloadConversationRenderer() {
+ // Nothing to do if there is no conversation page currently shown.
+ if (this.contentWrapper == null || this.contentWrapper.isDisposed() || this.conversationWidget == null) {
+ return;
+ }
+
+ // Avoid swapping the widget mid-stream; the in-flight turn is not yet persisted and would be lost.
+ if (this.actionBar != null && !this.actionBar.isSendButton()) {
+ return;
+ }
+
+ String currentConversationId = this.conversationId;
+
+ // Rebuild the page with the newly selected renderer.
+ createConversationPage();
+
+ // Re-restore the active conversation's turns so the visible history survives the renderer swap.
+ if (StringUtils.isNotBlank(currentConversationId) && this.persistenceManager != null) {
+ this.persistenceManager.loadConversation(currentConversationId).thenAccept(historyConversation -> {
+ if (historyConversation == null) {
+ return;
+ }
+ SwtUtils.invokeOnDisplayThreadAsync(() -> {
+ if (this.conversationWidget == null || this.conversationWidget.isDisposed()) {
+ return;
+ }
+ if (historyConversation.getTurns() != null && !historyConversation.getTurns().isEmpty()) {
+ for (AbstractTurnData turn : historyConversation.getTurns()) {
+ restoreTurn(turn);
+ }
+ }
+ List todos = historyConversation.getTodos();
+ TodoListService todoService = chatServiceManager.getTodoListService();
+ if (todoService != null && todos != null && !todos.isEmpty()) {
+ todoService.setTodoList(todos);
+ }
+ this.conversationWidget.scrollToBottom();
+ }, contentWrapper);
+ }).exceptionally(ex -> {
+ CopilotCore.LOGGER.error("Failed to reload conversation after renderer change: " + currentConversationId, ex);
+ return null;
+ });
+ }
}
/**
@@ -836,9 +922,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 +941,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 +971,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 +1020,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 +1075,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 +1317,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 +1389,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 +1411,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 +1530,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 +1582,36 @@ public String getSubagentConversationId() {
}
/**
- * Get the current chat content viewer.
+ * Returns the active conversation widget.
*/
- public ChatContentViewer getChatContentViewer() {
- return this.chatContentViewer;
+ public IConversationWidget getConversationWidget() {
+ return this.conversationWidget;
+ }
+
+ /**
+ * Updates the {@link TodoListService} from any todo-writing tool calls carried by a report
+ * progress event. Kept in the view (not a widget) because it is renderer-agnostic domain logic
+ * shared by both the SWT and browser conversation widgets.
+ */
+ private void updateTodoListFromReport(ChatProgressValue value) {
+ if (value == null || value.getAgentRounds() == null || value.getAgentRounds().isEmpty()) {
+ return;
+ }
+ TodoListService todoListService = chatServiceManager.getTodoListService();
+ if (todoListService == null) {
+ return;
+ }
+ for (AgentRound round : value.getAgentRounds()) {
+ if (round.getToolCalls() == null) {
+ continue;
+ }
+ for (AgentToolCall toolCall : round.getToolCalls()) {
+ ToolSpecificData toolSpecificData = toolCall.getToolSpecificData();
+ if (toolSpecificData != null && toolSpecificData.getTodoList() != null) {
+ todoListService.setTodoList(new ArrayList<>(toolSpecificData.getTodoList()));
+ }
+ }
+ }
}
/**
@@ -1640,6 +1757,11 @@ public void dispose() {
}
}
+ if (this.rendererPreferenceListener != null) {
+ CopilotUi.getPlugin().getPreferenceStore().removePropertyChangeListener(this.rendererPreferenceListener);
+ this.rendererPreferenceListener = null;
+ }
+
if (this.chatServiceManager != null) {
if (this.chatServiceManager.getUserPreferenceService() != null) {
this.chatServiceManager.getUserPreferenceService().unbindChatView();
@@ -1673,9 +1795,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 +2006,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 +2023,23 @@ public void scrollContentToBottom() {
*/
private void renderModelInfoInTurnWidget(String turnId, String modelName, double billingMultiplier,
String reasoningEffort) {
- BaseTurnWidget turnWidget = this.chatContentViewer.getTurnWidget(turnId);
- if (turnWidget instanceof CopilotTurnWidget copilotWidget) {
- copilotWidget.renderModelInfo(modelName, billingMultiplier, reasoningEffort);
-
- // Refresh the scroller layout to ensure the footer is visible.
- SwtUtils.invokeOnDisplayThreadAsync(() -> {
- this.chatContentViewer.refreshLayoutFull();
- this.chatContentViewer.scrollToBottomIfAutoScroll();
- }, this.chatContentViewer);
+ if (this.conversationWidget == null || this.conversationWidget.isDisposed()) {
+ return;
}
+ this.conversationWidget.renderModelInfo(turnId, modelName, billingMultiplier, reasoningEffort);
}
/**
- * Restore a single turn from persisted conversation data.
+ * Restore a single turn from persisted conversation data. Delegates to the active
+ * {@link IConversationWidget} implementation.
*
* @param turn the turn data to restore
*/
private void restoreTurn(AbstractTurnData turn) {
- if (turn == null || chatContentViewer == null) {
- return;
- }
-
- // Subagent turns: render their content inside the parent turn's subagent block
- if (turn instanceof CopilotTurnData copilotTurn
- && StringUtils.isNotBlank(copilotTurn.getParentTurnId())) {
- BaseTurnWidget parentWidget = chatContentViewer.getTurnWidget(copilotTurn.getParentTurnId());
- if (parentWidget != null) {
- String toolCallId = copilotTurn.getSubagentToolCallId();
- if (StringUtils.isNotBlank(toolCallId)) {
- // Restore subagent content into the SubagentMessageBlock identified by the tool call ID
- parentWidget.restoreSubagentContent(toolCallId, copilotTurn, persistenceManager.getDataFactory());
- } else {
- // Fallback: append to parent widget directly (legacy data without subagentToolCallId)
- restoreCopilotTurnContent(copilotTurn, parentWidget);
- }
- }
- return;
- }
-
- // Create user turn widget and populate with user message
- if (turn instanceof UserTurnData userTurn) {
- if (userTurn.getMessage() == null || StringUtils.isNotBlank(userTurn.getMessage().getText())) {
- BaseTurnWidget userTurnWidget = chatContentViewer.getLatestOrCreateNewTurnWidget(turn.getTurnId(), false, true);
- userTurnWidget.appendMessage(userTurn.getMessage().getText());
- userTurnWidget.flushMessageBuffer();
- return;
- }
- } else if (turn instanceof CopilotTurnData copilotTurn) {
- BaseTurnWidget copilotTurnWidget = chatContentViewer.getLatestOrCreateNewTurnWidget(turn.getTurnId(), true, true);
- restoreCopilotTurnContent(copilotTurn, copilotTurnWidget);
-
- copilotTurnWidget.flushMessageBuffer();
-
- // Restore model info footer if model name is present
- // This must be done AFTER flushMessageBuffer() to ensure footer appears at the bottom
- ReplyData replyData = copilotTurn.getReply();
- if (replyData != null && StringUtils.isNotBlank(replyData.getModelName())) {
- // Reasoning effort was captured and persisted at send time so the footer reflects what was actually used
- // for this turn, not whatever the user has selected now.
- renderModelInfoInTurnWidget(turn.getTurnId(), replyData.getModelName(), replyData.getBillingMultiplier(),
- replyData.getReasoningEffort());
- }
- }
- }
-
- /**
- * Restores the content of a CopilotTurnData (reply text, agent rounds, tool calls, errors, agent messages) into the
- * given turn widget. Used for both main copilot turns and subagent turns.
- */
- private void restoreCopilotTurnContent(CopilotTurnData copilotTurn, BaseTurnWidget turnWidget) {
- ReplyData replyData = copilotTurn.getReply();
- if (replyData == null) {
+ if (turn == null || conversationWidget == null || conversationWidget.isDisposed()) {
return;
}
-
- ThinkingTurnWidget thinkingWidget =
- turnWidget instanceof ThinkingTurnWidget ? (ThinkingTurnWidget) turnWidget : null;
-
- if (StringUtils.isNotBlank(replyData.getText())) {
- turnWidget.appendMessage(replyData.getText());
- }
-
- if (replyData.getEditAgentRounds() != null && !replyData.getEditAgentRounds().isEmpty()) {
- for (EditAgentRoundData round : replyData.getEditAgentRounds()) {
- // Restore thinking block before the round's reply and tool calls
- if (thinkingWidget != null && round.getThinkingBlock() != null) {
- thinkingWidget.restoreThinkingBlock(round.getThinkingBlock());
- }
- if (round.getReply() != null && !round.getReply().isEmpty()) {
- turnWidget.appendMessage(round.getReply());
- }
- if (round.getToolCalls() != null && !round.getToolCalls().isEmpty()) {
- for (ToolCallData toolCallData : round.getToolCalls()) {
- AgentToolCall agentToolCall = persistenceManager.getDataFactory()
- .convertToolCallDataToAgentToolCall(toolCallData);
- turnWidget.appendToolCallStatus(agentToolCall);
- }
- }
- }
- }
-
- if (replyData.getErrorMessages() != null && !replyData.getErrorMessages().isEmpty()) {
- for (ErrorMessageData errorMessageData : replyData.getErrorMessages()) {
- ErrorData errorData = errorMessageData.getError();
- SwtUtils.invokeOnDisplayThread(() -> {
- String errorMessage = errorData != null ? errorData.getMessage() : Messages.chat_warnWidget_defaultErrorMsg;
- int errorCode = errorData != null ? errorData.getCode() : 0;
- String modelProviderName = errorData != null ? errorData.getModelProviderName() : null;
- turnWidget.createWarnDialog(errorMessage, errorCode, modelProviderName);
- }, parent);
- }
- }
-
- if (replyData.getAgentMessages() != null && !replyData.getAgentMessages().isEmpty()) {
- for (AgentMessageData agentMessageData : replyData.getAgentMessages()) {
- if (StringUtils.equals(agentMessageData.getAgentSlug(), UiConstants.GITHUB_COPILOT_CODING_AGENT_SLUG)) {
- SwtUtils.invokeOnDisplayThread(() -> {
- CodingAgentMessageRequestParams params = new CodingAgentMessageRequestParams();
- params.setTitle(agentMessageData.getTitle());
- params.setDescription(agentMessageData.getDescription());
- params.setPrLink(agentMessageData.getPrLink());
- params.setConversationId(this.conversationId);
- params.setTurnId(copilotTurn.getTurnId());
- turnWidget.createAgentMessageWidget(params);
- }, parent);
- }
- }
- }
+ conversationWidget.restoreTurn(turn, persistenceManager.getDataFactory());
}
/**
diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ConversationHtmlBlockFactory.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ConversationHtmlBlockFactory.java
new file mode 100644
index 00000000..7abbf131
--- /dev/null
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ConversationHtmlBlockFactory.java
@@ -0,0 +1,557 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+package com.microsoft.copilot.eclipse.ui.chat;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.commons.lang3.StringUtils;
+import org.commonmark.Extension;
+import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension;
+import org.commonmark.ext.gfm.tables.TablesExtension;
+import org.commonmark.ext.task.list.items.TaskListItemsExtension;
+import org.commonmark.parser.Parser;
+import org.commonmark.renderer.html.HtmlRenderer;
+
+import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction;
+import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent;
+import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentToolCall;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ThinkingBlockData;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ToolCallData;
+import com.microsoft.copilot.eclipse.ui.chat.QuotaActions.QuotaAction;
+
+/**
+ * Factory for creating HTML block fragments used by {@link BrowserConversationWidget}.
+ *
+ *
Each method produces a self-contained HTML DIV block having an
+ * {@code id} attribute. The widget inserts or updates these blocks in the browser DOM
+ * using the generic JavaScript API for code block manipulation.
+ */
+public class ConversationHtmlBlockFactory {
+
+ /**
+ * DOM ID of the root chat container defined in {@code resources/html/chat-view.html}. New turn
+ * containers and top-level blocks are appended as its children. Must stay in sync with the
+ * {@code id} used in {@code chat-view.html}.
+ */
+ public static final String CHAT_CONTAINER_ID = "chat-container";
+
+ private final Parser markdownParser;
+ private final HtmlRenderer htmlRenderer;
+ private String copyIconDataUri = "";
+ private String insertIconDataUri = "";
+
+ /** Creates a new factory with GFM tables and other extensions. */
+ public ConversationHtmlBlockFactory() {
+ List extensions = List.of(
+ TablesExtension.create(),
+ TaskListItemsExtension.create(),
+ StrikethroughExtension.create()
+ );
+ this.markdownParser = Parser.builder().extensions(extensions).build();
+ this.htmlRenderer = HtmlRenderer.builder()
+ .extensions(extensions)
+ // prevent HTML injection in potentially malicious LLM-generated Markdown code
+ .escapeHtml(true)
+ // avoid potentially malicious URL schemes in LLM-generated Markdown code
+ .sanitizeUrls(true)
+ .build();
+ }
+
+ /**
+ * Sets the data URIs for code block action button icons.
+ *
+ * @param copyIconUri base64 data URI for the copy icon
+ * @param insertIconUri base64 data URI for the insert icon
+ */
+ public void setCodeBlockIcons(String copyIconUri, String insertIconUri) {
+ this.copyIconDataUri = copyIconUri != null ? copyIconUri : "";
+ this.insertIconDataUri = insertIconUri != null ? insertIconUri : "";
+ }
+
+ /**
+ * User turn container ID: {@code turnId-user}. User and copilot turns share the same
+ * server-assigned turn ID (workDoneToken), so each role gets a distinct container ID.
+ */
+ public static String userTurnContainerId(String turnId) {
+ return turnId + "-user";
+ }
+
+ /**
+ * Copilot turn container ID: {@code turnId-copilot}. Paired with
+ * {@link #userTurnContainerId(String)} to avoid duplicate DOM IDs.
+ */
+ public static String copilotTurnContainerId(String turnId) {
+ return turnId + "-copilot";
+ }
+
+ /** Content container ID, scoped by role: {@code turnId-user-content} or
+ * {@code turnId-copilot-content}.
+ */
+ public static String contentBlockId(String turnId, boolean isCopilot) {
+ String containerId = isCopilot
+ ? copilotTurnContainerId(turnId) : userTurnContainerId(turnId);
+ return containerId + "-content";
+ }
+
+ /** Sequential copilot child block ID: {@code turnId-N}. */
+ public static String copilotChildBlockId(String turnId, int index) {
+ return turnId + "-" + index;
+ }
+
+ /** Tool call block ID: {@code turnId-tc-N}. */
+ public static String toolCallBlockId(String turnId, int index) {
+ return turnId + "-tc-" + index;
+ }
+
+ /** Compacting status block ID: {@code turnId-compacting}. */
+ public static String compactingBlockId(String turnId) {
+ return turnId + "-compacting";
+ }
+
+ /** Model info block ID: {@code turnId-model-info}. */
+ public static String modelInfoBlockId(String turnId) {
+ return turnId + "-model-info";
+ }
+
+ /** Agent message block ID: {@code turnId-agent-msg-timestamp}. */
+ public static String agentMessageBlockId(String turnId) {
+ return turnId + "-agent-msg-" + System.currentTimeMillis();
+ }
+
+ /** Error turn block ID: {@code error-timestamp}. Not scoped to a turn (errors may be turnless). */
+ public static String errorBlockId() {
+ return "error-" + System.currentTimeMillis();
+ }
+
+ /** Streaming indicator block ID: {@code turnId-streaming}. */
+ public static String streamingIndicatorId(String turnId) {
+ return turnId + "-streaming";
+ }
+
+ /** Confirmation block ID: {@code turnId-confirm}. */
+ public static String confirmationBlockId(String turnId) {
+ return turnId + "-confirm";
+ }
+
+ /** Subagent block ID: {@code parentTurnId-subagent-toolCallId}. */
+ public static String subagentBlockId(String parentTurnId, String toolCallId) {
+ return parentTurnId + "-subagent-" + toolCallId;
+ }
+
+ /** Subagent content-area ID (where the nested subagent turn's child blocks are inserted). */
+ public static String subagentContentAreaId(String parentTurnId, String toolCallId) {
+ return subagentBlockId(parentTurnId, toolCallId) + "-content";
+ }
+
+ /**
+ * Creates the outer turn container with header (avatar + name) and empty content div.
+ */
+ public String createTurnContainerHtmlBlock(String turnId, boolean isCopilot,
+ String avatarDataUri, String displayName) {
+ String containerId = isCopilot
+ ? copilotTurnContainerId(turnId) : userTurnContainerId(turnId);
+ String cssClass = isCopilot ? "turn turn-copilot" : "turn turn-user";
+ String avatar = (avatarDataUri != null && !avatarDataUri.isEmpty())
+ ? "".formatted(escapeHtml(avatarDataUri))
+ : "";
+ return """
+
%s\
+ %s
\
+
"""
+ .formatted(escapeHtml(containerId), cssClass, avatar,
+ escapeHtml(displayName), escapeHtml(contentBlockId(turnId, isCopilot)));
+ }
+
+ /**
+ * Creates a nested subagent block: a bordered card with a header (copilot avatar + title) and an
+ * inner content area into which the subagent turn's child blocks are inserted. Mirrors the SWT
+ * {@code SubagentTurnWidget} (copilot avatar + tool-call-derived title) and shares the
+ * {@code subagent-message-block} CSS class for visual parity with the other message cards.
+ */
+ public String createSubagentBlockHtmlBlock(String blockId, String contentAreaId,
+ String avatarDataUri, String title) {
+ String avatar = StringUtils.isNotBlank(avatarDataUri)
+ ? "".formatted(escapeHtml(avatarDataUri))
+ : "";
+ String titleText = escapeHtml(StringUtils.isNotBlank(title) ? title : "Subagent");
+ return """
+
\
+
%s%s
\
+
"""
+ .formatted(escapeHtml(blockId), avatar, titleText, escapeHtml(contentAreaId));
+ }
+
+ /** Creates a collapsible thinking block (open by default during streaming, with spinner). */
+ public String createThinkingHtmlBlock(String blockId, String thinkingText) {
+ return """
+
"""
+ .formatted(escapeHtml(blockId), SvgIcons.get(SvgIcons.Icon.THINKING_BULB),
+ summary, renderMarkdown(thinkingText));
+ }
+
+ /** Creates a restored thinking block (closed, no spinner, with optional title). */
+ public String createRestoredThinkingHtmlBlock(String blockId, ThinkingBlockData data) {
+ return createSealedThinkingHtmlBlock(blockId, data.getContent(), data.getTitle());
+ }
+
+ /** Creates a tool call status block from a live AgentToolCall. */
+ public String createToolCallHtmlBlock(String blockId, AgentToolCall toolCall) {
+ String progressMsg = toolCall.getProgressMessage();
+ String progressSpan = (progressMsg != null && !progressMsg.isEmpty())
+ ? " %s".formatted(escapeHtml(progressMsg))
+ : "";
+ return toolCallHtmlBlock(blockId, toolCall.getStatus(), toolCall.getName(), progressSpan);
+ }
+
+ /** Creates a tool call status block from persisted ToolCallData. */
+ public String createRestoredToolCallHtmlBlock(String blockId, ToolCallData tc) {
+ String progressMsg = tc.getProgressMessage();
+ String progressSpan = StringUtils.isNotBlank(progressMsg)
+ ? " %s".formatted(escapeHtml(progressMsg))
+ : "";
+ return toolCallHtmlBlock(blockId, tc.getStatus(), tc.getName(), progressSpan);
+ }
+
+ /**
+ * Builds a tool-call status block. The {@code progressSpan} is a pre-rendered, optional
+ * progress fragment (empty string when absent) so each caller keeps its own presence check.
+ */
+ private static String toolCallHtmlBlock(String blockId, String status, String name,
+ String progressSpan) {
+ return """
+
%s \
+ %s%s
"""
+ .formatted(escapeHtml(blockId), toolCallStatusClass(status),
+ toolCallIcon(status), escapeHtml(name), progressSpan);
+ }
+
+ /**
+ * Returns the icon HTML for a tool call status. Running state shows animated dots;
+ * completed shows a green checkmark; error shows a red cross.
+ *
+ *
Note: The "running" state is sent by the language server for in-progress tool calls.
+ * Most tool calls complete quickly, so the running indicator may only flash briefly.
+ */
+ private static String toolCallIcon(String status) {
+ if ("completed".equals(status)) {
+ return """
+ ✓""";
+ } else if ("error".equals(status)) {
+ return """
+ ✗""";
+ } else if ("cancelled".equals(status)) {
+ return """
+ ✗""";
+ }
+ // running or unknown — spinning circle (same as thinking spinner)
+ return """
+ \
+ """;
+ }
+
+ private static String toolCallStatusClass(String status) {
+ if ("completed".equals(status)) {
+ return " tc-completed";
+ } else if ("error".equals(status) || "cancelled".equals(status)) {
+ return " tc-failed";
+ }
+ return "";
+ }
+
+ /** Creates a copilot response block with rendered Markdown content. */
+ public String createCopilotReplyHtmlBlock(String blockId, String markdownText) {
+ String renderedHtml = renderMarkdown(markdownText);
+ return """
+
%s
""".formatted(escapeHtml(blockId), renderedHtml);
+ }
+
+ /** Creates a user request block with rendered Markdown content. */
+ public String createUserRequestHtmlBlock(String blockId, String markdownText) {
+ String renderedHtml = renderMarkdown(markdownText);
+ return """
+
%s
""".formatted(escapeHtml(blockId), renderedHtml);
+ }
+
+ /** Creates an error message block (simple, no action buttons). */
+ public String createErrorMessageHtmlBlock(String blockId, String errorMessage) {
+ return createWarningMessageHtmlBlock(blockId, errorMessage, List.of());
+ }
+
+ /**
+ * Creates a warning message block with an SVG warning icon, message text, and optional
+ * action buttons. Used for quota-exceeded (402) errors and generic turn-level errors.
+ *
+ * @param blockId unique DOM element ID
+ * @param message the warning/error message to display
+ * @param actions quota actions to render as buttons; empty list for no buttons
+ * @return self-contained HTML div block
+ */
+ public String createWarningMessageHtmlBlock(String blockId, String message,
+ List actions) {
+ String actionsBlock = "";
+ if (actions != null && !actions.isEmpty()) {
+ StringBuilder buttons = new StringBuilder();
+ for (QuotaAction action : actions) {
+ String cssClass = action.primary() ? "btn-confirm btn-primary" : "btn-confirm";
+ buttons.append("""
+ """
+ .formatted(cssClass, escapeHtml(action.url()),
+ escapeHtml(action.tooltip()), escapeHtml(action.label())));
+ }
+ actionsBlock = "
"
+ .formatted(escapeHtml(explanation.toString()));
+ }
+ }
+
+ // Action buttons: mirror the SWT split-dropdown layout. The primary accept action is
+ // rendered as a button; any additional accept actions are moved into a caret-triggered
+ // dropdown attached to it, and the dismiss action stays a separate button beside it.
+ StringBuilder actionsHtml = new StringBuilder();
+ List actions = content.getActions();
+ if (actions != null && !actions.isEmpty()) {
+ int primaryIndex = -1;
+ int dismissIndex = -1;
+ List alternativeIndexes = new ArrayList<>();
+ for (int i = 0; i < actions.size(); i++) {
+ ConfirmationAction action = actions.get(i);
+ if (!action.isAccept()) {
+ dismissIndex = i;
+ } else if (action.isPrimary() && primaryIndex < 0) {
+ primaryIndex = i;
+ } else {
+ alternativeIndexes.add(i);
+ }
+ }
+ // Fallback: if no accept action is flagged primary, promote the first accept action so
+ // the confirmation card always stays actionable.
+ if (primaryIndex < 0 && !alternativeIndexes.isEmpty()) {
+ primaryIndex = alternativeIndexes.remove(0);
+ }
+ if (primaryIndex >= 0) {
+ appendConfirmationSplitButton(actionsHtml, actions, primaryIndex, alternativeIndexes);
+ }
+ if (dismissIndex >= 0) {
+ ConfirmationAction dismiss = actions.get(dismissIndex);
+ actionsHtml.append("""
+ """
+ .formatted(escapeHtml(dismiss.getLabel())));
+ }
+ }
+
+ return """
+
\
+
%s%s
%s%s%s\
+
%s
"""
+ .formatted(escapeHtml(blockId), SvgIcons.get(SvgIcons.Icon.TERMINAL),
+ escapeHtml(content.getTitle()), message, commandPanel, explanationPanel,
+ actionsHtml.toString());
+ }
+
+ /**
+ * Appends the primary accept action as a button. When alternative accept actions exist,
+ * the button is wrapped in a split-button group with a caret that opens a dropdown menu
+ * listing those alternatives; otherwise a plain primary button is emitted.
+ */
+ private void appendConfirmationSplitButton(StringBuilder html,
+ List actions, int primaryIndex, List alternativeIndexes) {
+ ConfirmationAction primary = actions.get(primaryIndex);
+ if (alternativeIndexes.isEmpty()) {
+ html.append("""
+ """
+ .formatted(primaryIndex, escapeHtml(primary.getLabel())));
+ return;
+ }
+ html.append("""
+
");
+ }
+
+ /** Renders Markdown to HTML and injects code block action buttons. */
+ public String renderMarkdown(String markdown) {
+ if (markdown == null || markdown.isEmpty()) {
+ return "";
+ }
+ String html = htmlRenderer.render(markdownParser.parse(markdown));
+ return injectCodeBlockButtons(html);
+ }
+
+ /**
+ * Renders Markdown to HTML for an inline context such as a thinking block title inside a
+ * {@code }. Strips a single wrapping {@code
...
} (which commonmark always emits
+ * around a lone paragraph) so the result stays inline and does not break the summary layout.
+ * Does not inject code block action buttons.
+ */
+ public String renderMarkdownInline(String markdown) {
+ if (markdown == null || markdown.isEmpty()) {
+ return "";
+ }
+ String html = htmlRenderer.render(markdownParser.parse(markdown)).strip();
+ if (html.startsWith("
""".formatted(copyImg, insertImg);
+ return html.replace("", buttons + "");
+ }
+
+ /** 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/QuotaActions.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/QuotaActions.java
index 5c3a0137..35bc75a3 100644
--- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/QuotaActions.java
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/QuotaActions.java
@@ -7,6 +7,7 @@
import org.apache.commons.lang3.StringUtils;
+import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CheckQuotaResult;
import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CopilotPlan;
import com.microsoft.copilot.eclipse.ui.UiConstants;
import com.microsoft.copilot.eclipse.ui.i18n.Messages;
@@ -34,6 +35,51 @@ public record QuotaAction(String label, String tooltip, String url, boolean prim
private QuotaActions() {
}
+ /**
+ * The plan inputs needed to build quota actions, extracted from a {@link CheckQuotaResult}. Shared
+ * by the browser renderer and the StyledText {@link WarnWidget} path so the {@code CheckQuotaResult}
+ * decomposition lives in one place.
+ *
+ * @param plan the user's Copilot plan, or {@code null} when unknown
+ * @param overageEnabled {@code true} when additional paid usage is already enabled for the user
+ * @param canUpgradePlan whether the user can upgrade their plan, or {@code null} when the language
+ * server did not supply this field
+ */
+ public record QuotaPlanContext(CopilotPlan plan, boolean overageEnabled, Boolean canUpgradePlan) {
+
+ /** Extracts the plan inputs from a language-server {@link CheckQuotaResult}. */
+ public static QuotaPlanContext from(CheckQuotaResult quotaStatus) {
+ boolean overageEnabled = quotaStatus.premiumInteractions() != null
+ && quotaStatus.premiumInteractions().overagePermitted();
+ return new QuotaPlanContext(quotaStatus.copilotPlan(), overageEnabled,
+ quotaStatus.canUpgradePlan());
+ }
+ }
+
+ /**
+ * Resolves the {@link QuotaAction}s for a quota-exceeded error given the current quota status.
+ * Returns an empty list for non-{@code 402} errors, BYOK quota errors, when token-based billing is
+ * not enabled, or when {@code quotaStatus} is {@code null}. Otherwise delegates to
+ * {@link #forPlan(CopilotPlan, boolean, Boolean)} with the inputs from
+ * {@link QuotaPlanContext#from(CheckQuotaResult)}.
+ *
+ * @param quotaStatus the current quota status, or {@code null}
+ * @param code the language-server error code
+ * @param modelProviderName the BYOK model-provider name, or {@code null}
+ * @return an immutable, possibly empty list; never {@code null}
+ */
+ public static List forQuotaStatus(CheckQuotaResult quotaStatus, int code,
+ String modelProviderName) {
+ if (code != 402 || isByokQuotaExceeded(code, modelProviderName)) {
+ return List.of();
+ }
+ if (quotaStatus == null || !quotaStatus.tokenBasedBillingEnabled()) {
+ return List.of();
+ }
+ QuotaPlanContext context = QuotaPlanContext.from(quotaStatus);
+ return forPlan(context.plan(), context.overageEnabled(), context.canUpgradePlan());
+ }
+
/**
* Returns the ordered list of {@link QuotaAction}s appropriate for the supplied plan.
*
diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/StyledTextConversationWidget.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/StyledTextConversationWidget.java
new file mode 100644
index 00000000..394fd4c3
--- /dev/null
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/StyledTextConversationWidget.java
@@ -0,0 +1,307 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+package com.microsoft.copilot.eclipse.ui.chat;
+
+import java.util.concurrent.CompletableFuture;
+
+import org.apache.commons.lang3.StringUtils;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Control;
+
+import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction;
+import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent;
+import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentToolCall;
+import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatProgressValue;
+import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolConfirmationResult;
+import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolConfirmationResult.ToolConfirmationResult;
+import com.microsoft.copilot.eclipse.core.lsp.protocol.codingagent.CodingAgentMessageRequestParams;
+import com.microsoft.copilot.eclipse.core.persistence.AbstractTurnData;
+import com.microsoft.copilot.eclipse.core.persistence.ConversationDataFactory;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.AgentMessageData;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.EditAgentRoundData;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ErrorData;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ErrorMessageData;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ReplyData;
+import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ToolCallData;
+import com.microsoft.copilot.eclipse.core.persistence.UserTurnData;
+import com.microsoft.copilot.eclipse.ui.UiConstants;
+import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager;
+import com.microsoft.copilot.eclipse.ui.utils.SwtUtils;
+
+/**
+ * {@link IConversationWidget} implementation backed by the existing SWT {@link ChatContentViewer}
+ * using {@link StyledText} to render the content.
+ * This is a thin adapter that delegates all calls to the underlying viewer.
+ */
+public class StyledTextConversationWidget implements IConversationWidget {
+
+ private final ChatContentViewer viewer;
+
+ /**
+ * Creates a new {@link StyledTextConversationWidget} that internally creates a {@link ChatContentViewer}.
+ *
+ * @param parent the parent composite
+ * @param serviceManager the chat service manager
+ */
+ public StyledTextConversationWidget(Composite parent, ChatServiceManager serviceManager) {
+ this.viewer = new ChatContentViewer(parent, SWT.NONE, serviceManager);
+ }
+
+ /**
+ * Returns the underlying {@link ChatContentViewer} for SWT-specific operations that are not part
+ * of the {@link IConversationWidget} interface (e.g., {@code getTurnWidget()}).
+ */
+ public ChatContentViewer getChatContentViewer() {
+ return viewer;
+ }
+
+ @Override
+ public Control getControl() {
+ return viewer;
+ }
+
+ @Override
+ public boolean isDisposed() {
+ return viewer.isDisposed();
+ }
+
+ @Override
+ public void dispose() {
+ viewer.dispose();
+ }
+
+ @Override
+ public void requestLayout() {
+ viewer.requestLayout();
+ }
+
+ @Override
+ public void setConversationId(String conversationId) {
+ viewer.setConversationId(conversationId);
+ }
+
+ @Override
+ public void beginTurn(String turnId, boolean isCopilot, boolean isHistory) {
+ viewer.getLatestOrCreateNewTurnWidget(turnId, isCopilot, isHistory);
+ }
+
+ @Override
+ public void processTurnEvent(ChatProgressValue value) {
+ viewer.processTurnEvent(value);
+ }
+
+ @Override
+ public void startNewUserTurn(String turnId, String message) {
+ viewer.startNewTurn(turnId, message);
+ }
+
+ @Override
+ public void scrollToBottom() {
+ viewer.getDisplay().asyncExec(() -> {
+ if (viewer.isDisposed()) {
+ return;
+ }
+ viewer.refreshLayoutFull();
+ viewer.scrollToBottomIfAutoScroll();
+ });
+ }
+
+ @Override
+ public void refreshScrollerLayout() {
+ viewer.refreshLayoutFull();
+ }
+
+ @Override
+ public void renderErrorMessage(String content) {
+ viewer.renderErrorMessage(content);
+ }
+
+ @Override
+ public void showCompactingStatusOnLatestCopilotTurn() {
+ viewer.showCompactingStatusOnLatestCopilotTurn();
+ }
+
+ @Override
+ public void hideCompactingStatusOnLatestCopilotTurn() {
+ viewer.hideCompactingStatusOnLatestCopilotTurn();
+ }
+
+ @Override
+ public String getActiveThinkingBlockId(String turnId) {
+ return viewer.getActiveThinkingBlockId(turnId);
+ }
+
+ @Override
+ public void restoreTurn(AbstractTurnData turn, ConversationDataFactory dataFactory) {
+ if (turn == null) {
+ return;
+ }
+
+ // Subagent turns: render inside parent turn's subagent block
+ if (turn instanceof CopilotTurnData copilotTurn
+ && StringUtils.isNotBlank(copilotTurn.getParentTurnId())) {
+ BaseTurnWidget parentWidget = viewer.getTurnWidget(copilotTurn.getParentTurnId());
+ if (parentWidget != null) {
+ String toolCallId = copilotTurn.getSubagentToolCallId();
+ if (StringUtils.isNotBlank(toolCallId)) {
+ parentWidget.restoreSubagentContent(toolCallId, copilotTurn, dataFactory);
+ } else {
+ restoreCopilotTurnContent(copilotTurn, parentWidget, dataFactory);
+ }
+ }
+ return;
+ }
+
+ // User turn
+ if (turn instanceof UserTurnData userTurn) {
+ if (userTurn.getMessage() == null
+ || StringUtils.isNotBlank(userTurn.getMessage().getText())) {
+ BaseTurnWidget userTurnWidget =
+ viewer.getLatestOrCreateNewTurnWidget(turn.getTurnId(), false, true);
+ userTurnWidget.appendMessage(userTurn.getMessage().getText());
+ userTurnWidget.flushMessageBuffer();
+ }
+ return;
+ }
+
+ // Copilot turn
+ if (turn instanceof CopilotTurnData copilotTurn) {
+ BaseTurnWidget copilotTurnWidget =
+ viewer.getLatestOrCreateNewTurnWidget(turn.getTurnId(), true, true);
+ restoreCopilotTurnContent(copilotTurn, copilotTurnWidget, dataFactory);
+ copilotTurnWidget.flushMessageBuffer();
+
+ // Restore model info footer
+ ReplyData replyData = copilotTurn.getReply();
+ if (replyData != null && StringUtils.isNotBlank(replyData.getModelName())) {
+ renderModelInfo(turn.getTurnId(), replyData.getModelName(),
+ replyData.getBillingMultiplier(), replyData.getReasoningEffort());
+ }
+ }
+ }
+
+ @Override
+ public void renderModelInfo(String turnId, String modelName, double billingMultiplier,
+ String reasoningEffort) {
+ if (viewer.isDisposed()) {
+ return;
+ }
+ BaseTurnWidget turnWidget = viewer.getTurnWidget(turnId);
+ if (turnWidget instanceof CopilotTurnWidget copilotWidget) {
+ copilotWidget.renderModelInfo(modelName, billingMultiplier, reasoningEffort);
+ SwtUtils.invokeOnDisplayThreadAsync(viewer::refreshLayoutFull, viewer);
+ }
+ }
+
+ @Override
+ public void renderAgentMessage(CodingAgentMessageRequestParams params) {
+ if (viewer.isDisposed() || params == null) {
+ return;
+ }
+ SwtUtils.invokeOnDisplayThread(() -> {
+ BaseTurnWidget turnWidget = viewer.getTurnWidget(params.getTurnId());
+ if (turnWidget != null && !turnWidget.isDisposed()) {
+ turnWidget.createAgentMessageWidget(params);
+ }
+ }, viewer);
+ }
+
+ @Override
+ public CompletableFuture requestToolConfirmation(
+ String turnId, ConfirmationContent content, Object input) {
+ BaseTurnWidget turnWidget = viewer.getTurnWidget(turnId);
+ if (turnWidget == null) {
+ return CompletableFuture.completedFuture(
+ new LanguageModelToolConfirmationResult(ToolConfirmationResult.DISMISS));
+ }
+ BaseTurnWidget activeTurnWidget = turnWidget.getActiveTurnWidget();
+ CompletableFuture future =
+ activeTurnWidget.requestToolExecutionConfirmation(content, input);
+ viewer.refreshLayoutFull();
+ return future;
+ }
+
+ @Override
+ public void cancelToolConfirmation(String turnId) {
+ BaseTurnWidget turnWidget = viewer.getTurnWidget(turnId);
+ if (turnWidget != null) {
+ turnWidget.getActiveTurnWidget().cancelToolConfirmation();
+ }
+ }
+
+ @Override
+ public ConfirmationAction getLastSelectedConfirmationAction() {
+ // In the SWT implementation, the selected action is retrieved from the dialog directly
+ // by AgentToolService. Return null here; SWT path uses dialog.getSelectedAction().
+ return null;
+ }
+
+ private void restoreCopilotTurnContent(CopilotTurnData copilotTurn, BaseTurnWidget turnWidget,
+ ConversationDataFactory dataFactory) {
+ ReplyData replyData = copilotTurn.getReply();
+ if (replyData == null) {
+ return;
+ }
+
+ ThinkingTurnWidget thinkingWidget =
+ turnWidget instanceof ThinkingTurnWidget ? (ThinkingTurnWidget) turnWidget : null;
+
+ if (StringUtils.isNotBlank(replyData.getText())) {
+ turnWidget.appendMessage(replyData.getText());
+ }
+
+ if (replyData.getEditAgentRounds() != null && !replyData.getEditAgentRounds().isEmpty()) {
+ for (EditAgentRoundData round : replyData.getEditAgentRounds()) {
+ if (thinkingWidget != null && round.getThinkingBlock() != null) {
+ thinkingWidget.restoreThinkingBlock(round.getThinkingBlock());
+ }
+ if (round.getReply() != null && !round.getReply().isEmpty()) {
+ turnWidget.appendMessage(round.getReply());
+ }
+ if (round.getToolCalls() != null && !round.getToolCalls().isEmpty()) {
+ for (ToolCallData toolCallData : round.getToolCalls()) {
+ AgentToolCall agentToolCall =
+ dataFactory.convertToolCallDataToAgentToolCall(toolCallData);
+ turnWidget.appendToolCallStatus(agentToolCall);
+ }
+ }
+ }
+ }
+
+ // Flush buffered text before creating error/agent widgets so that reply text
+ // always appears above them in the layout.
+ turnWidget.flushMessageBuffer();
+
+ if (replyData.getErrorMessages() != null && !replyData.getErrorMessages().isEmpty()) {
+ for (ErrorMessageData errorMessageData : replyData.getErrorMessages()) {
+ ErrorData errorData = errorMessageData.getError();
+ SwtUtils.invokeOnDisplayThread(() -> {
+ String errorMessage = errorData != null
+ ? errorData.getMessage() : Messages.chat_warnWidget_defaultErrorMsg;
+ int errorCode = errorData != null ? errorData.getCode() : 0;
+ String modelProviderName = errorData != null ? errorData.getModelProviderName() : null;
+ turnWidget.createWarnDialog(errorMessage, errorCode, modelProviderName);
+ }, viewer);
+ }
+ }
+
+ if (replyData.getAgentMessages() != null && !replyData.getAgentMessages().isEmpty()) {
+ for (AgentMessageData agentMessageData : replyData.getAgentMessages()) {
+ if (StringUtils.equals(agentMessageData.getAgentSlug(),
+ UiConstants.GITHUB_COPILOT_CODING_AGENT_SLUG)) {
+ SwtUtils.invokeOnDisplayThread(() -> {
+ CodingAgentMessageRequestParams params = new CodingAgentMessageRequestParams();
+ params.setTitle(agentMessageData.getTitle());
+ params.setDescription(agentMessageData.getDescription());
+ params.setPrLink(agentMessageData.getPrLink());
+ params.setTurnId(copilotTurn.getTurnId());
+ turnWidget.createAgentMessageWidget(params);
+ }, viewer);
+ }
+ }
+ }
+ }
+}
diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/SvgIcons.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/SvgIcons.java
new file mode 100644
index 00000000..9820f1ab
--- /dev/null
+++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/SvgIcons.java
@@ -0,0 +1,235 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+package com.microsoft.copilot.eclipse.ui.chat;
+
+import java.util.EnumMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.osgi.framework.Bundle;
+
+import com.microsoft.copilot.eclipse.core.utils.BundleUtils;
+import com.microsoft.copilot.eclipse.ui.CopilotUi;
+
+/**
+ * Registry of inline SVG icons used by the browser-based chat renderer.
+ *
+ *
Each icon lives as a standalone {@code .svg} file under {@code resources/html/icons/}
+ * containing only the icon geometry (path/line elements and a {@code viewBox}). At load time
+ * this registry injects the presentation attributes the chat DOM requires (CSS {@code class},
+ * sizing, {@code currentColor} theming, inline {@code style}) onto the root {@code