diff --git a/.github/skills/ui-action/REFERENCE.md b/.github/skills/ui-action/REFERENCE.md index 65d4fb3e..3811a2cf 100644 --- a/.github/skills/ui-action/REFERENCE.md +++ b/.github/skills/ui-action/REFERENCE.md @@ -34,6 +34,29 @@ The module-only shortcut can fail dependency resolution or reuse stale bundles, especially after source edits. If widget markers or other code changes appear to be ignored, run the root `clean verify` command. +### Selecting the chat renderer + +The plugin ships two chat renderers behind the `useBrowserBasedChatRenderer` +preference, and probes can target either one via the `-Dprobe.renderer` launch +property (read once by `ProbeRunner` before the ChatView opens): + +- omit it, or pass `-Dprobe.renderer=styledtext`, to run against the seasoned + StyledText renderer (the production default). These probes assert the SWT + widget tree (`user-turn`, `copilot-turn`, `model-info-label`). +- pass `-Dprobe.renderer=browser` to run against the browser-based (HTML) + renderer. Its conversation content lives in an SWT `Browser` DOM that is opaque + to SWTBot widget locators, so those probes assert the `Browser` widget exists + and fall back to `sleep` + `screenshot` for visual verification. + +Because the renderer is chosen per launch, cover both by running the two probes: + +```bash +# StyledText renderer (default) +./mvnw clean verify -Dprobe.script=probe-scripts/chat-send-receive-001.json +# Browser renderer +./mvnw clean verify -Dprobe.script=probe-scripts/chat-send-receive-browser-001.json -Dprobe.renderer=browser +``` + Platform notes: - Linux headless: prefix the command with `xvfb-run -a`. diff --git a/base.target b/base.target index a6fc6705..689e43e8 100644 --- a/base.target +++ b/base.target @@ -15,6 +15,19 @@ + + + + + + + + + + + + + diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java index 176bc245..e613cbcc 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java @@ -53,6 +53,7 @@ private Constants() { public static final String AUTO_BREAKPOINT_RESPONSE = "autoBreakpointResponse"; public static final String GITHUB_JOBS_VIEW_ID = "com.microsoft.copilot.eclipse.ui.jobs.JobsView"; public static final String SUPPRESS_TERMINAL_DEPENDENCY_DIALOG = "suppressTerminalDependencyDialog"; + public static final String USE_BROWSER_BASED_CHAT_RENDERER = "useBrowserBasedChatRenderer"; // Auto-Approve settings public static final String AUTO_APPROVE_TERMINAL_RULES = "autoApproveTerminalRules"; diff --git a/com.microsoft.copilot.eclipse.swtbot.test/probe-scripts/chat-send-receive-browser-001.json b/com.microsoft.copilot.eclipse.swtbot.test/probe-scripts/chat-send-receive-browser-001.json new file mode 100644 index 00000000..1d6f8063 --- /dev/null +++ b/com.microsoft.copilot.eclipse.swtbot.test/probe-scripts/chat-send-receive-browser-001.json @@ -0,0 +1,44 @@ +[ + { "action": "waitForIdle" }, + { "action": "screenshot", "id": "01-startup" }, + + { "action": "showView", "idRef": "com.microsoft.copilot.eclipse.ui.chat.ChatView" }, + { "action": "waitForIdle" }, + { "action": "screenshot", "id": "02-chat-open" }, + + { "action": "assertExists", + "locator": { "by": "viewId", "id": "com.microsoft.copilot.eclipse.ui.chat.ChatView" } }, + + { "action": "waitFor", + "locator": { "by": "widgetClass", "value": "Browser" }, + "timeoutSec": 30 }, + + { "action": "waitFor", + "locator": { "by": "styledText" }, + "timeoutSec": 30 }, + + { "action": "click", "locator": { "by": "styledText" } }, + { "action": "clearElement", "locator": { "by": "styledText" } }, + { "action": "typeIn", "locator": { "by": "styledText" }, "text": "hello" }, + { "action": "screenshot", "id": "03-typed" }, + + { "action": "dumpUi", "id": "03b-pre-model-poll" }, + { "action": "screenshot", "id": "03c-pre-model-poll" }, + + { "action": "waitForMethod", + "locator": { "by": "widgetId", "value": "model-picker" }, + "method": "getSelectedItemId", + "timeoutSec": 60 }, + + { "action": "waitFor", + "locator": { "by": "buttonWithTooltip", "tooltip": "Send" }, + "timeoutSec": 30 }, + { "action": "click", "locator": { "by": "buttonWithTooltip", "tooltip": "Send" } }, + + { "action": "sleep", "timeoutSec": 5 }, + { "action": "screenshot", "id": "04-user-message-sent" }, + + { "action": "sleep", "timeoutSec": 90 }, + { "action": "screenshot", "id": "05-agent-response" }, + { "action": "dumpUi", "id": "06-final" } +] diff --git a/com.microsoft.copilot.eclipse.swtbot.test/src/com/microsoft/copilot/eclipse/swtbot/test/probe/ProbeRunner.java b/com.microsoft.copilot.eclipse.swtbot.test/src/com/microsoft/copilot/eclipse/swtbot/test/probe/ProbeRunner.java index f64e037c..be3ff8da 100644 --- a/com.microsoft.copilot.eclipse.swtbot.test/src/com/microsoft/copilot/eclipse/swtbot/test/probe/ProbeRunner.java +++ b/com.microsoft.copilot.eclipse.swtbot.test/src/com/microsoft/copilot/eclipse/swtbot/test/probe/ProbeRunner.java @@ -48,6 +48,7 @@ @RunWith(SWTBotJunit4ClassRunner.class) public class ProbeRunner { private static final String SYSPROP = "probe.script"; + private static final String RENDERER_SYSPROP = "probe.renderer"; private static final Path RESULTS_DIR = Paths.get("target", "probe-results"); private static final String UI_BUNDLE_ID = "com.microsoft.copilot.eclipse.ui"; @@ -77,6 +78,13 @@ private static void suppressNuisancePreferences() { prefs.putBoolean(Constants.AUTO_SHOW_WHAT_IS_NEW, false); prefs.put(Constants.LAST_USED_COPILOT_PLUGIN_VERSION, currentUiBundleMajorMinor()); prefs.putBoolean(Constants.SUPPRESS_TERMINAL_DEPENDENCY_DIALOG, true); + // Select the chat renderer per launch so the same probe flow can be run against both + // renderers: pass -Dprobe.renderer=browser for the browser-based (HTML) renderer, or + // omit it (or -Dprobe.renderer=styledtext) for the seasoned StyledText renderer. The + // default matches production (StyledText), so plain runs exercise the SWT widget tree. + boolean useBrowserRenderer = + "browser".equalsIgnoreCase(System.getProperty(RENDERER_SYSPROP, "styledtext")); + prefs.putBoolean(Constants.USE_BROWSER_BASED_CHAT_RENDERER, useBrowserRenderer); prefs.flush(); } catch (BackingStoreException | RuntimeException e) { System.err.println("[ProbeRunner] Failed to preset nuisance-dialog preferences: " + e); diff --git a/com.microsoft.copilot.eclipse.terminal.api/build.properties b/com.microsoft.copilot.eclipse.terminal.api/build.properties index f38581b9..8f50fdba 100644 --- a/com.microsoft.copilot.eclipse.terminal.api/build.properties +++ b/com.microsoft.copilot.eclipse.terminal.api/build.properties @@ -1,5 +1,4 @@ source.. = src/ -output.. = bin/ bin.includes = META-INF/,\ .,\ scripts/ diff --git a/com.microsoft.copilot.eclipse.ui.jobs/.settings/org.eclipse.jdt.core.prefs b/com.microsoft.copilot.eclipse.ui.jobs/.settings/org.eclipse.jdt.core.prefs index 20cc7b58..fa415ae8 100644 --- a/com.microsoft.copilot.eclipse.ui.jobs/.settings/org.eclipse.jdt.core.prefs +++ b/com.microsoft.copilot.eclipse.ui.jobs/.settings/org.eclipse.jdt.core.prefs @@ -4,7 +4,7 @@ org.eclipse.jdt.core.compiler.compliance=17 org.eclipse.jdt.core.compiler.problem.assertIdentifier=error org.eclipse.jdt.core.compiler.problem.enablePreviewFeatures=disabled org.eclipse.jdt.core.compiler.problem.enumIdentifier=error -org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning +org.eclipse.jdt.core.compiler.problem.forbiddenReference=error org.eclipse.jdt.core.compiler.problem.reportPreviewFeatures=warning org.eclipse.jdt.core.compiler.release=enabled org.eclipse.jdt.core.compiler.source=17 diff --git a/com.microsoft.copilot.eclipse.ui.jobs/build.properties b/com.microsoft.copilot.eclipse.ui.jobs/build.properties index 18012dbe..db5288f2 100644 --- a/com.microsoft.copilot.eclipse.ui.jobs/build.properties +++ b/com.microsoft.copilot.eclipse.ui.jobs/build.properties @@ -1,5 +1,4 @@ source.. = src/ -output.. = bin/ bin.includes = META-INF/,\ .,\ plugin.xml,\ diff --git a/com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF index 9d33676c..a587f20a 100644 --- a/com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.ui.test/META-INF/MANIFEST.MF @@ -29,4 +29,5 @@ Require-Bundle: org.mockito.mockito-core;bundle-version="5.14.2", org.eclipse.e4.core.services;bundle-version="2.4.200", org.osgi.service.event, com.google.gson, - com.microsoft.copilot.eclipse.terminal.api;bundle-version="0.20.0" + com.microsoft.copilot.eclipse.terminal.api;bundle-version="0.20.0", + org.eclipse.e4.ui.css.swt.theme;bundle-version="0.14.500" diff --git a/com.microsoft.copilot.eclipse.ui.test/plugin.xml b/com.microsoft.copilot.eclipse.ui.test/plugin.xml index fd3fba17..f1696761 100644 --- a/com.microsoft.copilot.eclipse.ui.test/plugin.xml +++ b/com.microsoft.copilot.eclipse.ui.test/plugin.xml @@ -23,4 +23,29 @@ name="Editor for Refactor Rename Tests"/> + + + + + + + + + + + + + + diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidgetBehaviorTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidgetBehaviorTest.java new file mode 100644 index 00000000..f3466db9 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidgetBehaviorTest.java @@ -0,0 +1,649 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import org.eclipse.lsp4j.WorkDoneProgressKind; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Shell; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import com.microsoft.copilot.eclipse.core.AuthStatusManager; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentRound; +import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentToolCall; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatProgressValue; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationError; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolConfirmationResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolConfirmationResult.ToolConfirmationResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.Thinking; +import com.microsoft.copilot.eclipse.core.persistence.ConversationDataFactory; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.EditAgentRoundData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ErrorData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ErrorMessageData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ReplyData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ThinkingBlockData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ThinkingBlockState; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ToolCallData; +import com.microsoft.copilot.eclipse.core.persistence.UserTurnData; +import com.microsoft.copilot.eclipse.core.persistence.UserTurnData.MessageData; + +/** + * Behavior tests for {@link BrowserConversationWidget} covering turn lifecycle, + * state tracking, tool confirmation logic, and error handling. + * + *

These tests run as Eclipse plugin tests (the widget constructor requires the + * CopilotUi bundle to be active for icon loading). The browser page may not fully + * load in the headless test environment, but state-tracking logic and the script + * queuing path are still exercisable. + */ +class BrowserConversationWidgetBehaviorTest { + + private Shell shell; + private BrowserConversationWidget widget; + + @BeforeEach + void setUp() { + Display display = Display.getDefault(); + display.syncExec(() -> { + shell = new Shell(display); + widget = new BrowserConversationWidget(shell); + }); + } + + @AfterEach + void tearDown() { + Display.getDefault().syncExec(() -> { + if (widget != null && !widget.isDisposed()) { + widget.dispose(); + } + if (shell != null && !shell.isDisposed()) { + shell.dispose(); + } + }); + } + + @Nested + class TurnLifecycleTests { + + @Test + void processTurnEvent_thinking_setsActiveThinkingBlockId() { + widget.beginTurn("turn-1", true, false); + + ChatProgressValue event = new ChatProgressValue(); + event.setKind(WorkDoneProgressKind.report); + event.setTurnId("turn-1"); + event.setThinking(new Thinking("think-1", "Analyzing...", null)); + widget.processTurnEvent(event); + + assertNotNull(widget.getActiveThinkingBlockId("turn-1")); + } + + @Test + void processTurnEvent_multipleThinkingChunks_maintainsSameBlockId() { + widget.beginTurn("turn-1", true, false); + + ChatProgressValue event1 = new ChatProgressValue(); + event1.setKind(WorkDoneProgressKind.report); + event1.setTurnId("turn-1"); + event1.setThinking(new Thinking("think-1", "First chunk. ", null)); + widget.processTurnEvent(event1); + String blockId1 = widget.getActiveThinkingBlockId("turn-1"); + + ChatProgressValue event2 = new ChatProgressValue(); + event2.setKind(WorkDoneProgressKind.report); + event2.setTurnId("turn-1"); + event2.setThinking(new Thinking("think-1", "Second chunk.", null)); + widget.processTurnEvent(event2); + String blockId2 = widget.getActiveThinkingBlockId("turn-1"); + + assertEquals(blockId1, blockId2, "Subsequent thinking chunks should use same block"); + } + + @Test + void processTurnEvent_thinkingSealedByReplyInSameEvent() { + widget.beginTurn("turn-1", true, false); + + // First: establish a thinking block + ChatProgressValue thinkEvent = new ChatProgressValue(); + thinkEvent.setKind(WorkDoneProgressKind.report); + thinkEvent.setTurnId("turn-1"); + thinkEvent.setThinking(new Thinking("think-1", "Planning...", null)); + widget.processTurnEvent(thinkEvent); + assertNotNull(widget.getActiveThinkingBlockId("turn-1")); + + // Now: event with both thinking continuation AND a reply (triggers seal) + ChatProgressValue sealEvent = new ChatProgressValue(); + sealEvent.setKind(WorkDoneProgressKind.report); + sealEvent.setTurnId("turn-1"); + sealEvent.setThinking(new Thinking("think-1", "Done planning.", null)); + sealEvent.setReply("Here is the answer."); + widget.processTurnEvent(sealEvent); + + // Thinking block should be sealed (cleared from active tracking) + assertNull(widget.getActiveThinkingBlockId("turn-1")); + } + + @Test + void processTurnEvent_end_clearsThinkingState() { + widget.beginTurn("turn-1", true, false); + + ChatProgressValue thinkEvent = new ChatProgressValue(); + thinkEvent.setKind(WorkDoneProgressKind.report); + thinkEvent.setTurnId("turn-1"); + thinkEvent.setThinking(new Thinking("think-1", "Thinking...", null)); + widget.processTurnEvent(thinkEvent); + + ChatProgressValue endEvent = new ChatProgressValue(); + endEvent.setKind(WorkDoneProgressKind.end); + endEvent.setTurnId("turn-1"); + widget.processTurnEvent(endEvent); + + assertNull(widget.getActiveThinkingBlockId("turn-1")); + } + + @Test + void processTurnEvent_differentTurnId_tracksThinkingPerTurn() { + widget.beginTurn("turn-1", true, false); + + ChatProgressValue thinkEvent = new ChatProgressValue(); + thinkEvent.setKind(WorkDoneProgressKind.report); + thinkEvent.setTurnId("turn-1"); + thinkEvent.setThinking(new Thinking("think-1", "Thinking...", null)); + widget.processTurnEvent(thinkEvent); + String turn1Thinking = widget.getActiveThinkingBlockId("turn-1"); + assertNotNull(turn1Thinking); + + // A report for a different turn must not disturb turn-1's per-turn state. + ChatProgressValue otherEvent = new ChatProgressValue(); + otherEvent.setKind(WorkDoneProgressKind.report); + otherEvent.setTurnId("turn-2"); + otherEvent.setReply("Hello"); + widget.processTurnEvent(otherEvent); + + assertEquals(turn1Thinking, widget.getActiveThinkingBlockId("turn-1"), + "Each turn tracks its own thinking block independently"); + assertNull(widget.getActiveThinkingBlockId("turn-2"), + "turn-2 produced only a reply, so it has no thinking block"); + } + + @Test + void processTurnEvent_toolCallFollowedByReply_transitionsBlockType() { + widget.beginTurn("turn-1", true, false); + + // Tool call event (AgentToolCall/AgentRound have no setters — use reflection) + AgentToolCall toolCall = new AgentToolCall(); + setField(toolCall, "id", "tc-1"); + setField(toolCall, "name", "file_search"); + setField(toolCall, "status", "running"); + AgentRound round = new AgentRound(); + setField(round, "toolCalls", List.of(toolCall)); + + ChatProgressValue toolEvent = new ChatProgressValue(); + toolEvent.setKind(WorkDoneProgressKind.report); + toolEvent.setTurnId("turn-1"); + setField(toolEvent, "editAgentRounds", List.of(round)); + widget.processTurnEvent(toolEvent); + + // Reply event following tool call + ChatProgressValue replyEvent = new ChatProgressValue(); + replyEvent.setKind(WorkDoneProgressKind.report); + replyEvent.setTurnId("turn-1"); + replyEvent.setReply("Here are the results."); + widget.processTurnEvent(replyEvent); + + // No exception = state transition worked correctly + assertNull(widget.getActiveThinkingBlockId("turn-1")); + } + + @Test + void processTurnEvent_errorMessageDuringStreaming() { + widget.beginTurn("turn-1", true, false); + + // An error arrives on a report event (the streaming error path) + ChatProgressValue errorEvent = new ChatProgressValue(); + errorEvent.setKind(WorkDoneProgressKind.report); + errorEvent.setTurnId("turn-1"); + ConversationError error = new ConversationError(); + error.setMessage("Context window exceeded"); + error.setCode(400); + errorEvent.setConversationError(error); + widget.processTurnEvent(errorEvent); + + // No exception; error is rendered as warning block + } + + @Test + void processTurnEvent_errorOnEndEvent() { + widget.beginTurn("turn-1", true, false); + + ChatProgressValue endEvent = new ChatProgressValue(); + endEvent.setKind(WorkDoneProgressKind.end); + endEvent.setTurnId("turn-1"); + ConversationError error = new ConversationError(); + error.setMessage("Server disconnected"); + error.setCode(500); + endEvent.setConversationError(error); + widget.processTurnEvent(endEvent); + + // Error on end should still be processed without exception + } + } + + @Nested + class ToolConfirmationTests { + + @Test + void requestToolConfirmation_returnsPendingFuture() { + widget.beginTurn("turn-1", true, false); + + ConfirmationContent content = new ConfirmationContent( + "Run command?", "Execute npm install", + List.of(ConfirmationAction.allowOnce("Allow"), + ConfirmationAction.skip("Deny"))); + + CompletableFuture future = + widget.requestToolConfirmation("turn-1", content, null); + + assertNotNull(future); + assertFalse(future.isDone()); + } + + @Test + void cancelToolConfirmation_completesFutureWithDismiss() throws Exception { + widget.beginTurn("turn-1", true, false); + + ConfirmationContent content = new ConfirmationContent( + "Run command?", "Execute npm install", + List.of(ConfirmationAction.allowOnce("Allow"))); + + CompletableFuture future = + widget.requestToolConfirmation("turn-1", content, null); + + widget.cancelToolConfirmation("turn-1"); + + assertTrue(future.isDone()); + // getResult() is the LSP wire value ("dismiss"), not the enum constant. + assertEquals(ToolConfirmationResult.DISMISS.getValue(), + future.get(1, TimeUnit.SECONDS).getResult()); + } + + @Test + void requestToolConfirmation_cancelsPreviousPendingConfirmation() throws Exception { + widget.beginTurn("turn-1", true, false); + + ConfirmationContent content1 = new ConfirmationContent( + "First?", "First action", + List.of(ConfirmationAction.allowOnce("Allow"))); + CompletableFuture future1 = + widget.requestToolConfirmation("turn-1", content1, null); + + ConfirmationContent content2 = new ConfirmationContent( + "Second?", "Second action", + List.of(ConfirmationAction.allowOnce("Allow"))); + CompletableFuture future2 = + widget.requestToolConfirmation("turn-1", content2, null); + + assertTrue(future1.isDone(), "First future should be auto-cancelled"); + assertFalse(future2.isDone(), "Second future should still be pending"); + // getResult() is the LSP wire value ("dismiss"), not the enum constant. + assertEquals(ToolConfirmationResult.DISMISS.getValue(), + future1.get(1, TimeUnit.SECONDS).getResult()); + } + + @Test + void cancelToolConfirmation_whenNoPending_doesNotThrow() { + assertDoesNotThrow(() -> widget.cancelToolConfirmation("turn-1")); + } + + @Test + void getLastSelectedConfirmationAction_returnsNullBeforeAnyConfirmation() { + assertNull(widget.getLastSelectedConfirmationAction()); + } + } + + @Nested + class RestoreTurnTests { + + private ConversationDataFactory dataFactory; + + @BeforeEach + void setUpFactory() { + AuthStatusManager mockAuth = Mockito.mock(AuthStatusManager.class); + Mockito.when(mockAuth.getUserName()).thenReturn("test-user"); + dataFactory = new ConversationDataFactory(mockAuth); + } + + @Test + void restoreTurn_null_doesNotThrow() { + assertDoesNotThrow(() -> widget.restoreTurn(null, dataFactory)); + } + + @Test + void restoreTurn_userTurnWithNullMessage_doesNotRender() { + UserTurnData userTurn = new UserTurnData(); + userTurn.setTurnId("turn-null-msg"); + userTurn.setMessage(null); + assertDoesNotThrow(() -> widget.restoreTurn(userTurn, dataFactory)); + } + + @Test + void restoreTurn_copilotTurnWithParentTurnId_skipsRendering() { + CopilotTurnData copilotTurn = new CopilotTurnData(); + copilotTurn.setTurnId("turn-child"); + copilotTurn.setParentTurnId("turn-parent"); + assertDoesNotThrow(() -> widget.restoreTurn(copilotTurn, dataFactory)); + } + + @Test + void restoreTurn_fullCopilotTurnWithAllBlockTypes() { + CopilotTurnData copilotTurn = new CopilotTurnData(); + copilotTurn.setTurnId("turn-full"); + ReplyData reply = copilotTurn.getReply(); + reply.setText("Here is a **formatted** reply with `code`."); + reply.setModelName("GPT-5 mini"); + reply.setBillingMultiplier(1.0); + reply.setReasoningEffort("medium"); + + // Thinking block + ThinkingBlockData thinking = new ThinkingBlockData(); + thinking.setId("think-full"); + thinking.setContent("Analyzing requirements..."); + thinking.setTitle("Planning"); + thinking.setState(ThinkingBlockState.COMPLETED); + + // Tool calls + ToolCallData completedTool = new ToolCallData(); + completedTool.setId("tc-1"); + completedTool.setName("file_search"); + completedTool.setStatus("completed"); + completedTool.setProgressMessage("Found 5 results"); + + ToolCallData errorTool = new ToolCallData(); + errorTool.setId("tc-2"); + errorTool.setName("edit_file"); + errorTool.setStatus("error"); + errorTool.setProgressMessage("Permission denied"); + + EditAgentRoundData round = new EditAgentRoundData(); + round.setRoundId(1); + round.setThinkingBlock(thinking); + round.setToolCalls(List.of(completedTool, errorTool)); + round.setReply("After tool execution, here are more details."); + reply.setEditAgentRounds(List.of(round)); + + // Error messages + ErrorData errorData = new ErrorData(); + errorData.setMessage("Rate limited"); + errorData.setCode(429); + ErrorMessageData errorMsg = new ErrorMessageData(); + errorMsg.setError(errorData); + reply.setErrorMessages(List.of(errorMsg)); + + assertDoesNotThrow(() -> widget.restoreTurn(copilotTurn, dataFactory)); + } + + @Test + void restoreTurn_copilotTurnWithNullReplyData() { + CopilotTurnData copilotTurn = new CopilotTurnData(); + copilotTurn.setTurnId("turn-no-reply"); + // getReply() returns a new ReplyData with all nulls by default — + // but the text/model fields are blank so no content blocks are created + assertDoesNotThrow(() -> widget.restoreTurn(copilotTurn, dataFactory)); + } + } + + @Nested + class CompactingStatusTests { + + @Test + void showAndHideCompactingStatus_tracksLastCopilotTurnId() { + // Begin a copilot turn so lastCopilotTurnId is set + widget.beginTurn("turn-1", true, false); + + // These methods depend on lastCopilotTurnId being set correctly + assertDoesNotThrow(() -> widget.showCompactingStatusOnLatestCopilotTurn()); + assertDoesNotThrow(() -> widget.hideCompactingStatusOnLatestCopilotTurn()); + } + + @Test + void showCompactingStatus_beforeAnyTurn_doesNotThrow() { + // No turn started yet — should handle gracefully + assertDoesNotThrow(() -> widget.showCompactingStatusOnLatestCopilotTurn()); + } + + @Test + void showCompactingStatus_afterUserTurn_usesLastCopilotTurn() { + widget.beginTurn("turn-1", true, false); + widget.beginTurn("turn-2", false, false); // user turn + + // Should still reference turn-1 (last copilot turn) + assertDoesNotThrow(() -> widget.showCompactingStatusOnLatestCopilotTurn()); + assertDoesNotThrow(() -> widget.hideCompactingStatusOnLatestCopilotTurn()); + } + } + + @Nested + class WidgetDisposalDuringOperationsTests { + + @Test + void dispose_duringPendingConfirmation_doesNotThrow() { + widget.beginTurn("turn-1", true, false); + + ConfirmationContent content = new ConfirmationContent( + "Run?", "Command", + List.of(ConfirmationAction.allowOnce("Allow"))); + CompletableFuture future = + widget.requestToolConfirmation("turn-1", content, null); + + // Dispose while confirmation is pending (SWT disposal must run on the UI thread). + assertDoesNotThrow(() -> runOnUiThread(widget::dispose)); + // Future is left incomplete (no automatic completion on dispose) + // This is acceptable — the caller (ChatView) manages lifecycle + } + + @Test + void processTurnEvent_afterDispose_doesNotThrow() { + widget.beginTurn("turn-1", true, false); + runOnUiThread(widget::dispose); + + ChatProgressValue event = new ChatProgressValue(); + event.setKind(WorkDoneProgressKind.report); + event.setTurnId("turn-1"); + event.setReply("Should be ignored"); + + // executeScript checks isDisposed() — should not throw + assertDoesNotThrow(() -> widget.processTurnEvent(event)); + } + + @Test + void restoreTurn_afterDispose_doesNotThrow() { + runOnUiThread(widget::dispose); + + AuthStatusManager mockAuth = Mockito.mock(AuthStatusManager.class); + Mockito.when(mockAuth.getUserName()).thenReturn("test-user"); + ConversationDataFactory dataFactory = new ConversationDataFactory(mockAuth); + + UserTurnData userTurn = new UserTurnData(); + userTurn.setTurnId("turn-1"); + userTurn.setMessage(new MessageData("Hello")); + + assertDoesNotThrow(() -> widget.restoreTurn(userTurn, dataFactory)); + } + } + + @Nested + class RenderOperationTests { + + @Test + void renderErrorMessage_doesNotThrow() { + assertDoesNotThrow(() -> widget.renderErrorMessage("Something went wrong")); + } + + @Test + void renderErrorMessage_withMarkdownContent() { + assertDoesNotThrow( + () -> widget.renderErrorMessage("**Error**: `connection refused`")); + } + + @Test + void startNewUserTurn_doesNotThrow() { + assertDoesNotThrow( + () -> widget.startNewUserTurn("turn-1", "How do I write tests?")); + } + + @Test + void renderAgentMessage_withNullParams_doesNotThrow() { + assertDoesNotThrow(() -> widget.renderAgentMessage(null)); + } + } + + /** + * Subagent nesting: a subagent turn is rendered inside its parent copilot turn (driven by the + * parent's {@code run_subagent} tool call), mirroring the SWT {@code BaseTurnWidget}. Because + * streaming state is per-turn, the parent and subagent keep independent block counters, so their + * block ids never collide. + */ + @Nested + class SubagentNestingTests { + + @Test + void liveSubagentFlow_keepsParentAndSubagentStateIndependent() { + // Parent copilot turn starts and launches a subagent. + widget.beginTurn("parent", true, false); + widget.processTurnEvent( + toolCallReport("parent", null, "sub-tc", "run_subagent", "running")); + + // Subagent turn streams its own thinking, nested under the parent. + widget.beginTurn("sub", true, false); + widget.processTurnEvent(thinkingReport("sub", "parent", "Subagent planning...")); + String subThinking = widget.getActiveThinkingBlockId("sub"); + assertNotNull(subThinking, "Subagent turn tracks its own thinking block"); + assertTrue(subThinking.startsWith("sub-"), + "Subagent block ids derive from the subagent turn id (independent counter)"); + widget.processTurnEvent(endEvent("sub", "parent")); + + // Subagent completes; the parent resumes and continues thinking. + widget.processTurnEvent( + toolCallReport("parent", null, "sub-tc", "run_subagent", "completed")); + widget.processTurnEvent(thinkingReport("parent", null, "Back in the parent...")); + String parentThinking = widget.getActiveThinkingBlockId("parent"); + assertNotNull(parentThinking, "Parent turn keeps its own state after the subagent"); + assertTrue(parentThinking.startsWith("parent-"), + "Parent block ids stay under the parent turn id"); + assertNotEquals(subThinking, parentThinking, + "Parent and subagent block ids never collide"); + } + + @Test + void restore_subagentTurnNestedUnderParent_doesNotThrow() { + AuthStatusManager mockAuth = Mockito.mock(AuthStatusManager.class); + Mockito.when(mockAuth.getUserName()).thenReturn("test-user"); + ConversationDataFactory dataFactory = new ConversationDataFactory(mockAuth); + + // Parent turn persisted with a run_subagent tool call. + ToolCallData subToolCall = new ToolCallData(); + subToolCall.setId("sub-tc"); + subToolCall.setName("run_subagent"); + subToolCall.setStatus("completed"); + EditAgentRoundData parentRound = new EditAgentRoundData(); + parentRound.setToolCalls(List.of(subToolCall)); + parentRound.setReply("Delegating to a subagent."); + ReplyData parentReply = new ReplyData(); + parentReply.setEditAgentRounds(List.of(parentRound)); + CopilotTurnData parentTurn = new CopilotTurnData(); + parentTurn.setTurnId("parent"); + parentTurn.setReply(parentReply); + + // Subagent turn persisted with parentTurnId + subagentToolCallId. + ReplyData subReply = new ReplyData(); + subReply.setText("Subagent result."); + CopilotTurnData subTurn = new CopilotTurnData(); + subTurn.setTurnId("sub"); + subTurn.setParentTurnId("parent"); + subTurn.setSubagentToolCallId("sub-tc"); + subTurn.setReply(subReply); + + // Parent precedes subagent in persisted order (as ChatView restores them). + assertDoesNotThrow(() -> { + widget.restoreTurn(parentTurn, dataFactory); + widget.restoreTurn(subTurn, dataFactory); + }); + } + + private ChatProgressValue toolCallReport(String turnId, String parentTurnId, + String toolCallId, String toolName, String status) { + AgentToolCall toolCall = new AgentToolCall(); + setField(toolCall, "id", toolCallId); + setField(toolCall, "name", toolName); + setField(toolCall, "status", status); + AgentRound round = new AgentRound(); + setField(round, "toolCalls", List.of(toolCall)); + ChatProgressValue event = baseReport(turnId, parentTurnId); + setField(event, "editAgentRounds", List.of(round)); + return event; + } + + private ChatProgressValue thinkingReport(String turnId, String parentTurnId, String text) { + ChatProgressValue event = baseReport(turnId, parentTurnId); + event.setThinking(new Thinking("t", text, null)); + return event; + } + + private ChatProgressValue baseReport(String turnId, String parentTurnId) { + ChatProgressValue event = new ChatProgressValue(); + event.setKind(WorkDoneProgressKind.report); + event.setTurnId(turnId); + if (parentTurnId != null) { + event.setParentTurnId(parentTurnId); + } + return event; + } + + private ChatProgressValue endEvent(String turnId, String parentTurnId) { + ChatProgressValue event = new ChatProgressValue(); + event.setKind(WorkDoneProgressKind.end); + event.setTurnId(turnId); + if (parentTurnId != null) { + event.setParentTurnId(parentTurnId); + } + return event; + } + } + + /** Sets a private field via reflection (used for fields without public setters). */ + private static void setField(Object target, String fieldName, Object value) { + try { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (Exception e) { + throw new RuntimeException("Failed to set field " + fieldName, e); + } + } + + /** Runs an action on the SWT UI thread; SWT widget disposal must happen there. */ + private static void runOnUiThread(Runnable action) { + Display.getDefault().syncExec(action); + } +} + diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidgetEscapingTests.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidgetEscapingTests.java new file mode 100644 index 00000000..ad4ba94d --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidgetEscapingTests.java @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * Pure unit tests for {@link BrowserConversationWidget#escapeForJs(String)}. + * No SWT Display or widget needed. + */ +class BrowserConversationWidgetEscapingTests { + + @Test + void returnsEmptyForNull() { + assertEquals("", BrowserConversationWidget.escapeForJs(null)); + } + + @ParameterizedTest(name = "escapeForJs({0}) = {1}") + @MethodSource("escapeForJsCases") + void escapesCharactersCorrectly(String input, String expected) { + assertEquals(expected, BrowserConversationWidget.escapeForJs(input)); + } + + static Stream escapeForJsCases() { + return Stream.of( + Arguments.of("hello", "hello"), + Arguments.of("it's", "it\\'s"), + Arguments.of("back\\slash", "back\\\\slash"), + Arguments.of("line1\nline2", "line1\\nline2"), + Arguments.of("tab\there", "tab\\there"), + Arguments.of("cr\r", "cr\\r"), + Arguments.of("all\\\n\r\t'", "all\\\\\\n\\r\\t\\'") + ); + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidgetTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidgetTest.java new file mode 100644 index 00000000..63f89c38 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidgetTest.java @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Shell; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Basic platform integration test for {@link BrowserConversationWidget}. + * Verifies that the widget can be created and disposed without error in a real + * Eclipse platform environment (OSGi bundles active, Display available). + */ +class BrowserConversationWidgetTest { + + private Shell shell; + private BrowserConversationWidget widget; + + @BeforeEach + void setUp() { + Display display = Display.getDefault(); + display.syncExec(() -> { + shell = new Shell(display); + widget = new BrowserConversationWidget(shell); + }); + } + + @AfterEach + void tearDown() { + Display.getDefault().syncExec(() -> { + if (widget != null && !widget.isDisposed()) { + widget.dispose(); + } + if (shell != null && !shell.isDisposed()) { + shell.dispose(); + } + }); + } + + @Test + void widgetCreatesSuccessfully() { + assertNotNull(widget); + assertNotNull(widget.getControl()); + assertFalse(widget.isDisposed()); + } + + @Test + void widgetDisposesCleanly() { + // SWT widgets must be disposed on the UI thread, the way real callers do. + assertDoesNotThrow(() -> Display.getDefault().syncExec(() -> widget.dispose())); + } +} + diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ChatErrorMessagesTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ChatErrorMessagesTest.java new file mode 100644 index 00000000..00eb9289 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ChatErrorMessagesTest.java @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatProgressValue; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationError; +import com.microsoft.copilot.eclipse.ui.i18n.Messages; + +/** + * Unit tests for {@link ChatErrorMessages#resolveDisplayMessage}, which both conversation widgets + * rely on to display identical, normalized error text. + */ +class ChatErrorMessagesTest { + + @Test + void resolveDisplayMessage_nullValue_returnsEmpty() { + assertEquals("", ChatErrorMessages.resolveDisplayMessage(null)); + } + + @Test + void resolveDisplayMessage_stripsRequestIdSuffix() { + assertEquals("Something went wrong.", + ChatErrorMessages.resolveDisplayMessage(error("Something went wrong. | Request ID: abc-123", null))); + } + + @Test + void resolveDisplayMessage_stripsGitHubRequestIdSuffix() { + assertEquals("Rate limit reached.", + ChatErrorMessages.resolveDisplayMessage(error("Rate limit reached. GitHub Request ID: xyz.789.", null))); + } + + @Test + void resolveDisplayMessage_leavesPlainMessageUnchanged() { + assertEquals("Plain error.", ChatErrorMessages.resolveDisplayMessage(error("Plain error.", null))); + } + + @Test + void resolveDisplayMessage_modelNotSupported_mapsToFriendlyMessage() { + assertEquals(Messages.chat_model_unsupported_message, + ChatErrorMessages.resolveDisplayMessage(error("raw model error", "model_not_supported"))); + } + + private static ChatProgressValue error(String message, String reason) { + ConversationError error = new ConversationError(); + error.setMessage(message); + error.setReason(reason); + ChatProgressValue value = new ChatProgressValue(); + value.setConversationError(error); + return value; + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ConversationHtmlBlockFactoryTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ConversationHtmlBlockFactoryTest.java new file mode 100644 index 00000000..2a890403 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ConversationHtmlBlockFactoryTest.java @@ -0,0 +1,761 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; + +import com.google.gson.Gson; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentToolCall; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ThinkingBlockData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ToolCallData; + + +class ConversationHtmlBlockFactoryTest { + + private static final Gson GSON = new Gson(); + + private ConversationHtmlBlockFactory factory; + + @BeforeEach + void setUp() { + factory = new ConversationHtmlBlockFactory(); + } + + @Nested + class EscapeHtmlTests { + + @Test + void returnsEmptyStringForNull() { + assertEquals("", ConversationHtmlBlockFactory.escapeHtml(null)); + } + + @ParameterizedTest(name = "escapeHtml(\"{0}\") = \"{1}\"") + @CsvSource({ + "'"); + assertFalse(html.contains("", "completed", + "path with & special "); + String html = factory.createToolCallHtmlBlock("t1-tc-4", tc); + + assertTrue(html.contains("<script>"), "Tool name should be escaped"); + assertTrue(html.contains("& special <chars>"), + "Progress should be escaped"); + } + + @Test + void restoredToolCallBlock_matchesLiveToolCallBehavior() { + ToolCallData tc = new ToolCallData(); + tc.setName("readFile"); + tc.setStatus("completed"); + tc.setProgressMessage("src/main.java"); + + String html = factory.createRestoredToolCallHtmlBlock("t1-tc-5", tc); + + assertTrue(html.contains("tc-completed")); + assertTrue(html.contains("✓")); + assertTrue(html.contains("readFile")); + assertTrue(html.contains("src/main.java")); + } + } + + @Nested + class ModelInfoBlockTests { + + @Test + void rendersModelNameOnly_whenBillingIsOneAndNoEffort() { + String html = factory.createModelInfoHtmlBlock("b1", "GPT-5 mini", 1.0, null); + + assertTrue(html.contains("GPT-5 mini"), "Should show model name"); + assertFalse(html.contains("billing-multiplier"), + "Should omit billing when multiplier is 1.0"); + assertFalse(html.contains("reasoning-effort"), + "Should omit effort when null"); + } + + @Test + void includesBillingMultiplier_whenNotOne() { + String html = factory.createModelInfoHtmlBlock("b1", "gpt-4o", 2.0, null); + + assertTrue(html.contains("(2.0x)"), "Should show multiplier"); + assertTrue(html.contains("billing-multiplier")); + } + + @Test + void includesCapitalizedReasoningEffort() { + String html = factory.createModelInfoHtmlBlock("b1", "GPT-5", 1.0, "medium"); + + assertTrue(html.contains("- Medium"), "Should capitalize and prefix with dash"); + assertTrue(html.contains("reasoning-effort")); + } + + @Test + void omitsBillingMultiplier_whenZero() { + String html = factory.createModelInfoHtmlBlock("b1", "Model", 0, "high"); + + assertFalse(html.contains("billing-multiplier"), + "Should omit billing when multiplier is 0"); + assertTrue(html.contains("- High")); + } + + @Test + void omitsEffort_whenEmpty() { + String html = factory.createModelInfoHtmlBlock("b1", "Model", 1.0, ""); + + assertFalse(html.contains("reasoning-effort"), + "Should omit effort when empty string"); + } + } + + @Nested + class ConfirmationBlockTests { + + @Test + void rendersFullConfirmation_withCommandAndButtons() { + ConfirmationContent content = new ConfirmationContent( + "Run in terminal?", + "This will execute a command.", + List.of( + ConfirmationAction.allowOnce("Allow"), + ConfirmationAction.skip("Deny"))); + + Map input = Map.of( + "command", "npm install", + "explanation", "Installs dependencies"); + + String html = factory.createConfirmationHtmlBlock("confirm-1", content, input); + + assertTrue(html.contains("Run in terminal?"), "Should show title"); + assertTrue(html.contains("This will execute a command."), "Should show message"); + assertTrue(html.contains("npm install"), "Should show command"); + assertTrue(html.contains("Installs dependencies"), "Should show explanation"); + assertTrue(html.contains("btn-primary"), "Primary button should have class"); + assertTrue(html.contains("window.acceptToolAction(0)"), + "Accept button should call acceptToolAction"); + assertTrue(html.contains("window.dismissToolAction()"), + "Deny button should call dismissToolAction"); + assertTrue(html.contains("Allow"), "Should show accept label"); + assertTrue(html.contains("Deny"), "Should show deny label"); + } + + @Test + void omitsOptionalFields_whenAbsent() { + ConfirmationContent content = new ConfirmationContent( + "Confirm action?", null, List.of()); + + String html = factory.createConfirmationHtmlBlock("confirm-2", content, "not-a-map"); + + assertTrue(html.contains("Confirm action?"), "Should show title"); + assertFalse(html.contains("confirmation-message"), + "Should omit message div when null"); + assertFalse(html.contains("confirmation-command"), + "Should omit command when input is not a Map"); + } + + @Test + void escapesHtmlInTitleAndMessage() { + ConfirmationContent content = new ConfirmationContent( + "Title", "msg with & special", List.of()); + + String html = factory.createConfirmationHtmlBlock("confirm-3", content, null); + + assertTrue(html.contains("<b>Title</b>"), + "Title should be escaped"); + assertTrue(html.contains("msg with & special"), + "Message should be escaped"); + } + } + + @Nested + class ThinkingBlockTests { + + @Test + void activeThinkingBlock_isOpenWithSpinner() { + String html = factory.createThinkingHtmlBlock("think-1", "Analyzing code..."); + + assertTrue(html.contains("

"), "Should be open during streaming"); + assertTrue(html.contains("thinking-spinner"), "Should show spinner"); + assertTrue(html.contains("Thinking…"), "Should have 'Thinking...' summary"); + assertTrue(html.contains("Analyzing code..."), "Should include thinking text"); + assertTrue(html.contains("class=\"thinking-block\"")); + } + + @Test + void sealedThinkingBlock_isClosedWithCustomTitle() { + String html = factory.createSealedThinkingHtmlBlock( + "think-2", "Done.", "Analysis Complete"); + + assertTrue(html.contains("
"), "Should have
"); + assertFalse(html.contains("
"), "Should NOT be open"); + assertFalse(html.contains("thinking-spinner"), "Should NOT show spinner"); + assertTrue(html.contains("Analysis Complete"), "Should show custom title"); + assertTrue(html.contains("Done."), "Should have content"); + } + + @Test + void sealedThinkingBlock_usesDefaultTitle_whenBlank() { + String html = factory.createSealedThinkingHtmlBlock("think-3", "text", null); + assertTrue(html.contains("Thinking…"), + "Should fall back to default title when null"); + + html = factory.createSealedThinkingHtmlBlock("think-4", "text", ""); + assertTrue(html.contains("Thinking…"), + "Should fall back to default title when empty"); + } + + @Test + void restoredThinkingBlock_delegatesToSealed() { + ThinkingBlockData data = new ThinkingBlockData("tb-1", "Analyzing..."); + data.setTitle("Code Analysis"); + + String html = factory.createRestoredThinkingHtmlBlock("think-5", data); + + assertTrue(html.contains("Code Analysis")); + assertTrue(html.contains("
")); + assertFalse(html.contains("
")); + assertTrue(html.contains("Analyzing...")); + } + + @Test + void escapesHtmlInThinkingContent() { + String html = factory.createThinkingHtmlBlock("think-6", "if (a < b) { }"); + assertTrue(html.contains("a < b"), "Should escape HTML in thinking text"); + } + } + + @Nested + class TurnContainerAndOtherBlockTests { + + @Test + void createTurnContainerHtmlBlock_copilotTurn() { + String html = factory.createTurnContainerHtmlBlock( + "turn-123", true, "data:image/png;base64,ABC", "GitHub Copilot"); + + assertTrue(html.contains("id=\"turn-123-copilot\"")); + assertTrue(html.contains("turn-copilot")); + assertTrue(html.contains("turn-avatar")); + assertTrue(html.contains("GitHub Copilot")); + assertTrue(html.contains("id=\"turn-123-copilot-content\"")); + } + + @Test + void createTurnContainerHtmlBlock_userTurn() { + String html = factory.createTurnContainerHtmlBlock( + "turn-456", false, null, "TestUser"); + + assertTrue(html.contains("id=\"turn-456-user\"")); + assertTrue(html.contains("turn-user")); + assertTrue(html.contains("TestUser")); + assertTrue(html.contains("id=\"turn-456-user-content\"")); + assertFalse(html.contains("turn-avatar"), + "Should omit avatar img when dataUri is null"); + } + + @Test + void sameTurnId_producesDifferentContainerIds() { + String user = factory.createTurnContainerHtmlBlock("t1", false, null, "U"); + String copilot = factory.createTurnContainerHtmlBlock("t1", true, null, "C"); + + assertTrue(user.contains("id=\"t1-user\"")); + assertTrue(copilot.contains("id=\"t1-copilot\"")); + assertFalse(user.contains("-copilot")); + assertFalse(copilot.contains("-user\"")); + } + + @Test + void createCopilotReplyHtmlBlock_rendersMarkdown() { + String html = factory.createCopilotReplyHtmlBlock("b1", "**hello**"); + + assertTrue(html.contains("id=\"b1\"")); + assertTrue(html.contains("class=\"response\"")); + assertTrue(html.contains("hello")); + } + + @Test + void createUserRequestHtmlBlock_rendersMarkdown() { + String html = factory.createUserRequestHtmlBlock("b2", "How do I **sort**?"); + + assertTrue(html.contains("id=\"b2\"")); + assertTrue(html.contains("class=\"user-request\"")); + assertTrue(html.contains("sort")); + } + + @Test + void createErrorMessageHtmlBlock() { + String html = factory.createErrorMessageHtmlBlock("e1", "Rate limit exceeded"); + + assertTrue(html.contains("id=\"e1\"")); + assertTrue(html.contains("class=\"warning-message\"")); + assertTrue(html.contains("Rate limit exceeded")); + } + + @Test + void createWarningMessageHtmlBlockWithActions() { + List actions = List.of( + new QuotaActions.QuotaAction("Upgrade Plan", "Upgrade tooltip", + "https://example.com/upgrade", true), + new QuotaActions.QuotaAction("Enable Usage", "Usage tooltip", + "https://example.com/usage", false)); + + String html = factory.createWarningMessageHtmlBlock("w1", "Quota exceeded", actions); + + assertTrue(html.contains("id=\"w1\"")); + assertTrue(html.contains("class=\"warning-message\"")); + assertTrue(html.contains("Quota exceeded")); + assertTrue(html.contains("class=\"warning-actions\"")); + assertTrue(html.contains("btn-confirm btn-primary")); + assertTrue(html.contains("Upgrade Plan")); + assertTrue(html.contains("Enable Usage")); + assertTrue(html.contains("copilotAction('openLink','")); + } + + @Test + void createWarningMessageHtmlBlockWithoutActions() { + String html = factory.createWarningMessageHtmlBlock("w2", "Generic error", List.of()); + + assertTrue(html.contains("id=\"w2\"")); + assertTrue(html.contains("class=\"warning-message\"")); + assertTrue(html.contains("Generic error")); + assertFalse(html.contains("warning-actions")); + } + + @Test + void createStreamingIndicatorHtmlBlock() { + String html = factory.createStreamingIndicatorHtmlBlock("s1"); + + assertTrue(html.contains("id=\"s1\"")); + assertTrue(html.contains("class=\"streaming-indicator\"")); + assertTrue(html.contains("class=\"dot\"")); + } + + @Test + void createCompactingStatusHtmlBlock() { + String html = factory.createCompactingStatusHtmlBlock("c1"); + + assertTrue(html.contains("id=\"c1\"")); + assertTrue(html.contains("class=\"compacting-status\"")); + assertTrue(html.contains("Compacting")); + } + + @Test + void createAgentMessageHtmlBlock() { + String html = factory.createAgentMessageHtmlBlock( + "msg-1", "PR Created", "Description", "https://github.com/pr/1"); + + assertTrue(html.contains("id=\"msg-1\"")); + assertTrue(html.contains("class=\"message-card agent-message\"")); + assertTrue(html.contains("PR Created")); + assertTrue(html.contains("Description")); + assertTrue(html.contains("copilotAction('openLink','https://github.com/pr/1')")); + } + } + + @Nested + class BlockIdTests { + + @Test + void userAndCopilotContainerIds_doNotCollide() { + assertEquals("t1-user", ConversationHtmlBlockFactory.userTurnContainerId("t1")); + assertEquals("t1-copilot", + ConversationHtmlBlockFactory.copilotTurnContainerId("t1")); + } + + @Test + void contentBlockId_scopedByRole() { + assertEquals("t1-user-content", + ConversationHtmlBlockFactory.contentBlockId("t1", false)); + assertEquals("t1-copilot-content", + ConversationHtmlBlockFactory.contentBlockId("t1", true)); + } + + @Test + void otherIdFormats() { + assertEquals("t1-5", ConversationHtmlBlockFactory.copilotChildBlockId("t1", 5)); + assertEquals("t1-tc-3", ConversationHtmlBlockFactory.toolCallBlockId("t1", 3)); + assertEquals("t1-compacting", ConversationHtmlBlockFactory.compactingBlockId("t1")); + assertEquals("t1-model-info", ConversationHtmlBlockFactory.modelInfoBlockId("t1")); + assertEquals("t1-streaming", + ConversationHtmlBlockFactory.streamingIndicatorId("t1")); + assertEquals("t1-confirm", + ConversationHtmlBlockFactory.confirmationBlockId("t1")); + } + } + + // Creates an AgentToolCall using Gson (class has no setters). + private static AgentToolCall agentToolCall(String name, String status, + String progressMessage) { + String json = GSON.toJson(Map.of( + "name", name, + "status", status, + "progressMessage", progressMessage != null ? progressMessage : "")); + AgentToolCall tc = GSON.fromJson(json, AgentToolCall.class); + // Gson sets empty string; clear to null if that's what we want + if (progressMessage == null) { + // Use Gson again with explicit null + String jsonNull = "{\"name\":\"" + name + "\",\"status\":\"" + status + "\"}"; + tc = GSON.fromJson(jsonNull, AgentToolCall.class); + } + return tc; + } + + @Nested + class SubagentBlockTests { + + @Test + void subagentBlockId_combinesParentTurnAndToolCall() { + assertEquals("turn-1-subagent-tc-9", + ConversationHtmlBlockFactory.subagentBlockId("turn-1", "tc-9")); + } + + @Test + void subagentContentAreaId_suffixesBlockIdWithContent() { + assertEquals("turn-1-subagent-tc-9-content", + ConversationHtmlBlockFactory.subagentContentAreaId("turn-1", "tc-9")); + } + + @Test + void createSubagentBlockHtmlBlock_nestsContentAreaInsideBorderedBlock() { + String blockId = ConversationHtmlBlockFactory.subagentBlockId("turn-1", "tc-9"); + String contentAreaId = ConversationHtmlBlockFactory.subagentContentAreaId("turn-1", "tc-9"); + String html = factory.createSubagentBlockHtmlBlock( + blockId, contentAreaId, "data:image/png;base64,AAAA", "Investigated the failing test"); + + assertTrue(html.contains("id=\"turn-1-subagent-tc-9\""), "Outer block carries its id"); + assertTrue(html.contains("class=\"message-card subagent-message-block\""), + "Shares SWT subagent CSS class for parity"); + assertTrue(html.contains("id=\"turn-1-subagent-tc-9-content\""), + "Inner content area carries its id"); + assertTrue(html.contains("class=\"subagent-content\""), "Inner content area is styled"); + assertTrue(html.contains("class=\"block-title\""), "Card has a header"); + assertTrue(html.contains("class=\"subagent-avatar\""), "Header shows the copilot avatar"); + assertTrue(html.contains("Investigated the failing test"), "Header shows the title"); + // Content area must be nested inside the outer block. + assertTrue(html.indexOf(blockId) < html.indexOf(contentAreaId), + "Content area is nested inside the outer block"); + } + + @Test + void createSubagentBlockHtmlBlock_fallsBackToDefaultTitle() { + String blockId = ConversationHtmlBlockFactory.subagentBlockId("turn-1", "tc-9"); + String contentAreaId = ConversationHtmlBlockFactory.subagentContentAreaId("turn-1", "tc-9"); + String html = factory.createSubagentBlockHtmlBlock(blockId, contentAreaId, null, null); + + assertTrue(html.contains("Subagent"), "Falls back to a default title"); + assertFalse(html.contains("class=\"subagent-avatar\""), "No avatar img when uri is blank"); + } + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ConversationRendererVisualDemoSideBySide.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ConversationRendererVisualDemoSideBySide.java new file mode 100644 index 00000000..3843247e --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ConversationRendererVisualDemoSideBySide.java @@ -0,0 +1,559 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import java.util.List; +import java.util.Map; + +import org.eclipse.core.commands.AbstractHandler; +import org.eclipse.core.commands.ExecutionEvent; +import org.eclipse.core.commands.ExecutionException; +import org.eclipse.e4.ui.css.swt.theme.IThemeEngine; +import org.eclipse.lsp4j.WorkDoneProgressKind; +import org.eclipse.swt.SWT; +import org.eclipse.swt.browser.Browser; +import org.eclipse.swt.custom.SashForm; +import org.eclipse.swt.layout.FillLayout; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Shell; +import org.mockito.Mockito; +import org.eclipse.ui.PlatformUI; + +import com.microsoft.copilot.eclipse.core.AuthStatusManager; +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatProgressValue; +import com.microsoft.copilot.eclipse.core.lsp.protocol.Thinking; +import com.microsoft.copilot.eclipse.core.persistence.AbstractTurnData; +import com.microsoft.copilot.eclipse.core.persistence.ConversationDataFactory; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.AgentMessageData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.EditAgentRoundData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ErrorData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ErrorMessageData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ReplyData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ThinkingBlockData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ThinkingBlockState; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ToolCallData; +import com.microsoft.copilot.eclipse.core.persistence.UserTurnData; +import com.microsoft.copilot.eclipse.core.persistence.UserTurnData.MessageData; +import com.microsoft.copilot.eclipse.ui.chat.services.AvatarService; +import com.microsoft.copilot.eclipse.ui.chat.services.ChatFontService; +import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; + +/** + * Side-by-side visual comparison of {@link BrowserConversationWidget} (left) and + * {@link StyledTextConversationWidget} (right) rendering identical conversation content. + * + *

How to run: Launch the test Eclipse Application (include the + * {@code com.microsoft.copilot.eclipse.ui.test} bundle), then open the Copilot menu and select + * "Compare Chat View Rendering Alternatives". + * + *

Both widgets require a running Eclipse workbench (PlatformUI, OSGi bundle classloaders). + * This handler cannot run as a standalone Java application. + */ +public class ConversationRendererVisualDemoSideBySide extends AbstractHandler { + + @Override + public Object execute(ExecutionEvent event) throws ExecutionException { + openSideBySide(Display.getCurrent()); + return null; + } + + /** + * Opens the side-by-side comparison shell. + */ + public static void openSideBySide(Display display) { + Shell shell = new Shell(display); + shell.setText("Conversation Renderer \u2014 Side-by-Side Comparison"); + shell.setSize(1600, 1000); + shell.setLayout(new GridLayout(1, false)); + + // --- Theme toggle buttons --- + Composite toolbar = new Composite(shell, SWT.NONE); + toolbar.setLayoutData(new GridData(SWT.FILL, SWT.TOP, true, false)); + toolbar.setLayout(new GridLayout(3, false)); + Label themeLabel = new Label(toolbar, SWT.NONE); + themeLabel.setText("Theme:"); + Button darkButton = new Button(toolbar, SWT.PUSH); + darkButton.setText("Dark"); + Button lightButton = new Button(toolbar, SWT.PUSH); + lightButton.setText("Light"); + + // --- SashForm with both widgets --- + SashForm sash = new SashForm(shell, SWT.HORIZONTAL); + sash.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); + + // --- Left: Browser-based renderer --- + Composite leftPane = createLabeledPane(sash, + "BrowserConversationWidget (HTML/CSS)"); + BrowserConversationWidget browserWidget = new BrowserConversationWidget(leftPane); + + // --- Right: StyledText-based renderer --- + Composite rightPane = createLabeledPane(sash, + "StyledTextConversationWidget (SWT StyledText)"); + ChatServiceManager demoServiceManager = createDemoChatServiceManager(); + StyledTextConversationWidget[] styledTextHolder = { + new StyledTextConversationWidget(rightPane, demoServiceManager)}; + + sash.setWeights(50, 50); + + // Theme toggle listeners — recreate StyledText widget to pick up new theme colors + darkButton.addListener(SWT.Selection, e -> { + Browser browser = (Browser) browserWidget.getControl(); + browser.execute("window.setTheme('dark')"); + switchEclipseTheme("org.eclipse.e4.ui.css.theme.e4_dark"); + styledTextHolder[0] = recreateStyledTextWidget( + rightPane, demoServiceManager, styledTextHolder[0]); + }); + lightButton.addListener(SWT.Selection, e -> { + Browser browser = (Browser) browserWidget.getControl(); + browser.execute("window.setTheme('light')"); + switchEclipseTheme("org.eclipse.e4.ui.css.theme.e4_default"); + styledTextHolder[0] = recreateStyledTextWidget( + rightPane, demoServiceManager, styledTextHolder[0]); + }); + + // Feed identical content to both widgets after the browser page has loaded. + // BrowserConversationWidget needs its page loaded before insertHtmlBlockChild works, + // so we schedule the content injection asynchronously. + display.asyncExec(() -> feedDemoContent(browserWidget, styledTextHolder[0])); + + shell.open(); + } + + // --- Demo content constants (shared between both rendering paths) --- + + private static final String USER_MSG_1 = + "How do I create a **GFM table** in Markdown? Also show me a code example."; + + private static final String COPILOT_REPLY_TABLE = """ + Here's a GFM table example: + + | Feature | StyledText | Browser | + |----------------|:----------:|:-------:| + | Tables | \u274c | \u2705 | + | Code blocks | \u2705 | \u2705 | + | Task lists | \u274c | \u2705 | + | Inline code | \u2705 | \u2705 | + | Bold/Italic | \u2705 | \u2705 | + + And here's a Java code example: + + ```java + public class HelloWorld { + public static void main(String[] args) { + System.out.println("Hello, world!"); + if (args.length > 0) { + System.out.println("Args: " + String.join(", ", args)); + } + } + } + ``` + + You can also use **inline code** like `System.out.println()` in your text. + + Here's a task list: + + - [x] Create table structure + - [x] Add code block + - [ ] Test rendering + - [ ] Submit PR"""; + + private static final String THINKING_CONTENT = + "The user wants to know about GFM tables and code examples. " + + "I'll provide a table example and a fenced code block with Java."; + + private static final String THINKING_TITLE = "Planning response"; + + private static final String USER_MSG_2 = + "Thanks! Can you also show me how bold and italic text render?"; + + private static final String COPILOT_REPLY_FORMATTING = """ + Sure! Here are some formatting examples: + + - **Bold text** is wrapped in double asterisks + - *Italic text* uses single asterisks + - ***Bold and italic*** uses triple asterisks + - ~~Strikethrough~~ uses tildes + + > This is a blockquote that can span + > multiple lines. + + And a numbered list: + + 1. First item + 2. Second item + 3. Third item"""; + + private static final String ERROR_MSG = "Let me try a different approach..."; + private static final String ERROR_DETAIL = + "Context window exceeded. The conversation is too long."; + + private static final String USER_MSG_FIX_TEST = + "The login test is failing. Investigate it with a subagent and open a PR with the fix."; + private static final String USER_MSG_BUILD = + "Great. Now run the build to make sure everything still compiles."; + private static final String USER_MSG_ERROR = + "Summarize this entire 500-page design document in a single response."; + private static final String USER_MSG_QUOTA = + "Generate the full implementation using the most capable premium model."; + private static final String USER_MSG_INVESTIGATE = + "Before you make changes, investigate the current setup and run whatever tools you need."; + private static final String USER_MSG_LIVE = + "Walk me through the migration approach step by step."; + + private static final String SUBAGENT_THINKING_CONTENT = + "The failing test points at the login flow. I'll delegate a focused investigation to a " + + "subagent, then summarize the finding and open a pull request."; + private static final String BUILD_REPLY_TEXT = + "I'll run the build now. This needs to execute a terminal command:"; + + private static final String AGENT_MSG_TITLE = "Pull Request Created"; + private static final String AGENT_MSG_DESC = + "Implemented the requested feature with comprehensive unit tests and integration tests. " + + "All existing tests continue to pass. The PR includes documentation updates as well."; + private static final String AGENT_MSG_PR_LINK = + "https://github.com/microsoft/copilot-for-eclipse/pull/330"; + + private static final String CONFIRM_TITLE = "Run command in terminal?"; + private static final String CONFIRM_MESSAGE = + "Copilot wants to execute a shell command."; + private static final String CONFIRM_COMMAND = "npm install && npm run build"; + private static final String CONFIRM_EXPLANATION = + "Install dependencies and build the project"; + + private static final String THINKING_ACTIVE_TEXT = + "Analyzing the project structure to determine the best approach. " + + "I need to check if there are existing test files and " + + "understand the module dependencies before making changes..."; + + private static final String QUOTA_REPLY_TEXT = "I was unable to complete the request."; + private static final String QUOTA_ERROR_MSG = + "You've used all your premium requests for the month. " + + "Consider upgrading your plan for continued access."; + + private static final String SUBAGENT_PARENT_REPLY = + "I'll delegate the investigation to a subagent."; + private static final String SUBAGENT_REPLY_TEXT = + "Searched the codebase and found the failing assertion in `LoginServiceTest`. " + + "The mock returns `null` where a session token is expected."; + + private static Composite createLabeledPane(Composite parent, String title) { + Composite pane = new Composite(parent, SWT.NONE); + pane.setLayout(new GridLayout(1, false)); + Label label = new Label(pane, SWT.NONE); + label.setText(title); + label.setLayoutData(new GridData(SWT.FILL, SWT.TOP, true, false)); + Composite content = new Composite(pane, SWT.NONE); + content.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); + content.setLayout(new FillLayout()); + return content; + } + + /** + * Creates a mock {@link ChatServiceManager} that reuses the real {@link AvatarService} + * and {@link AuthStatusManager} from the running Eclipse instance (for real user avatar), + * plus a real {@link ChatFontService}. + */ + private static ChatServiceManager createDemoChatServiceManager() { + ChatServiceManager mockManager = Mockito.mock(ChatServiceManager.class); + + // Use the real AuthStatusManager from the running Copilot plugin + AuthStatusManager realAuth = CopilotCore.getPlugin().getAuthStatusManager(); + Mockito.when(mockManager.getAuthStatusManager()).thenReturn(realAuth); + + // Use a real AvatarService backed by the real auth (loads GitHub avatar) + AvatarService realAvatarService = new AvatarService(realAuth); + Mockito.when(mockManager.getAvatarService()).thenReturn(realAvatarService); + + // ChatFontService — real instance (no OSGi dependencies in constructor) + ChatFontService fontService = new ChatFontService(); + Mockito.when(mockManager.getChatFontService()).thenReturn(fontService); + + return mockManager; + } + + /** + * Feeds identical synthetic conversation data to both widgets. The conversation interleaves user + * turns with copilot turns (so every copilot turn gets its own header). History (restored) turns + * and live (streamed) turns are kept in separate turns: restoring a turn and then streaming into + * the same turn id would duplicate the browser turn container and collide child-block ids. The + * active (non-sealed) thinking block is therefore a live-only turn placed mid-conversation (never + * the trailing round, which would otherwise reserve empty space below it in StyledText). Covers + * all block types: text with formatting, tables, code blocks, task lists, thinking blocks (sealed + * and active), tool calls (all statuses), tool confirmation, error and quota warnings, subagent + * nesting, agent messages, and model info. + */ + private static void feedDemoContent( + BrowserConversationWidget browser, StyledTextConversationWidget styledText) { + feedWidgets(browser, styledText); + } + + /** + * Feeds demo content to one or both widgets. When {@code browser} is {@code null}, only the + * StyledText widget is populated (used after theme-switch recreation). + */ + private static void feedWidgets( + BrowserConversationWidget browser, StyledTextConversationWidget styledText) { + + // ConversationDataFactory needs an AuthStatusManager for tool call conversion + AuthStatusManager mockAuth = Mockito.mock(AuthStatusManager.class); + Mockito.when(mockAuth.getUserName()).thenReturn("demo-user"); + ConversationDataFactory dataFactory = new ConversationDataFactory(mockAuth); + + // The conversation interleaves user turns with copilot turns so every copilot turn gets its own + // header. History (restored) turns and the single live (streamed) turn are kept separate. + + // --- Exchange 1 (turns 1-2): table + code + task list (reply only) --- + restoreUser(browser, styledText, dataFactory, "turn-1", USER_MSG_1); + + CopilotTurnData tableTurn = new CopilotTurnData(); + tableTurn.setTurnId("turn-2"); + ReplyData tableReply = tableTurn.getReply(); + tableReply.setText(COPILOT_REPLY_TABLE); + tableReply.setModelName("GPT-5 mini"); + tableReply.setBillingMultiplier(1.0); + tableReply.setReasoningEffort("medium"); + restoreBoth(browser, styledText, tableTurn, dataFactory); + + // --- Exchange 2 (turns 3-4): markdown formatting examples --- + restoreUser(browser, styledText, dataFactory, "turn-3", USER_MSG_2); + + CopilotTurnData formattingTurn = new CopilotTurnData(); + formattingTurn.setTurnId("turn-4"); + ReplyData formattingReply = formattingTurn.getReply(); + formattingReply.setText(COPILOT_REPLY_FORMATTING); + formattingReply.setModelName("Claude Opus 4"); + formattingReply.setBillingMultiplier(2.5); + formattingReply.setReasoningEffort("high"); + restoreBoth(browser, styledText, formattingTurn, dataFactory); + + // --- Exchange 3 (turns 5-6): sealed thinking block with tool-call examples (restored) --- + restoreUser(browser, styledText, dataFactory, "turn-5", USER_MSG_INVESTIGATE); + + ThinkingBlockData thinkingBlock = new ThinkingBlockData(); + thinkingBlock.setId("turn-6-thinking"); + thinkingBlock.setContent(THINKING_CONTENT); + thinkingBlock.setTitle(THINKING_TITLE); + thinkingBlock.setState(ThinkingBlockState.COMPLETED); + + EditAgentRoundData thinkingRound = new EditAgentRoundData(); + thinkingRound.setRoundId(1); + thinkingRound.setThinkingBlock(thinkingBlock); + thinkingRound.setToolCalls(List.of( + toolCall("tc-1", "file_search", "completed", "Found 3 results in src/"), + toolCall("tc-2", "run_in_terminal", "cancelled", "User cancelled"), + toolCall("tc-3", "edit_file", "error", "Permission denied: /etc/hosts"), + toolCall("tc-4", "grep_search", "running", "Searching..."))); + + CopilotTurnData thinkingTurn = new CopilotTurnData(); + thinkingTurn.setTurnId("turn-6"); + ReplyData thinkingReply = thinkingTurn.getReply(); + thinkingReply.setEditAgentRounds(List.of(thinkingRound)); + thinkingReply.setModelName("GPT-5 mini"); + thinkingReply.setBillingMultiplier(1.0); + thinkingReply.setReasoningEffort("medium"); + restoreBoth(browser, styledText, thinkingTurn, dataFactory); + + // --- Exchange 4 (turns 7-8): active (non-sealed) thinking block via the live API --- + // Kept in its own live-only turn (never mixed with a restored turn, which would duplicate the + // browser turn container and collide child-block ids). Positioned mid-conversation so it is not + // the trailing round — a short trailing active-thinking turn would reserve empty scroller space + // below it in the StyledText windowed renderer. + restoreUser(browser, styledText, dataFactory, "turn-7", USER_MSG_LIVE); + + if (browser != null) { + browser.beginTurn("turn-8", true, false); + } + styledText.beginTurn("turn-8", true, false); + + ChatProgressValue thinkingEvent = new ChatProgressValue(); + thinkingEvent.setKind(WorkDoneProgressKind.report); + thinkingEvent.setTurnId("turn-8"); + thinkingEvent.setThinking(new Thinking("turn-8-active", THINKING_ACTIVE_TEXT, null)); + if (browser != null) { + browser.processTurnEvent(thinkingEvent); + } + styledText.processTurnEvent(thinkingEvent); + + // --- Exchange 5 (turns 9-10): reply + thinking + nested subagent + PR agent message --- + restoreUser(browser, styledText, dataFactory, "turn-9", USER_MSG_FIX_TEST); + + ThinkingBlockData subThinking = new ThinkingBlockData(); + subThinking.setId("turn-10-thinking"); + subThinking.setContent(SUBAGENT_THINKING_CONTENT); + subThinking.setTitle("Delegating investigation"); + subThinking.setState(ThinkingBlockState.COMPLETED); + + EditAgentRoundData subRound = new EditAgentRoundData(); + subRound.setRoundId(1); + subRound.setThinkingBlock(subThinking); + subRound.setToolCalls(List.of( + toolCall("tc-subagent", "run_subagent", "completed", "Investigated the failing test"))); + + CopilotTurnData subParentTurn = new CopilotTurnData(); + subParentTurn.setTurnId("turn-10"); + ReplyData subParentReply = subParentTurn.getReply(); + subParentReply.setText(SUBAGENT_PARENT_REPLY); + subParentReply.setEditAgentRounds(List.of(subRound)); + AgentMessageData prMessage = new AgentMessageData(); + prMessage.setTitle(AGENT_MSG_TITLE); + prMessage.setDescription(AGENT_MSG_DESC); + prMessage.setPrLink(AGENT_MSG_PR_LINK); + prMessage.setAgentSlug("github-copilot-coding-agent"); + subParentReply.setAgentMessages(List.of(prMessage)); + subParentReply.setModelName("Claude Opus 4"); + subParentReply.setBillingMultiplier(2.5); + subParentReply.setReasoningEffort("high"); + restoreBoth(browser, styledText, subParentTurn, dataFactory); + + // Nested subagent turn (parentTurnId + subagentToolCallId route it into the subagent block). + CopilotTurnData subChildTurn = new CopilotTurnData(); + subChildTurn.setTurnId("turn-10-sub"); + subChildTurn.setParentTurnId("turn-10"); + subChildTurn.setSubagentToolCallId("tc-subagent"); + subChildTurn.getReply().setText(SUBAGENT_REPLY_TEXT); + restoreBoth(browser, styledText, subChildTurn, dataFactory); + + // --- Exchange 6 (turns 11-12): running tool with a live tool-confirmation request --- + restoreUser(browser, styledText, dataFactory, "turn-11", USER_MSG_BUILD); + + CopilotTurnData confirmTurn = new CopilotTurnData(); + confirmTurn.setTurnId("turn-12"); + ReplyData confirmReply = confirmTurn.getReply(); + confirmReply.setText(BUILD_REPLY_TEXT); + EditAgentRoundData confirmRound = new EditAgentRoundData(); + confirmRound.setRoundId(1); + confirmRound.setToolCalls(List.of( + toolCall("tc-confirm", "run_in_terminal", "running", "Awaiting confirmation..."))); + confirmReply.setEditAgentRounds(List.of(confirmRound)); + confirmReply.setModelName("GPT-5 mini"); + confirmReply.setBillingMultiplier(1.0); + restoreBoth(browser, styledText, confirmTurn, dataFactory); + + // Show tool confirmation UI (live interaction on the copilot turn just restored) + ConfirmationContent confirmation = new ConfirmationContent( + CONFIRM_TITLE, CONFIRM_MESSAGE, + List.of( + ConfirmationAction.allowOnce("Allow"), + ConfirmationAction.skip("Deny"))); + Map confirmInput = Map.of( + "command", CONFIRM_COMMAND, + "explanation", CONFIRM_EXPLANATION); + if (browser != null) { + browser.requestToolConfirmation("turn-12", confirmation, confirmInput); + } + styledText.requestToolConfirmation("turn-12", confirmation, confirmInput); + + // --- Exchange 7 (turns 13-14): agent message with a non-quota error warning (400) --- + restoreUser(browser, styledText, dataFactory, "turn-13", USER_MSG_ERROR); + + CopilotTurnData errorTurn = new CopilotTurnData(); + errorTurn.setTurnId("turn-14"); + ReplyData errorReply = errorTurn.getReply(); + errorReply.setText(ERROR_MSG); + ErrorData errorData = new ErrorData(); + errorData.setMessage(ERROR_DETAIL); + errorData.setCode(400); + ErrorMessageData errorMessageData = new ErrorMessageData(); + errorMessageData.setError(errorData); + errorReply.setErrorMessages(List.of(errorMessageData)); + errorReply.setModelName("GPT-5 mini"); + errorReply.setBillingMultiplier(1.0); + restoreBoth(browser, styledText, errorTurn, dataFactory); + + // --- Exchange 8 (turns 15-16): quota-exceeded warning (402) with action buttons --- + restoreUser(browser, styledText, dataFactory, "turn-15", USER_MSG_QUOTA); + + CopilotTurnData quotaTurn = new CopilotTurnData(); + quotaTurn.setTurnId("turn-16"); + ReplyData quotaReply = quotaTurn.getReply(); + quotaReply.setText(QUOTA_REPLY_TEXT); + ErrorData quotaError = new ErrorData(); + quotaError.setMessage(QUOTA_ERROR_MSG); + quotaError.setCode(402); + ErrorMessageData quotaMessageData = new ErrorMessageData(); + quotaMessageData.setError(quotaError); + quotaReply.setErrorMessages(List.of(quotaMessageData)); + quotaReply.setModelName("Claude Opus 4"); + quotaReply.setBillingMultiplier(2.5); + quotaReply.setReasoningEffort("high"); + restoreBoth(browser, styledText, quotaTurn, dataFactory); + } + + /** Restores a turn on both widgets; skips browser if null. */ + private static void restoreBoth(BrowserConversationWidget browser, + StyledTextConversationWidget styledText, AbstractTurnData turn, + ConversationDataFactory dataFactory) { + if (browser != null) { + browser.restoreTurn(turn, dataFactory); + } + styledText.restoreTurn(turn, dataFactory); + } + + /** Restores a user turn on both widgets; skips browser if null. */ + private static void restoreUser(BrowserConversationWidget browser, + StyledTextConversationWidget styledText, ConversationDataFactory dataFactory, + String turnId, String message) { + UserTurnData userTurn = new UserTurnData(); + userTurn.setTurnId(turnId); + userTurn.setMessage(new MessageData(message)); + restoreBoth(browser, styledText, userTurn, dataFactory); + } + + /** Creates a tool call with the given id, name, status and progress message. */ + private static ToolCallData toolCall(String id, String name, String status, String progress) { + ToolCallData toolCall = new ToolCallData(); + toolCall.setId(id); + toolCall.setName(name); + toolCall.setStatus(status); + toolCall.setProgressMessage(progress); + return toolCall; + } + + /** + * Disposes the old StyledText widget and creates a fresh one with updated theme colors. + * The StyledText-based widget reads theme colors at construction time and does not + * dynamically refresh them, so recreation is necessary on theme change. + */ + private static StyledTextConversationWidget recreateStyledTextWidget( + Composite parent, ChatServiceManager serviceManager, + StyledTextConversationWidget oldWidget) { + if (oldWidget != null && !oldWidget.getControl().isDisposed()) { + oldWidget.getControl().dispose(); + } + StyledTextConversationWidget newWidget = + new StyledTextConversationWidget(parent, serviceManager); + parent.requestLayout(); + + // Re-feed demo content to the new widget (browser-only stub as first arg) + parent.getDisplay().asyncExec(() -> feedStyledTextOnly(newWidget)); + return newWidget; + } + + /** + * Feeds demo content to a single StyledText widget (used after theme-switch recreation). + */ + private static void feedStyledTextOnly(StyledTextConversationWidget styledText) { + feedWidgets(null, styledText); + } + + /** + * Switches the Eclipse platform CSS theme (affects StyledText colors). + */ + private static void switchEclipseTheme(String themeId) { + IThemeEngine themeEngine = + (IThemeEngine) PlatformUI.getWorkbench().getService(IThemeEngine.class); + if (themeEngine != null) { + themeEngine.setTheme(themeId, true); + } + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/UiUtilsThemeTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/UiUtilsThemeTest.java new file mode 100644 index 00000000..88e94a83 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/UiUtilsThemeTest.java @@ -0,0 +1,178 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.utils; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import org.eclipse.core.runtime.Platform; +import org.eclipse.core.runtime.preferences.IPreferencesService; +import org.eclipse.e4.ui.css.swt.theme.ITheme; +import org.eclipse.e4.ui.css.swt.theme.IThemeEngine; +import org.eclipse.ui.IWorkbench; +import org.eclipse.ui.PlatformUI; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.osgi.framework.Bundle; + +/** + * Unit tests for {@link UiUtils#isDarkTheme()}. + * + *

The detection resolves the active e4 CSS theme reflectively through the theme bundle's own + * class loader, so no compile-time reference to the friend-restricted + * {@code org.eclipse.e4.ui.css.swt.theme} package is required in production. These tests drive that + * logic entirely through mocked {@link PlatformUI} and {@link Platform} statics, covering both the + * case where e4 CSS theming is available and the graceful fallbacks when it is not. + */ +class UiUtilsThemeTest { + + private static final String THEME_BUNDLE = "org.eclipse.e4.ui.css.swt.theme"; + private static final String DARK_THEME_ID = "org.eclipse.e4.ui.css.theme.e4_dark"; + private static final String LIGHT_THEME_ID = "org.eclipse.e4.ui.css.theme.e4_default"; + + // --------------------------------------------------------------------------- + // Theming available: active theme resolved via reflection. + // --------------------------------------------------------------------------- + + @Test + void isDarkTheme_activeThemeIsDark_returnsTrue() throws Exception { + try (MockedStatic platformUi = mockStatic(PlatformUI.class); + MockedStatic platform = mockStatic(Platform.class)) { + IWorkbench workbench = givenThemingAvailable(platformUi, platform); + givenActiveTheme(workbench, DARK_THEME_ID); + + assertTrue(UiUtils.isDarkTheme()); + } + } + + @Test + void isDarkTheme_activeThemeIsLight_returnsFalse() throws Exception { + try (MockedStatic platformUi = mockStatic(PlatformUI.class); + MockedStatic platform = mockStatic(Platform.class)) { + IWorkbench workbench = givenThemingAvailable(platformUi, platform); + givenActiveTheme(workbench, LIGHT_THEME_ID); + + assertFalse(UiUtils.isDarkTheme()); + } + } + + @Test + void isDarkTheme_activeThemeIdMixedCase_returnsTrue() throws Exception { + try (MockedStatic platformUi = mockStatic(PlatformUI.class); + MockedStatic platform = mockStatic(Platform.class)) { + IWorkbench workbench = givenThemingAvailable(platformUi, platform); + givenActiveTheme(workbench, "com.example.Solarized.DARK.Theme"); + + assertTrue(UiUtils.isDarkTheme()); + } + } + + @Test + void isDarkTheme_activeThemeNull_fallsBackToPreference() throws Exception { + try (MockedStatic platformUi = mockStatic(PlatformUI.class); + MockedStatic platform = mockStatic(Platform.class)) { + IWorkbench workbench = givenThemingAvailable(platformUi, platform); + givenActiveTheme(workbench, null); + givenPersistedThemeId(platform, DARK_THEME_ID); + + assertTrue(UiUtils.isDarkTheme()); + } + } + + // --------------------------------------------------------------------------- + // Theming unavailable: graceful fallback to the persisted preference. + // --------------------------------------------------------------------------- + + @Test + void isDarkTheme_themeBundleAbsent_fallsBackToPreferenceDark() { + try (MockedStatic platformUi = mockStatic(PlatformUI.class); + MockedStatic platform = mockStatic(Platform.class)) { + platform.when(() -> Platform.getBundle(THEME_BUNDLE)).thenReturn(null); + givenPersistedThemeId(platform, DARK_THEME_ID); + + assertTrue(UiUtils.isDarkTheme()); + } + } + + @Test + void isDarkTheme_themeBundleAbsent_fallsBackToPreferenceLight() { + try (MockedStatic platformUi = mockStatic(PlatformUI.class); + MockedStatic platform = mockStatic(Platform.class)) { + platform.when(() -> Platform.getBundle(THEME_BUNDLE)).thenReturn(null); + givenPersistedThemeId(platform, ""); + + assertFalse(UiUtils.isDarkTheme()); + } + } + + @Test + void isDarkTheme_themeServiceNull_fallsBackToPreference() throws Exception { + try (MockedStatic platformUi = mockStatic(PlatformUI.class); + MockedStatic platform = mockStatic(Platform.class)) { + IWorkbench workbench = givenThemingAvailable(platformUi, platform); + doReturn(null).when(workbench).getService(IThemeEngine.class); + givenPersistedThemeId(platform, DARK_THEME_ID); + + assertTrue(UiUtils.isDarkTheme()); + } + } + + @Test + void isDarkTheme_reflectionFailure_fallsBackToPreferenceWithoutThrowing() throws Exception { + try (MockedStatic platformUi = mockStatic(PlatformUI.class); + MockedStatic platform = mockStatic(Platform.class)) { + IWorkbench workbench = mock(IWorkbench.class); + platformUi.when(PlatformUI::getWorkbench).thenReturn(workbench); + + Bundle themeBundle = mock(Bundle.class); + doThrow(new ClassNotFoundException()).when(themeBundle) + .loadClass("org.eclipse.e4.ui.css.swt.theme.IThemeEngine"); + platform.when(() -> Platform.getBundle(THEME_BUNDLE)).thenReturn(themeBundle); + givenPersistedThemeId(platform, LIGHT_THEME_ID); + + assertFalse(UiUtils.isDarkTheme()); + } + } + + // --------------------------------------------------------------------------- + // Helpers. + // --------------------------------------------------------------------------- + + private static IWorkbench givenThemingAvailable(MockedStatic platformUi, + MockedStatic platform) throws ClassNotFoundException { + IWorkbench workbench = mock(IWorkbench.class); + platformUi.when(PlatformUI::getWorkbench).thenReturn(workbench); + + Bundle themeBundle = mock(Bundle.class); + doReturn(IThemeEngine.class).when(themeBundle) + .loadClass("org.eclipse.e4.ui.css.swt.theme.IThemeEngine"); + doReturn(ITheme.class).when(themeBundle) + .loadClass("org.eclipse.e4.ui.css.swt.theme.ITheme"); + platform.when(() -> Platform.getBundle(THEME_BUNDLE)).thenReturn(themeBundle); + return workbench; + } + + private static void givenActiveTheme(IWorkbench workbench, String themeId) { + IThemeEngine themeEngine = mock(IThemeEngine.class); + doReturn(themeEngine).when(workbench).getService(IThemeEngine.class); + + ITheme activeTheme = null; + if (themeId != null) { + activeTheme = mock(ITheme.class); + when(activeTheme.getId()).thenReturn(themeId); + } + when(themeEngine.getActiveTheme()).thenReturn(activeTheme); + } + + private static void givenPersistedThemeId(MockedStatic platform, String themeId) { + IPreferencesService preferences = mock(IPreferencesService.class); + platform.when(Platform::getPreferencesService).thenReturn(preferences); + when(preferences.getString(THEME_BUNDLE, "themeid", "", null)).thenReturn(themeId); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF index dc86d49a..62cf0972 100644 --- a/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF @@ -32,7 +32,7 @@ Require-Bundle: com.microsoft.copilot.eclipse.core;bundle-version="0.20.0", org.eclipse.ui.editors;bundle-version="3.17.100", org.eclipse.ui;bundle-version="3.205.0", org.eclipse.ui.navigator;bundle-version="3.12.200", - org.eclipse.jface.text;bundle-version="3.24.200", + org.eclipse.jface.text;bundle-version="3.24.200", org.eclipse.core.runtime;bundle-version="[3.30.0,4.0.0)", org.eclipse.core.expressions, org.eclipse.jdt.annotation;resolution:=optional, @@ -68,4 +68,8 @@ Require-Bundle: com.microsoft.copilot.eclipse.core;bundle-version="0.20.0", org.eclipse.ui.editors, org.eclipse.debug.core;resolution:=optional, org.eclipse.jdt.core;resolution:=optional, - org.eclipse.jdt.debug;resolution:=optional + org.eclipse.jdt.debug;resolution:=optional, + org.commonmark;bundle-version="0.24.0", + org.commonmark-gfm-tables;bundle-version="0.24.0", + org.commonmark-gfm-strikethrough;bundle-version="0.24.0", + org.commonmark-task-list-items;bundle-version="0.24.0" diff --git a/com.microsoft.copilot.eclipse.ui/build.properties b/com.microsoft.copilot.eclipse.ui/build.properties index 5ba08abe..0bb08961 100644 --- a/com.microsoft.copilot.eclipse.ui/build.properties +++ b/com.microsoft.copilot.eclipse.ui/build.properties @@ -8,4 +8,5 @@ bin.includes = META-INF/,\ css/,\ intro/,\ terminal-bundles/*.jar,\ - contexts.xml + contexts.xml,\ + resources/ diff --git a/com.microsoft.copilot.eclipse.ui/resources/html/chat-view.html b/com.microsoft.copilot.eclipse.ui/resources/html/chat-view.html new file mode 100644 index 00000000..ef93a458 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/resources/html/chat-view.html @@ -0,0 +1,115 @@ + + + + + + + + + Copilot Chat View + + + + + + + + +

+ + + + diff --git a/com.microsoft.copilot.eclipse.ui/resources/html/css/chat-browser-base.css b/com.microsoft.copilot.eclipse.ui/resources/html/css/chat-browser-base.css new file mode 100644 index 00000000..146f8a72 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/resources/html/css/chat-browser-base.css @@ -0,0 +1,372 @@ +/* Copyright (c) Microsoft Corporation. */ +/* Licensed under the MIT license. */ + +/* + * Base structural CSS for the browser-based chat view. + * Colors are applied via body.dark / body.light classes defined in the theme CSS files. + */ + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --font-mono: 'Cascadia Code', 'Fira Code', Consolas, monospace; + font-size: 13px; + padding: 8px; + line-height: 1.5; +} + +.chat-container { display: flex; flex-direction: column; gap: 12px; } + +.turn { padding: 8px 12px; border-radius: 6px; word-wrap: break-word; } + +.turn-user { box-shadow: inset 3px 0 0 var(--accent); } + +.turn-content { line-height: 1.5; } + +.tool-calls { margin-bottom: 6px; } + +.tool-call { font-size: 12px; padding: 2px 0; display: flex; align-items: flex-start; gap: 4px; } + +.tool-call-name { font-family: var(--font-mono); } + +.tool-call-progress { font-style: italic; } + +.tool-call-icon { display: inline-flex; align-items: center; font-weight: bold; } +.tool-call-icon.tc-success { color: #28a745 !important; } +.tool-call-icon.tc-error { color: #d73a49 !important; } +.tool-call-icon.tc-cancelled { color: var(--muted); } +.tool-call-icon.tc-running { + display: inline-flex; + align-items: center; +} +.tool-call-icon.tc-running .thinking-spinner { + width: 12px; + height: 12px; + border: 2px solid var(--muted); + border-top-color: transparent; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +/* Shared box styling for message cards (agent messages, tool confirmations, subagent calls). + Individual card classes add only their own element-specific rules. */ +.message-card { + margin: 8px 0; + padding: 8px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-secondary); + font-size: 12px; +} + +/* Nested subagent turn (mirrors SWT SubagentMessageBlock). Border/accent use themed variables. */ +.subagent-content { display: flex; flex-direction: column; } + +.subagent-avatar { + width: 14px; + height: 14px; + border-radius: 50%; + margin-right: 6px; +} + +.thinking-block { margin: 4px 0; font-size: 12px; } + +.thinking-block details { margin: 0; } + +.thinking-block summary { + cursor: pointer; + font-style: italic; + list-style: none; + display: flex; + align-items: center; +} + +.thinking-block summary .thinking-chevron { + font-style: normal; + margin-right: 4px; +} + +.thinking-block summary .thinking-chevron::after { + content: '\25B8'; +} + +.thinking-block details[open] > summary .thinking-chevron::after { + content: '\25BE'; +} + +.thinking-block summary .thinking-icon { + margin-right: 4px; + flex-shrink: 0; +} + +.thinking-block summary .thinking-spinner { + display: inline-block; + width: 12px; + height: 12px; + border: 2px solid currentColor; + border-top-color: transparent; + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin-right: 4px; + flex-shrink: 0; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.thinking-block .thinking-body { + margin: 4px 0; + padding: 4px 0 4px 10px; + border-left: 2px solid currentColor; + white-space: pre-wrap; + font-size: 11px; + max-height: 180px; + overflow-y: auto; + opacity: 0.85; +} + +.turn-content p { margin: 4px 0; } + +.turn-content a { text-decoration: none; } + +.turn-content a:hover { text-decoration: underline; } + +.turn-content code { + font-family: var(--font-mono); + font-size: 12px; + padding: 1px 4px; + border-radius: 3px; +} + +.turn-content pre { + position: relative; + margin: 8px 0; + padding: 8px; + border-radius: 4px; + overflow-x: auto; +} + +.turn-content pre code { background: none; padding: 0; } + +.turn-content table { border-collapse: collapse; margin: 8px 0; width: auto; } + +.turn-content th, +.turn-content td { padding: 4px 8px; text-align: left; } + +.turn-content th { font-weight: 600; } + +.turn-content ul, +.turn-content ol { margin: 4px 0 4px 20px; } + +.turn-content li { margin: 2px 0; } + +.turn-content blockquote { padding-left: 10px; margin: 6px 0; } + +.turn-content hr { border: none; margin: 8px 0; } + +/* Turn header with avatar and name */ +.turn-header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 6px; +} + +.turn-avatar { + width: 24px; + height: 24px; + border-radius: 50%; +} + +.turn-name { + font-weight: 600; + font-size: 12px; +} + +/* Code block action buttons */ +.code-actions { + position: absolute; + top: 4px; + right: 4px; + display: flex; + gap: 4px; + opacity: 0; + transition: opacity 0.15s; +} + +pre:hover .code-actions { opacity: 1; } + +.code-action-btn { + font-size: 11px; + padding: 3px 5px; + border: none; + border-radius: 3px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +/* Model info footer */ +.model-info { + margin-top: 8px; + font-size: 11px; + font-style: italic; + text-align: right; +} + +/* Error messages */ +.error-message { + padding: 6px 8px; + margin: 6px 0; + border-radius: 4px; + font-size: 12px; +} + +/* Warning messages with icon + optional action buttons */ +.warning-message { + padding: 8px; + margin: 6px 0; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 12px; +} + +.warning-content { + display: flex; + align-items: flex-start; + gap: 4px; +} + +.warning-content span { + flex: 1; + line-height: 1.4; +} + +.warning-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; + margin-left: 18px; +} + +/* Agent messages (PR links) */ +/* Box styling (margin, padding, border, radius, background, font-size) comes from + the shared .message-card class; keep only element-specific rules here. */ + +.block-title { + display: flex; + align-items: center; + font-weight: 600; + margin-bottom: 6px; +} + +.agent-message-desc { + margin: 4px 0 8px 0; + color: var(--muted); +} + +.agent-message-actions { + display: flex; + gap: 8px; + margin-top: 6px; +} + +.agent-message a { word-break: break-all; } + +/* Compacting status */ +.compacting-status { + margin-top: 8px; + font-size: 12px; + font-style: italic; + animation: blink 1s step-end infinite; +} + +/* Streaming indicator (animated dots) */ +.streaming-indicator { + display: flex; + gap: 4px; + padding: 8px 0; +} + +.streaming-indicator .dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + opacity: 0.4; + animation: dot-pulse 1.4s ease-in-out infinite; +} + +.streaming-indicator .dot:nth-child(2) { animation-delay: 0.2s; } + +.streaming-indicator .dot:nth-child(3) { animation-delay: 0.4s; } + +@keyframes dot-pulse { + 0%, 80%, 100% { opacity: 0.4; transform: scale(1); } + 40% { opacity: 1; transform: scale(1.2); } +} + +/* ═══════════════════ Tool Confirmation Block ═══════════════════ */ +/* Shared box styling comes from .message-card; keep only element-specific rules. */ +.confirmation-block { + min-width: fit-content; +} + +.confirmation-message { + margin-bottom: 8px; + color: var(--muted); +} + +.confirmation-command { + font-family: var(--font-mono); + font-size: 12px; + color: var(--code-color); + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 4px; + padding: 8px; + margin-bottom: 8px; + white-space: pre-wrap; + word-break: break-all; +} + +.confirmation-explanation { + margin-bottom: 8px; + font-size: 12px; + color: var(--muted); +} + +.confirmation-actions { + display: flex; + flex-wrap: nowrap; + gap: 8px; + margin-top: 8px; +} + +.btn-confirm { + padding: 3px 8px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg-secondary); + color: var(--fg-primary); + cursor: pointer; + font-size: 13px; +} + +.btn-confirm:hover { + background: var(--bg-hover); +} + +.btn-confirm.btn-primary { + background: var(--accent); + color: var(--fg-on-accent); + border-color: var(--accent); +} + +.btn-confirm.btn-primary:hover { + opacity: 0.9; +} diff --git a/com.microsoft.copilot.eclipse.ui/resources/html/css/chat-browser-dark.css b/com.microsoft.copilot.eclipse.ui/resources/html/css/chat-browser-dark.css new file mode 100644 index 00000000..6d149f7e --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/resources/html/css/chat-browser-dark.css @@ -0,0 +1,47 @@ +/* Copyright (c) Microsoft Corporation. */ +/* Licensed under the MIT license. */ + +/* + * Dark theme colors for the browser-based chat view. + * Values are synchronized with css/dark.css - update together. + */ + +body.dark { + background: #1e1f22; + color: #dee1e5; + --accent: #0078d4; + --muted: #a4a4a4; + --bg-primary: #1e1f22; + --bg-secondary: #2a2a2a; + --bg-hover: #3d3d3d; + --fg-primary: #dee1e5; + --fg-on-accent: #ffffff; + --border: #3d3d3d; + --code-color: #e0c576; +} + +body.dark .turn-user { background: #26282b; } +body.dark .turn-copilot { background: #1e1f22; } +body.dark .turn-error { background: #3d2020; border-left: 3px solid #f44747; } +body.dark .turn-name { color: #dee1e5; } +body.dark .tool-call { color: #a4a4a4; } +body.dark .tool-call-name { color: #0078d4; } +body.dark .thinking-block summary { color: #a4a4a4; } +body.dark .thinking-block .thinking-body { color: #a4a4a4; border-left-color: #505050; } +body.dark .turn-content a { color: #0078d4; } +body.dark .turn-content code { background: #2a2a2a; color: var(--code-color); } +body.dark .turn-content pre { background: #2a2a2a; } +body.dark .turn-content pre code { color: var(--code-color); } +body.dark .turn-content th, +body.dark .turn-content td { border: 1px solid #3d3d3d; } +body.dark .turn-content th { background: #2a2d2e; } +body.dark .turn-content tr:nth-child(even) td { background: #252729; } +body.dark .turn-content blockquote { border-left: 3px solid #E0C576; color: #E0C576; } +body.dark .turn-content hr { border-top: 1px solid #3d3d3d; } +body.dark .code-action-btn { background: #3d3d3d; color: #dee1e5; } +body.dark .code-action-btn:hover { background: #505050; } +body.dark .model-info { color: #808080; } +body.dark .error-message { background: #3d2020; color: #f44747; } +body.dark .warning-message { background: #3d3520; border-color: #e6a817; color: #f5c518; } +body.dark .agent-message { } +body.dark .compacting-status { color: #808080; } diff --git a/com.microsoft.copilot.eclipse.ui/resources/html/css/chat-browser-light.css b/com.microsoft.copilot.eclipse.ui/resources/html/css/chat-browser-light.css new file mode 100644 index 00000000..1faf39b7 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/resources/html/css/chat-browser-light.css @@ -0,0 +1,47 @@ +/* Copyright (c) Microsoft Corporation. */ +/* Licensed under the MIT license. */ + +/* + * Light theme colors for the browser-based chat view. + * Values are synchronized with css/light.css - update together. + */ + +body.light { + background: #f8f8f8; + color: #000000; + --accent: #0078d4; + --muted: #808080; + --bg-primary: #f8f8f8; + --bg-secondary: #f0f0f0; + --bg-hover: #e0e0e0; + --fg-primary: #000000; + --fg-on-accent: #ffffff; + --border: #d0d0d0; + --code-color: rgb(38, 86, 145); +} + +body.light .turn-user { background: #efefee; } +body.light .turn-copilot { background: #f8f8f8; } +body.light .turn-error { background: #fde7e7; border-left: 3px solid #d32f2f; } +body.light .turn-name { color: #000000; } +body.light .tool-call { color: #808080; } +body.light .tool-call-name { color: #0078d4; } +body.light .thinking-block summary { color: #808080; } +body.light .thinking-block .thinking-body { color: #808080; border-left-color: #c0c0c0; } +body.light .turn-content a { color: #0078d4; } +body.light .turn-content code { background: #f0f0f0; color: var(--code-color); } +body.light .turn-content pre { background: #f0f0f0; } +body.light .turn-content pre code { color: var(--code-color); } +body.light .turn-content th, +body.light .turn-content td { border: 1px solid #d0d0d0; } +body.light .turn-content th { background: #e8e8e8; } +body.light .turn-content tr:nth-child(even) td { background: #f4f4f4; } +body.light .turn-content blockquote { border-left: 3px solid rgb(38, 86, 145); color: rgb(38, 86, 145); } +body.light .turn-content hr { border-top: 1px solid #d0d0d0; } +body.light .code-action-btn { background: #e0e0e0; color: #333333; } +body.light .code-action-btn:hover { background: #d0d0d0; } +body.light .model-info { color: #808080; } +body.light .error-message { background: #fde7e7; color: #d32f2f; } +body.light .warning-message { background: #fef9e7; border-color: #e6a817; color: #795600; } +body.light .agent-message { } +body.light .compacting-status { color: #808080; } 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..2af634c9 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidget.java @@ -0,0 +1,1144 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.concurrent.CompletableFuture; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.commons.lang3.StringUtils; +import org.eclipse.core.runtime.FileLocator; +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.BrowserFunction; +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.PlatformUI; +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.GenerateThinkingTitleParams; +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.lsp.protocol.quota.CheckQuotaResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CopilotPlan; +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.ui.CopilotUi; +import com.microsoft.copilot.eclipse.ui.UiConstants; +import com.microsoft.copilot.eclipse.ui.chat.QuotaActions.QuotaAction; +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 { + + private static final String COPILOT_AVATAR_PATH = + "/icons/chat/chat_message_copilot_avatar.png"; + private static final String USER_AVATAR_PATH = "/icons/chat/chat_message_user_avatar.png"; + private static final String COPILOT_DISPLAY_NAME = "GitHub Copilot"; + private static final String GITHUB_AVATAR_URL = + "https://avatars.githubusercontent.com/%s?s=24&v=4"; + + private final Browser browser; + private final ConversationHtmlBlockFactory htmlFactory; + + // Turn streaming state (per-turn, keyed by turnId). + private final Map turnStates = new HashMap<>(); + private String currentTurnId; + private String lastCopilotTurnId; + private String conversationId; + + // Subagent nesting (single level, mirrors SWT BaseTurnWidget). Set while the parent's + // run_subagent tool call is running; consumed by the subagent's beginTurn. + private String activeSubagentContentAreaId; + // Maps a run_subagent tool call id to its nested content-area id (used on restore). + private final Map subagentContentAreaByToolCallId = new HashMap<>(); + + // Tool confirmation state + private CompletableFuture pendingConfirmationFuture; + private String pendingConfirmationTurnId; + private List pendingConfirmationActions; + private ConfirmationAction lastSelectedAction; + + private boolean pageLoaded; + private final Queue pendingScripts = new LinkedList<>(); + private String copilotAvatarDataUri; + private String userAvatarDataUri; + private String userName; + + /** Types of child blocks within a copilot turn. */ + private enum ChildBlockType { + THINKING, TOOL_CALL, RESPONSE + } + + /** + * Per-turn streaming state, keyed by turnId. Keeping the block counter and current-block pointers + * per turn means a parent turn that resumes after a nested subagent keeps its own counter, so no + * duplicate DOM ids are produced. {@code contentAreaId} is where this turn's child blocks are + * inserted: the turn's own content area, or — for a subagent turn — the nested subagent content + * area. + */ + private static final class TurnStreamState { + private final String contentAreaId; + private int childBlockCounter; + private ChildBlockType currentBlockType; + private String currentChildBlockId; + private String currentToolCallId; + private String currentThinkingBlockId; + private StringBuilder currentThinkingText; + private StringBuilder currentReplyText; + private boolean streamingIndicatorVisible; + + TurnStreamState(String contentAreaId) { + this.contentAreaId = contentAreaId; + } + + void resetTransient() { + currentBlockType = null; + currentChildBlockId = null; + currentToolCallId = null; + currentThinkingBlockId = null; + currentThinkingText = null; + currentReplyText = null; + } + } + + /** + * Creates a new browser-based conversation widget. + * + * @param parent the parent composite + */ + public BrowserConversationWidget(Composite parent) { + browser = new Browser(parent, SWT.NONE); + browser.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); + htmlFactory = new ConversationHtmlBlockFactory(); + + // Load avatar icons as base64 data URIs + Bundle bundle = CopilotUi.getPlugin().getBundle(); + this.copilotAvatarDataUri = loadIconAsDataUri(bundle, COPILOT_AVATAR_PATH); + this.userAvatarDataUri = loadIconAsDataUri(bundle, USER_AVATAR_PATH); + + // Load code block action icons from Eclipse platform bundles + Bundle uiBundle = org.eclipse.core.runtime.Platform.getBundle("org.eclipse.ui"); + String copyUri = loadIconAsDataUri(uiBundle, "icons/full/etool16/copy_edit.png"); + Bundle textEditorBundle = org.eclipse.core.runtime.Platform.getBundle( + UiConstants.WORKBENCH_TEXTEDITOR); + String insertUri = loadIconAsDataUri(textEditorBundle, UiConstants.INSERT_ICON); + htmlFactory.setCodeBlockIcons(copyUri, insertUri); + + registerBrowserFunctions(); + + browser.addProgressListener(new ProgressAdapter() { + @Override + public void completed(ProgressEvent event) { + pageLoaded = true; + String theme = UiUtils.isDarkTheme() ? "dark" : "light"; + browser.execute("window.setTheme('" + theme + "')"); + while (!pendingScripts.isEmpty()) { + browser.execute(pendingScripts.poll()); + } + } + }); + + try { + URL fileUrl = bundle.getEntry("resources/html/chat-view.html"); + URL resolvedUrl = FileLocator.toFileURL(fileUrl); + browser.setUrl(resolvedUrl.toExternalForm()); + } catch (Exception e) { + CopilotCore.LOGGER.error("Failed to load chat-view.html in browser widget", e); + } + } + + @Override + public Control getControl() { + return browser; + } + + @Override + public boolean isDisposed() { + return browser.isDisposed(); + } + + @Override + public void dispose() { + if (!browser.isDisposed()) { + browser.dispose(); + } + } + + @Override + public void requestLayout() { + browser.requestLayout(); + } + + @Override + public void setConversationId(String conversationId) { + this.conversationId = conversationId; + } + + @Override + public void beginTurn(String turnId, boolean isCopilot, boolean isHistory) { + // Subagent begin: nest inside the parent's active subagent block instead of creating a + // top-level turn container. Its child blocks are routed to the nested content area. + if (isCopilot && activeSubagentContentAreaId != null) { + currentTurnId = turnId; + turnStates.put(turnId, new TurnStreamState(activeSubagentContentAreaId)); + return; + } + currentTurnId = turnId; + if (isCopilot) { + lastCopilotTurnId = turnId; + } + turnStates.put(turnId, + new TurnStreamState(ConversationHtmlBlockFactory.contentBlockId(turnId, isCopilot))); + String turnHtml = htmlFactory.createTurnContainerHtmlBlock( + turnId, isCopilot, + isCopilot ? copilotAvatarDataUri : getUserAvatarUri(), + isCopilot ? COPILOT_DISPLAY_NAME : getUserDisplayName()); + insertHtmlBlockChild("chat-container", turnHtml); + if (isCopilot && !isHistory) { + showStreamingIndicator(turnId); + } + } + + @Override + public void processTurnEvent(ChatProgressValue value) { + String turnId = value.getTurnId(); + + switch (value.getKind()) { + case report -> { + currentTurnId = turnId; + ensureTurnState(turnId, value); + removeStreamingIndicator(turnId); + processThinking(turnId, value); + processAgentRounds(turnId, value); + processReply(turnId, extractReplyChunk(value)); + } + case end -> { + removeStreamingIndicator(turnId); + finalizeTurn(turnId); + } + default -> { + // begin is handled by beginTurn(), called separately by ChatView + } + } + + // Handle error messages (can arrive on any event kind, including report and end) + String errorMessage = ChatErrorMessages.resolveDisplayMessage(value); + if (StringUtils.isNotEmpty(errorMessage)) { + processErrorMessage(turnId, errorMessage, value.getCode(), value.getErrorModelProviderName()); + } + } + + @Override + public void startNewUserTurn(String turnId, String message) { + String turnHtml = htmlFactory.createTurnContainerHtmlBlock( + turnId, false, getUserAvatarUri(), getUserDisplayName()); + insertHtmlBlockChild("chat-container", turnHtml); + String contentId = ConversationHtmlBlockFactory.contentBlockId(turnId, false); + String blockId = contentId + "-0"; + String blockHtml = htmlFactory.createUserRequestHtmlBlock(blockId, message); + insertHtmlBlockChild(contentId, blockHtml); + } + + @Override + public void scrollToBottom() { + executeScript("window.scrollTo(0, document.body.scrollHeight)"); + } + + @Override + public void refreshScrollerLayout() { + // Browser handles its own layout; no action needed + } + + @Override + public void renderErrorMessage(String content) { + String errorId = "error-" + System.currentTimeMillis(); + String renderedContent = htmlFactory.renderMarkdown(content); + String errorHtml = htmlFactory.createErrorTurnHtmlBlock(errorId, renderedContent); + insertHtmlBlockChild("chat-container", errorHtml); + // Scroll the newly inserted error banner into view so the user notices it, mirroring the + // SWT renderer's showControl-based scroll for error banners. + scrollToBottom(); + } + + @Override + public void showCompactingStatusOnLatestCopilotTurn() { + String turnId = lastCopilotTurnId != null ? lastCopilotTurnId : currentTurnId; + if (turnId != null) { + String blockId = ConversationHtmlBlockFactory.compactingBlockId(turnId); + String html = htmlFactory.createCompactingStatusHtmlBlock(blockId); + insertHtmlBlockChild(ConversationHtmlBlockFactory.contentBlockId(turnId, true), html); + } + } + + @Override + public void hideCompactingStatusOnLatestCopilotTurn() { + String turnId = lastCopilotTurnId != null ? lastCopilotTurnId : currentTurnId; + if (turnId != null) { + removeHtmlBlock(ConversationHtmlBlockFactory.compactingBlockId(turnId)); + } + } + + @Override + public String getActiveThinkingBlockId(String turnId) { + TurnStreamState state = turnId != null ? turnStates.get(turnId) : null; + return state != null ? state.currentThinkingBlockId : null; + } + + @Override + public void restoreTurn(AbstractTurnData turn, ConversationDataFactory dataFactory) { + if (turn == null) { + return; + } + // Subagent turn: render nested inside the parent turn's subagent block. + if (turn instanceof CopilotTurnData copilotTurn + && StringUtils.isNotBlank(copilotTurn.getParentTurnId())) { + restoreSubagentTurn(copilotTurn); + return; + } + if (turn instanceof UserTurnData userTurn) { + if (userTurn.getMessage() != null + && StringUtils.isNotBlank(userTurn.getMessage().getText())) { + startNewUserTurn(turn.getTurnId(), userTurn.getMessage().getText()); + } + return; + } + if (turn instanceof CopilotTurnData copilotTurn) { + String turnId = turn.getTurnId(); + lastCopilotTurnId = turnId; + String turnHtml = htmlFactory.createTurnContainerHtmlBlock( + turnId, true, copilotAvatarDataUri, COPILOT_DISPLAY_NAME); + insertHtmlBlockChild("chat-container", turnHtml); + restoreCopilotTurnContent(turnId, + ConversationHtmlBlockFactory.contentBlockId(turnId, true), copilotTurn); + ReplyData replyData = copilotTurn.getReply(); + if (replyData != null && StringUtils.isNotBlank(replyData.getModelName())) { + renderModelInfo(turnId, replyData.getModelName(), + replyData.getBillingMultiplier(), replyData.getReasoningEffort()); + } + } + } + + /** + * Restores a subagent turn into the nested subagent block that was opened while restoring the + * parent turn's {@code run_subagent} tool call. Falls back to the parent's content area when the + * subagent tool-call id is missing (mirrors the SWT blank-toolCallId path). + */ + private void restoreSubagentTurn(CopilotTurnData copilotTurn) { + String toolCallId = copilotTurn.getSubagentToolCallId(); + String contentAreaId = StringUtils.isNotBlank(toolCallId) + ? subagentContentAreaByToolCallId.get(toolCallId) : null; + if (contentAreaId == null) { + contentAreaId = + ConversationHtmlBlockFactory.contentBlockId(copilotTurn.getParentTurnId(), true); + } + restoreCopilotTurnContent(copilotTurn.getTurnId(), contentAreaId, copilotTurn); + } + + @Override + public void renderModelInfo(String turnId, String modelName, double billingMultiplier, + String reasoningEffort) { + // When token-based billing is enabled, the per-turn billing multiplier is no longer + // a meaningful price signal — hide it, matching StyledText widget behavior. + double effectiveMultiplier = billingMultiplier; + try { + if (CopilotCore.getPlugin().getAuthStatusManager() + .getQuotaStatus().tokenBasedBillingEnabled()) { + effectiveMultiplier = 0; + } + } catch (Exception e) { + // Defensive: if quota status unavailable, fall back to showing multiplier + } + String blockId = ConversationHtmlBlockFactory.modelInfoBlockId(turnId); + String html = htmlFactory.createModelInfoHtmlBlock( + blockId, modelName, effectiveMultiplier, reasoningEffort); + insertHtmlBlockChild(ConversationHtmlBlockFactory.copilotTurnContainerId(turnId), html); + } + + @Override + public void renderAgentMessage(CodingAgentMessageRequestParams params) { + if (params == null) { + return; + } + String turnId = params.getTurnId(); + if (StringUtils.isBlank(turnId)) { + return; + } + String blockId = ConversationHtmlBlockFactory.agentMessageBlockId(turnId); + String html = htmlFactory.createAgentMessageHtmlBlock( + blockId, params.getTitle(), params.getDescription(), params.getPrLink()); + insertHtmlBlockChild(ConversationHtmlBlockFactory.copilotTurnContainerId(turnId), html); + } + + @Override + public CompletableFuture requestToolConfirmation( + String turnId, ConfirmationContent content, Object input) { + // Cancel any existing pending confirmation + cancelToolConfirmation(turnId); + + CompletableFuture future = new CompletableFuture<>(); + pendingConfirmationFuture = future; + pendingConfirmationTurnId = turnId; + pendingConfirmationActions = content.getActions(); + lastSelectedAction = null; + + String blockId = ConversationHtmlBlockFactory.confirmationBlockId(turnId); + String html = htmlFactory.createConfirmationHtmlBlock(blockId, content, input); + // Insert before the model-info block so confirmation appears above it + String modelInfoId = ConversationHtmlBlockFactory.modelInfoBlockId(turnId); + insertHtmlBlockBefore( + ConversationHtmlBlockFactory.copilotTurnContainerId(turnId), html, modelInfoId); + scrollToBottom(); + + return future; + } + + @Override + public void cancelToolConfirmation(String turnId) { + if (pendingConfirmationFuture != null && !pendingConfirmationFuture.isDone()) { + pendingConfirmationFuture.complete( + new LanguageModelToolConfirmationResult(ToolConfirmationResult.DISMISS)); + } + pendingConfirmationFuture = null; + pendingConfirmationActions = null; + pendingConfirmationTurnId = null; + removeConfirmationBlock(turnId); + } + + @Override + public ConfirmationAction getLastSelectedConfirmationAction() { + return lastSelectedAction; + } + + private void resolveConfirmation(int actionIndex, boolean accepted) { + if (pendingConfirmationFuture == null || pendingConfirmationFuture.isDone()) { + return; + } + if (accepted && pendingConfirmationActions != null + && actionIndex >= 0 && actionIndex < pendingConfirmationActions.size()) { + lastSelectedAction = pendingConfirmationActions.get(actionIndex); + } + ToolConfirmationResult result = accepted + ? ToolConfirmationResult.ACCEPT : ToolConfirmationResult.DISMISS; + pendingConfirmationFuture.complete(new LanguageModelToolConfirmationResult(result)); + pendingConfirmationFuture = null; + pendingConfirmationActions = null; + if (pendingConfirmationTurnId != null) { + removeConfirmationBlock(pendingConfirmationTurnId); + pendingConfirmationTurnId = null; + } + } + + private void removeConfirmationBlock(String turnId) { + if (turnId == null) { + return; + } + String blockId = ConversationHtmlBlockFactory.confirmationBlockId(turnId); + executeScript("window.removeBlock && window.removeBlock('" + escapeForJs(blockId) + "')"); + } + + /** + * Processes a thinking chunk: starts a new thinking block or updates the current one. + * Seals the thinking block if renderable output follows in the same event. + */ + private void processThinking(String turnId, ChatProgressValue value) { + Thinking thinking = value.getThinking(); + boolean hasThinking = thinking != null && thinking.text() != null + && !thinking.text().isBlank(); + + TurnStreamState state = stateFor(turnId); + if (hasThinking) { + if (state.currentBlockType != ChildBlockType.THINKING) { + state.currentThinkingText = new StringBuilder(thinking.text()); + state.currentBlockType = ChildBlockType.THINKING; + state.currentChildBlockId = ConversationHtmlBlockFactory.copilotChildBlockId( + turnId, state.childBlockCounter++); + state.currentThinkingBlockId = state.currentChildBlockId; + String blockHtml = htmlFactory.createThinkingHtmlBlock( + state.currentChildBlockId, thinking.text()); + insertHtmlBlockChild(state.contentAreaId, blockHtml); + } else { + state.currentThinkingText.append(thinking.text()); + updateThinkingBodyText(state.currentChildBlockId, state.currentThinkingText.toString()); + } + } + + // Seal thinking if renderable output follows in the same event + if (hasRenderableOutput(value) && state.currentBlockType == ChildBlockType.THINKING) { + sealCurrentThinkingBlock(turnId); + } + } + + /** + * Seals the current thinking block: collapses it, removes the spinner, and requests a title + * from the language server. Mirrors the behavior of {@code ThinkingTurnWidget.sealThinking()}. + * Uses targeted DOM manipulation instead of replacing the entire block. + */ + private void sealCurrentThinkingBlock(String turnId) { + TurnStreamState state = stateFor(turnId); + if (state.currentThinkingBlockId == null || state.currentThinkingText == null) { + state.currentBlockType = null; + return; + } + String blockId = state.currentThinkingBlockId; + String thinkingContent = state.currentThinkingText.toString(); + state.currentBlockType = null; + + // Collapse details, remove spinner (targeted DOM ops, no full re-render) + collapseThinkingBlock(blockId); + + // Request title from language server (async) + requestThinkingTitle(blockId, thinkingContent, turnId); + + // The block is sealed; it is no longer the turn's active thinking block. + state.currentThinkingBlockId = null; + state.currentThinkingText = null; + } + + /** + * Collapses a thinking block by removing the {@code open} attribute from its + * {@code

} element and removing the spinner element. + */ + private void collapseThinkingBlock(String blockId) { + String bulbSvgJs = ConversationHtmlBlockFactory.BULB_SVG + .replace("\"", "\\\"").replace("'", "\\'"); + String script = "var b=document.getElementById('" + escapeForJs(blockId) + "');" + + "if(b){var d=b.querySelector('details');if(d)d.removeAttribute('open');" + + "var s=b.querySelector('.thinking-spinner');" + + "if(s){var icon=document.createElement('span');" + + "icon.innerHTML='" + bulbSvgJs + "';" + + "s.parentNode.replaceChild(icon.firstChild,s);}}"; + executeScript(script); + } + + /** + * Updates the text content of a thinking block's body and auto-scrolls to the bottom + * if the user hasn't scrolled up. Avoids replacing the entire block (which resets scroll). + */ + private void updateThinkingBodyText(String blockId, String text) { + String script = "var b=document.getElementById('" + escapeForJs(blockId) + "');" + + "if(b){var bd=b.querySelector('.thinking-body');if(bd){" + + "bd.textContent='" + escapeForJs(text) + "';" + + "var thr=60;if(bd.scrollHeight-(bd.scrollTop+bd.clientHeight)<=thr)" + + "bd.scrollTop=bd.scrollHeight;}}"; + executeScript(script); + } + + /** + * Updates the summary/title text of a thinking block without replacing the entire block. + */ + private void updateThinkingBlockTitle(String blockId, String title) { + String script = "var b=document.getElementById('" + escapeForJs(blockId) + "');" + + "if(b){var s=b.querySelector('summary');if(s)s.textContent='" + + escapeForJs(title) + "';}"; + executeScript(script); + } + + /** + * Requests a thinking block title from the language server and updates the summary on response. + * Follows the same logic as {@code ThinkingTurnWidget}: extracts bold titles from thinking text + * and sends either extractedTitles or the full content to the server. + */ + private void requestThinkingTitle(String blockId, String thinkingContent, String turnId) { + var ls = CopilotCore.getPlugin().getCopilotLanguageServer(); + if (ls == null || StringUtils.isBlank(thinkingContent)) { + return; + } + String[] extractedTitles = extractBoldTitles(thinkingContent); + boolean hasTitles = extractedTitles.length > 0; + var params = new GenerateThinkingTitleParams(hasTitles ? null : thinkingContent, + hasTitles ? extractedTitles : null); + ls.generateThinkingTitle(params) + .thenAccept(resp -> { + if (resp != null && StringUtils.isNotBlank(resp.title())) { + updateThinkingBlockTitle(blockId, resp.title()); + persistThinkingTitle(turnId, blockId, resp.title()); + } + }) + .exceptionally(ex -> null); + } + + private void persistThinkingTitle(String turnId, String thinkingBlockId, String title) { + if (conversationId == null) { + return; + } + var chatServiceManager = CopilotUi.getPlugin().getChatServiceManager(); + if (chatServiceManager != null) { + chatServiceManager.getPersistenceManager() + .updateThinkingBlockTitle(conversationId, turnId, thinkingBlockId, title); + } + } + + /** Extracts {@code **Title**} patterns from thinking text (same regex as ThinkingBlock). */ + private static final Pattern TITLE_PATTERN = + Pattern.compile("(?:^|\\n)\\*\\*([^*\\r\\n]+?)\\*\\*(?=\\r?\\n|$)"); + + private static String[] extractBoldTitles(String text) { + Matcher matcher = TITLE_PATTERN.matcher(text); + List titles = new ArrayList<>(); + while (matcher.find()) { + String title = matcher.group(1).trim(); + if (!title.isEmpty()) { + titles.add(title); + } + } + return titles.toArray(String[]::new); + } + + /** + * Processes agent rounds: handles tool calls and agent-round replies. + */ + private void processAgentRounds(String turnId, ChatProgressValue value) { + List agentRounds = value.getAgentRounds(); + if (agentRounds == null || agentRounds.isEmpty()) { + return; + } + AgentRound round = agentRounds.get(0); + if (round.getToolCalls() != null && !round.getToolCalls().isEmpty()) { + AgentToolCall toolCall = round.getToolCalls().get(0); + processToolCall(turnId, toolCall); + } + } + + /** + * Processes a reply chunk: starts a new response block or updates the current one. + */ + private void processReply(String turnId, String replyChunk) { + if (replyChunk == null || replyChunk.isEmpty()) { + return; + } + TurnStreamState state = stateFor(turnId); + if (state.currentBlockType != ChildBlockType.RESPONSE) { + state.currentReplyText = new StringBuilder(replyChunk); + state.currentBlockType = ChildBlockType.RESPONSE; + state.currentChildBlockId = ConversationHtmlBlockFactory.copilotChildBlockId( + turnId, state.childBlockCounter++); + String blockHtml = htmlFactory.createCopilotReplyHtmlBlock( + state.currentChildBlockId, state.currentReplyText.toString()); + insertHtmlBlockChild(state.contentAreaId, blockHtml); + } else { + state.currentReplyText.append(replyChunk); + String blockHtml = htmlFactory.createCopilotReplyHtmlBlock( + state.currentChildBlockId, state.currentReplyText.toString()); + replaceHtmlBlock(state.currentChildBlockId, blockHtml); + } + } + + /** + * Processes a tool call: creates a new tool call block or updates an existing one. The + * {@code run_subagent} tool call is not rendered as a normal block — it drives subagent nesting + * (mirrors {@code BaseTurnWidget} in the SWT renderer). + */ + private void processToolCall(String turnId, AgentToolCall toolCall) { + if (toolCall != null && "run_subagent".equalsIgnoreCase(toolCall.getName())) { + handleSubagentToolCall(turnId, toolCall); + return; + } + TurnStreamState state = stateFor(turnId); + String toolCallId = toolCall.getId(); + boolean isSameToolCall = state.currentBlockType == ChildBlockType.TOOL_CALL + && state.currentChildBlockId != null + && toolCallId != null && toolCallId.equals(state.currentToolCallId); + + if (!isSameToolCall) { + state.currentBlockType = ChildBlockType.TOOL_CALL; + state.currentToolCallId = toolCallId; + String blockId = + ConversationHtmlBlockFactory.toolCallBlockId(turnId, state.childBlockCounter++); + state.currentChildBlockId = blockId; + String blockHtml = htmlFactory.createToolCallHtmlBlock(blockId, toolCall); + insertHtmlBlockChild(state.contentAreaId, blockHtml); + } else { + String blockHtml = htmlFactory.createToolCallHtmlBlock( + state.currentChildBlockId, toolCall); + replaceHtmlBlock(state.currentChildBlockId, blockHtml); + } + } + + /** + * Handles the parent turn's {@code run_subagent} tool call. On {@code running} it opens a nested + * subagent block under the parent's content area; on completion/cancellation/error it closes the + * active nesting. Mirrors {@code BaseTurnWidget.handleSubagentToolCall} in the SWT renderer. + */ + private void handleSubagentToolCall(String parentTurnId, AgentToolCall toolCall) { + String status = toolCall.getStatus() == null ? "" : toolCall.getStatus().toLowerCase(); + String toolCallId = toolCall.getId(); + switch (status) { + case "running" -> { + if (activeSubagentContentAreaId == null && StringUtils.isNotBlank(toolCallId)) { + activeSubagentContentAreaId = openSubagentBlock(parentTurnId, toolCallId, + subagentTitle(toolCall.getProgressMessage(), toolCall.getName())); + } + } + case "completed", "cancelled", "error" -> activeSubagentContentAreaId = null; + default -> { + // pending/unknown: nothing to do yet + } + } + } + + /** + * Inserts a nested subagent block under the given parent turn's content area (idempotent per + * tool-call id) and returns the id of its inner content area, where the subagent's child blocks + * are inserted. + */ + private String openSubagentBlock(String parentTurnId, String toolCallId, String title) { + String existing = subagentContentAreaByToolCallId.get(toolCallId); + if (existing != null) { + return existing; + } + String blockId = ConversationHtmlBlockFactory.subagentBlockId(parentTurnId, toolCallId); + String contentAreaId = + ConversationHtmlBlockFactory.subagentContentAreaId(parentTurnId, toolCallId); + String html = htmlFactory.createSubagentBlockHtmlBlock( + blockId, contentAreaId, copilotAvatarDataUri, title); + insertHtmlBlockChild(ConversationHtmlBlockFactory.contentBlockId(parentTurnId, true), html); + subagentContentAreaByToolCallId.put(toolCallId, contentAreaId); + return contentAreaId; + } + + /** Derives the subagent card title: progress message, else tool name, else "Subagent". */ + private static String subagentTitle(String progressMessage, String name) { + if (StringUtils.isNotBlank(progressMessage)) { + return progressMessage; + } + if (StringUtils.isNotBlank(name)) { + return name; + } + return "Subagent"; + } + + /** + * Processes a streaming error message: renders a warning block with the error text and, + * for quota-exceeded (402) errors, plan-driven action buttons. + */ + private void processErrorMessage(String turnId, String message, int code, + String modelProviderName) { + List actions = resolveQuotaActions(code, modelProviderName); + TurnStreamState state = stateFor(turnId); + String blockId = + ConversationHtmlBlockFactory.copilotChildBlockId(turnId, state.childBlockCounter++); + state.currentBlockType = null; + insertHtmlBlockChild(state.contentAreaId, + htmlFactory.createWarningMessageHtmlBlock(blockId, message, actions)); + // Scroll the newly inserted warning banner (e.g. quota-exceeded / 402 with plan-driven + // action buttons) into view so the user notices the required action, mirroring the SWT + // renderer's showControl-based scroll for warn banners. + scrollToBottom(); + } + + /** + * Resolves the quota action buttons appropriate for an error code. Returns an empty list + * for non-quota errors or when the required auth status is unavailable. + */ + private List resolveQuotaActions(int code, String modelProviderName) { + if (code != 402 || QuotaActions.isByokQuotaExceeded(code, modelProviderName)) { + return List.of(); + } + try { + CheckQuotaResult quotaStatus = + CopilotCore.getPlugin().getAuthStatusManager().getQuotaStatus(); + if (!quotaStatus.tokenBasedBillingEnabled()) { + return List.of(); + } + CopilotPlan plan = quotaStatus.copilotPlan(); + boolean overageEnabled = quotaStatus.premiumInteractions() != null + && quotaStatus.premiumInteractions().overagePermitted(); + Boolean canUpgradePlan = quotaStatus.canUpgradePlan(); + return QuotaActions.forPlan(plan, overageEnabled, canUpgradePlan); + } catch (Exception e) { + CopilotCore.LOGGER.error("Failed to resolve quota actions", e); + return List.of(); + } + } + + /** Finalizes a turn: seals thinking, final response render, and resets its transient pointers. */ + private void finalizeTurn(String turnId) { + TurnStreamState state = turnStates.get(turnId); + if (state == null) { + return; + } + // Seal any active thinking block before the turn ends + if (state.currentBlockType == ChildBlockType.THINKING) { + sealCurrentThinkingBlock(turnId); + } else if (state.currentBlockType == ChildBlockType.RESPONSE + && state.currentReplyText != null && state.currentReplyText.length() > 0) { + String blockHtml = htmlFactory.createCopilotReplyHtmlBlock( + state.currentChildBlockId, state.currentReplyText.toString()); + replaceHtmlBlock(state.currentChildBlockId, blockHtml); + } + state.resetTransient(); + } + + private void restoreCopilotTurnContent(String turnId, String contentId, + CopilotTurnData copilotTurn) { + ReplyData replyData = copilotTurn.getReply(); + if (replyData == null) { + return; + } + + int blockIdx = 0; + + if (StringUtils.isNotBlank(replyData.getText())) { + String blockId = ConversationHtmlBlockFactory.copilotChildBlockId(turnId, blockIdx++); + insertHtmlBlockChild(contentId, + htmlFactory.createCopilotReplyHtmlBlock(blockId, replyData.getText())); + } + + if (replyData.getEditAgentRounds() != null) { + for (EditAgentRoundData round : replyData.getEditAgentRounds()) { + ThinkingBlockData thinkingBlock = round.getThinkingBlock(); + if (thinkingBlock != null && StringUtils.isNotBlank(thinkingBlock.getContent())) { + String blockId = ConversationHtmlBlockFactory.copilotChildBlockId(turnId, blockIdx++); + insertHtmlBlockChild(contentId, + htmlFactory.createRestoredThinkingHtmlBlock(blockId, thinkingBlock)); + } + // Reply text renders before tool calls so it appears above them (and above any nested + // subagent card opened by a run_subagent tool call), matching StyledTextConversationWidget. + if (StringUtils.isNotBlank(round.getReply())) { + String blockId = ConversationHtmlBlockFactory.copilotChildBlockId(turnId, blockIdx++); + insertHtmlBlockChild(contentId, + htmlFactory.createCopilotReplyHtmlBlock(blockId, round.getReply())); + } + if (round.getToolCalls() != null) { + for (ToolCallData tc : round.getToolCalls()) { + // A run_subagent tool call opens a nested block; the subagent turn (restored + // afterwards) renders into it rather than showing a normal tool-call block. + if (tc != null && "run_subagent".equalsIgnoreCase(tc.getName()) + && StringUtils.isNotBlank(tc.getId())) { + openSubagentBlock(turnId, tc.getId(), + subagentTitle(tc.getProgressMessage(), tc.getName())); + continue; + } + String blockId = ConversationHtmlBlockFactory.copilotChildBlockId(turnId, blockIdx++); + insertHtmlBlockChild(contentId, + htmlFactory.createRestoredToolCallHtmlBlock(blockId, tc)); + } + } + } + } + + if (replyData.getErrorMessages() != null) { + for (ErrorMessageData errorMessageData : replyData.getErrorMessages()) { + ErrorData errorData = errorMessageData.getError(); + String errorMessage = errorData != null + ? errorData.getMessage() : "An error occurred"; + int errorCode = errorData != null ? errorData.getCode() : 0; + String modelProviderName = errorData != null ? errorData.getModelProviderName() : null; + List actions = resolveQuotaActions(errorCode, modelProviderName); + String blockId = ConversationHtmlBlockFactory.copilotChildBlockId(turnId, blockIdx++); + insertHtmlBlockChild(contentId, + htmlFactory.createWarningMessageHtmlBlock(blockId, errorMessage, actions)); + } + } + + if (replyData.getAgentMessages() != null) { + for (AgentMessageData agentMsg : replyData.getAgentMessages()) { + if (StringUtils.equals(agentMsg.getAgentSlug(), + UiConstants.GITHUB_COPILOT_CODING_AGENT_SLUG)) { + String blockId = ConversationHtmlBlockFactory.copilotChildBlockId(turnId, blockIdx++); + insertHtmlBlockChild(contentId, + htmlFactory.createAgentMessageHtmlBlock(blockId, + agentMsg.getTitle(), agentMsg.getDescription(), + agentMsg.getPrLink())); + } + } + } + } + + private void insertHtmlBlockChild(String parentId, String html) { + executeScript("window.insertBlock('" + escapeForJs(parentId) + "', '" + + escapeForJs(html) + "')"); + } + + private void insertHtmlBlockBefore(String parentId, String html, String beforeId) { + executeScript("window.insertBlockBefore('" + escapeForJs(parentId) + "', '" + + escapeForJs(html) + "', '" + escapeForJs(beforeId) + "')"); + } + + + private void replaceHtmlBlock(String blockId, String html) { + executeScript("window.replaceBlock('" + escapeForJs(blockId) + "', '" + + escapeForJs(html) + "')"); + } + + private void removeHtmlBlock(String blockId) { + executeScript("window.removeBlock('" + escapeForJs(blockId) + "')"); + } + + private void showStreamingIndicator(String turnId) { + TurnStreamState state = stateFor(turnId); + String indicatorId = ConversationHtmlBlockFactory.streamingIndicatorId(turnId); + String html = htmlFactory.createStreamingIndicatorHtmlBlock(indicatorId); + insertHtmlBlockChild(state.contentAreaId, html); + state.streamingIndicatorVisible = true; + } + + private void removeStreamingIndicator(String turnId) { + TurnStreamState state = turnStates.get(turnId); + if (state == null || !state.streamingIndicatorVisible) { + return; + } + removeHtmlBlock(ConversationHtmlBlockFactory.streamingIndicatorId(turnId)); + state.streamingIndicatorVisible = false; + } + + private boolean hasRenderableOutput(ChatProgressValue value) { + if (StringUtils.isNotBlank(value.getReply())) { + return true; + } + if (value.getAgentRounds() != null && !value.getAgentRounds().isEmpty()) { + AgentRound round = value.getAgentRounds().get(0); + if (round.getReply() != null && !round.getReply().isEmpty()) { + return true; + } + if (round.getToolCalls() != null && !round.getToolCalls().isEmpty()) { + return true; + } + } + return false; + } + + private String extractReplyChunk(ChatProgressValue value) { + if (value.getAgentRounds() != null && !value.getAgentRounds().isEmpty()) { + return value.getAgentRounds().get(0).getReply(); + } + return value.getReply(); + } + + /** Returns the streaming state for a turn, creating a default top-level one if absent. */ + private TurnStreamState stateFor(String turnId) { + return turnStates.computeIfAbsent(turnId, + id -> new TurnStreamState(ConversationHtmlBlockFactory.contentBlockId(id, true))); + } + + /** + * Ensures a streaming state exists for a turn seen in a {@code report} event. Normally the state + * is created by {@link #beginTurn}; this defensively creates one (nested when a subagent is + * active) if the begin event was not observed. + */ + private TurnStreamState ensureTurnState(String turnId, ChatProgressValue value) { + TurnStreamState state = turnStates.get(turnId); + if (state != null) { + return state; + } + boolean nested = value != null && StringUtils.isNotBlank(value.getParentTurnId()) + && activeSubagentContentAreaId != null; + String contentAreaId = nested + ? activeSubagentContentAreaId + : ConversationHtmlBlockFactory.contentBlockId(turnId, true); + state = new TurnStreamState(contentAreaId); + turnStates.put(turnId, state); + return state; + } + + private String getUserDisplayName() { + if (userName != null && !userName.isEmpty()) { + return userName; + } + try { + return CopilotCore.getPlugin().getAuthStatusManager().getUserName(); + } catch (Exception e) { + return "User"; + } + } + + /** + * Returns the user avatar URI. Uses the GitHub avatar URL if the user is signed in, + * otherwise falls back to the bundled default user icon. + */ + private String getUserAvatarUri() { + try { + String user = CopilotCore.getPlugin().getAuthStatusManager().getUserName(); + if (StringUtils.isNotBlank(user)) { + return String.format(GITHUB_AVATAR_URL, user); + } + } catch (Exception e) { + // Fall through to default + } + return userAvatarDataUri; + } + + private static String loadIconAsDataUri(Bundle bundle, String path) { + try { + URL entry = bundle.getEntry(path); + if (entry == null) { + return ""; + } + try (InputStream is = entry.openStream()) { + byte[] bytes = is.readAllBytes(); + return "data:image/png;base64," + Base64.getEncoder().encodeToString(bytes); + } + } catch (IOException e) { + CopilotCore.LOGGER.error("Failed to load icon: " + path, e); + return ""; + } + } + + private void registerBrowserFunctions() { + new BrowserFunction(browser, "copyToClipboard") { + @Override + public Object function(Object[] arguments) { + if (arguments.length > 0 && arguments[0] instanceof String code) { + Display.getDefault().asyncExec(() -> { + Clipboard clipboard = + new Clipboard(Display.getDefault()); + clipboard.setContents( + new Object[]{code}, + new Transfer[]{ + TextTransfer.getInstance()}); + clipboard.dispose(); + }); + } + return null; + } + }; + + new BrowserFunction(browser, "insertAtCursor") { + @Override + public Object function(Object[] arguments) { + if (arguments.length > 0 && arguments[0] instanceof String code) { + Display.getDefault().asyncExec(() -> { + try { + IEditorPart editor = PlatformUI.getWorkbench() + .getActiveWorkbenchWindow().getActivePage().getActiveEditor(); + if (editor instanceof ITextEditor textEditor) { + IDocument doc = textEditor.getDocumentProvider().getDocument( + textEditor.getEditorInput()); + ITextSelection sel = (ITextSelection) textEditor + .getSelectionProvider().getSelection(); + doc.replace(sel.getOffset(), sel.getLength(), code); + } + } catch (Exception e) { + CopilotCore.LOGGER.error("Failed to insert code at cursor", e); + } + }); + } + return null; + } + }; + + new BrowserFunction(browser, "acceptToolAction") { + @Override + public Object function(Object[] arguments) { + if (arguments.length > 0 && arguments[0] instanceof Double actionIndex) { + resolveConfirmation(actionIndex.intValue(), true); + } + return null; + } + }; + + new BrowserFunction(browser, "dismissToolAction") { + @Override + public Object function(Object[] arguments) { + resolveConfirmation(-1, false); + return null; + } + }; + + new BrowserFunction(browser, "copilotAction") { + @Override + public Object function(Object[] arguments) { + if (arguments.length < 2) { + return null; + } + String action = String.valueOf(arguments[0]); + String param = String.valueOf(arguments[1]); + switch (action) { + case "openLink": + UiUtils.openLink(param); + break; + case "openJobList": + UiUtils.openE4Part(Constants.GITHUB_JOBS_VIEW_ID); + break; + default: + break; + } + return null; + } + }; + } + + private void executeScript(String script) { + if (browser.isDisposed()) { + return; + } + Display.getDefault().asyncExec(() -> { + if (browser.isDisposed()) { + return; + } + if (pageLoaded) { + browser.execute(script); + } else { + pendingScripts.add(script); + } + }); + } + + /** Escapes a string for safe embedding in a single-quoted JavaScript string literal. */ + public static String escapeForJs(String text) { + if (text == null) { + return ""; + } + return text.replace("\\", "\\\\") + .replace("'", "\\'") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java index b3db64b4..c7e8c6b8 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java @@ -3,17 +3,14 @@ package com.microsoft.copilot.eclipse.ui.chat; -import java.util.ArrayList; import java.util.HashMap; import java.util.IdentityHashMap; -import java.util.List; import java.util.Map; import java.util.Queue; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; -import java.util.regex.Pattern; import org.apache.commons.lang3.StringUtils; import org.eclipse.e4.core.services.events.IEventBroker; @@ -34,13 +31,9 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentToolCall; import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatProgressValue; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel; -import com.microsoft.copilot.eclipse.core.lsp.protocol.TodoItem; -import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolSpecificData; import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CheckQuotaResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CopilotPlan; -import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; -import com.microsoft.copilot.eclipse.ui.chat.services.TodoListService; import com.microsoft.copilot.eclipse.ui.i18n.Messages; import com.microsoft.copilot.eclipse.ui.swt.CssConstants; import com.microsoft.copilot.eclipse.ui.utils.MenuUtils; @@ -59,13 +52,6 @@ public class ChatContentViewer extends Composite { private static final int SCROLL_THRESHOLD = 100; - /** - * Matches the trailing "| Request ID: ..." and "GitHub Request ID: ..." segments that the - * language server appends to user-facing error messages. - */ - private static final Pattern REQUEST_ID_SUFFIX = - Pattern.compile("\\s*\\|?\\s*(?:GitHub\\s+)?Request\\s+ID:\\s*\\S+\\.?", Pattern.CASE_INSENSITIVE); - private ChatServiceManager serviceManager; private String conversationId; @@ -253,8 +239,6 @@ private void doProcessTurnEvent(ChatProgressValue value) { return; } - ChatServiceManager chatServiceManager = CopilotUi.getPlugin().getChatServiceManager(); - if (value.getKind() == WorkDoneProgressKind.report) { if (turnWidget instanceof ThinkingTurnWidget thinkingTurn) { thinkingTurn.setConversationContext(conversationId, value.getTurnId()); @@ -277,9 +261,6 @@ private void doProcessTurnEvent(ChatProgressValue value) { if (agentRound.getToolCalls() != null && !agentRound.getToolCalls().isEmpty()) { AgentToolCall toolCall = agentRound.getToolCalls().get(0); turnWidget.appendToolCallStatus(toolCall); - - // Extract and process todo list from tool result details - processTodoListFromToolCall(chatServiceManager, value.getConversationId(), toolCall); } } else { // Handle chat mode responses @@ -294,15 +275,7 @@ private void doProcessTurnEvent(ChatProgressValue value) { turnWidget.flushMessageBuffer(); } - String errMsg = value.getErrorMessage(); - if (StringUtils.isNotEmpty(errMsg)) { - errMsg = REQUEST_ID_SUFFIX.matcher(errMsg).replaceAll(StringUtils.EMPTY).trim(); - } - String reason = value.getErrorReason(); - if (StringUtils.isNotEmpty(reason) && reason.equals("model_not_supported")) { - // TODO: add enable button for better UX. - errMsg = Messages.chat_model_unsupported_message; - } + String errMsg = ChatErrorMessages.resolveDisplayMessage(value); if (StringUtils.isNotEmpty(errMsg)) { // TODO: Remove this legacy fallback after TBB is officially released. // When the language server has not enabled token-based billing yet, fall back to the @@ -398,36 +371,6 @@ private static boolean hasRenderableAgentRound(ChatProgressValue value) { return false; } - /** - * Process todo list from tool call result. Extracts todo list data from the tool-specific data - * and updates the TodoListService. - * - * @param chatServiceManager the chat service manager - * @param conversationId the conversation ID - * @param toolCall the agent tool call containing tool-specific data - */ - private void processTodoListFromToolCall(ChatServiceManager chatServiceManager, String conversationId, - AgentToolCall toolCall) { - if (chatServiceManager == null || conversationId == null || toolCall == null) { - return; - } - - ToolSpecificData toolSpecificData = toolCall.getToolSpecificData(); - if (toolSpecificData == null || toolSpecificData.getTodoList() == null) { - return; - } - - TodoListService todoListService = chatServiceManager.getTodoListService(); - if (todoListService == null) { - return; - } - - List todos = toolSpecificData.getTodoList(); - if (todos != null) { - todoListService.setTodoList(new ArrayList<>(todos)); - } - } - /** * Shows the compacting status on the latest Copilot turn after flushing any buffered reply text. */ diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatErrorMessages.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatErrorMessages.java new file mode 100644 index 00000000..640b14e9 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatErrorMessages.java @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import java.util.regex.Pattern; + +import org.apache.commons.lang3.StringUtils; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatProgressValue; +import com.microsoft.copilot.eclipse.ui.i18n.Messages; + +/** + * Normalizes user-facing error messages carried by {@link ChatProgressValue} so that both the SWT + * ({@link ChatContentViewer}) and browser ({@link BrowserConversationWidget}) conversation widgets + * display identical text. + */ +public final class ChatErrorMessages { + + /** Server reason string indicating the selected model is not supported. */ + private static final String REASON_MODEL_NOT_SUPPORTED = "model_not_supported"; + + /** + * Matches the trailing "| Request ID: ..." and "GitHub Request ID: ..." segments that the + * language server appends to user-facing error messages. + */ + private static final Pattern REQUEST_ID_SUFFIX = Pattern.compile( + "\\s*\\|?\\s*(?:GitHub\\s+)?Request\\s+ID:\\s*\\S+\\.?", Pattern.CASE_INSENSITIVE); + + private ChatErrorMessages() { + } + + /** + * Resolves the display error message for a progress event: strips the trailing request-ID suffix + * and maps the {@code model_not_supported} reason to a friendly message. Returns the original + * message (which may be blank) when no normalization applies. + * + * @param value the progress event + * @return the normalized message to display, possibly blank + */ + public static String resolveDisplayMessage(ChatProgressValue value) { + if (value == null) { + return StringUtils.EMPTY; + } + String message = value.getErrorMessage(); + if (StringUtils.isNotEmpty(message)) { + message = REQUEST_ID_SUFFIX.matcher(message).replaceAll(StringUtils.EMPTY).trim(); + } + if (REASON_MODEL_NOT_SUPPORTED.equals(value.getErrorReason())) { + message = Messages.chat_model_unsupported_message; + } + return message; + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java index c5aa62c6..532e5af2 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java @@ -69,19 +69,13 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotStatusResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.RateLimitWarningParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.TodoItem; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolSpecificData; import com.microsoft.copilot.eclipse.core.lsp.protocol.Turn; import com.microsoft.copilot.eclipse.core.lsp.protocol.codingagent.CodingAgentMessageRequestParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.QuotaWarningParams; import com.microsoft.copilot.eclipse.core.persistence.AbstractTurnData; import com.microsoft.copilot.eclipse.core.persistence.ConversationPersistenceManager; import com.microsoft.copilot.eclipse.core.persistence.ConversationXmlData; -import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData; -import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.AgentMessageData; -import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.EditAgentRoundData; -import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ErrorData; -import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ErrorMessageData; -import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ReplyData; -import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ToolCallData; import com.microsoft.copilot.eclipse.core.persistence.UserTurnData; import com.microsoft.copilot.eclipse.terminal.api.IRunInTerminalTool; import com.microsoft.copilot.eclipse.terminal.api.TerminalServiceManager; @@ -116,7 +110,7 @@ public class ChatView extends ViewPart implements ChatProgressListener, MessageL private Composite contentWrapper; private HandoffContainer handoffContainer; private ActionBar actionBar; - private ChatContentViewer chatContentViewer; + private IConversationWidget conversationWidget; private Composite loadingViewer; private Composite noSubscriptionViewer; private Composite beforeLoginWelcomeViewer; @@ -297,8 +291,8 @@ public void done(IJobChangeEvent event) { } // Clear existing content by recreating the chat content viewer - if (chatContentViewer != null) { - chatContentViewer.dispose(); + if (conversationWidget != null) { + conversationWidget.dispose(); createConversationPage(); } @@ -328,7 +322,9 @@ public void done(IJobChangeEvent event) { } // Scroll to bottom after restoring all turns - SwtUtils.invokeOnDisplayThreadAsync(this::scrollContentToBottom, chatContentViewer); + if (conversationWidget != null) { + conversationWidget.scrollToBottom(); + } // Hide chat history and show restored conversation hideChatHistory(); @@ -416,10 +412,10 @@ public void done(IJobChangeEvent event) { return; } SwtUtils.invokeOnDisplayThreadAsync(() -> { - if (this.chatContentViewer == null || this.chatContentViewer.isDisposed()) { + if (this.conversationWidget == null || this.conversationWidget.isDisposed()) { return; } - this.chatContentViewer.showCompactingStatusOnLatestCopilotTurn(); + this.conversationWidget.showCompactingStatusOnLatestCopilotTurn(); }, parent); }; this.eventBroker.subscribe(CopilotEventConstants.TOPIC_CHAT_COMPRESSION_STARTED, @@ -434,10 +430,10 @@ public void done(IJobChangeEvent event) { return; } SwtUtils.invokeOnDisplayThreadAsync(() -> { - if (this.chatContentViewer == null || this.chatContentViewer.isDisposed()) { + if (this.conversationWidget == null || this.conversationWidget.isDisposed()) { return; } - this.chatContentViewer.hideCompactingStatusOnLatestCopilotTurn(); + this.conversationWidget.hideCompactingStatusOnLatestCopilotTurn(); if (params.contextInfo() != null) { this.chatServiceManager.getContextWindowService().updateContextSize(params.contextInfo()); } @@ -786,8 +782,17 @@ private void createLoadingPage() { */ private void createConversationPage() { clearChatView(this.contentWrapper); - this.chatContentViewer = new ChatContentViewer(this.contentWrapper, SWT.NONE, this.chatServiceManager); - this.chatContentViewer.requestLayout(); + this.conversationWidget = createConversationWidget(this.contentWrapper); + this.conversationWidget.requestLayout(); + } + + private IConversationWidget createConversationWidget(Composite parent) { + boolean useBrowser = CopilotUi.getPlugin().getPreferenceStore() + .getBoolean(Constants.USE_BROWSER_BASED_CHAT_RENDERER); + if (useBrowser) { + return new BrowserConversationWidget(parent); + } + return new StyledTextConversationWidget(parent, this.chatServiceManager); } /** @@ -836,9 +841,9 @@ private void clearChatView(Composite composite) { this.agentModeViewer.dispose(); this.agentModeViewer = null; } - if (this.chatContentViewer != null) { - this.chatContentViewer.dispose(); - this.chatContentViewer = null; + if (this.conversationWidget != null) { + this.conversationWidget.dispose(); + this.conversationWidget = null; } if (this.noSubscriptionViewer != null) { this.noSubscriptionViewer.dispose(); @@ -855,13 +860,13 @@ private void clearChatView(Composite composite) { */ @Override public void onChatProgress(ChatProgressValue value) { - if (this.actionBar.isSendButton()) { + if (this.actionBar == null || this.actionBar.isSendButton()) { return; } switch (value.getKind()) { case begin: - if (this.chatContentViewer != null) { - this.chatContentViewer.getLatestOrCreateNewTurnWidget(value.getTurnId(), true, false); + if (this.conversationWidget != null) { + this.conversationWidget.beginTurn(value.getTurnId(), true, false); } // Handle subagent conversation ID management @@ -885,8 +890,8 @@ public void onChatProgress(ChatProgressValue value) { this.conversationState = ConversationState.CONTINUED_CONVERSATION; } // Always sync conversationId — chatContentViewer may have been recreated - if (this.chatContentViewer != null) { - this.chatContentViewer.setConversationId(this.conversationId); + if (this.conversationWidget != null) { + this.conversationWidget.setConversationId(this.conversationId); } } @@ -934,14 +939,20 @@ public void onChatProgress(ChatProgressValue value) { } if ((value.getAgentRounds() == null || value.getAgentRounds().isEmpty()) && (value.getReply() == null || value.getReply().isEmpty()) - && (value.getThinking() == null || StringUtils.isBlank(value.getThinking().text()))) { + && (value.getThinking() == null || StringUtils.isBlank(value.getThinking().text())) + && StringUtils.isEmpty(value.getErrorMessage())) { return; } - if (this.chatContentViewer != null) { - this.chatContentViewer.processTurnEvent(value); + if (this.conversationWidget != null) { + this.conversationWidget.processTurnEvent(value); } - String thinkingBlockId = this.chatContentViewer != null - ? this.chatContentViewer.getActiveThinkingBlockId(value.getTurnId()) : null; + + // Update the todo list from any todo-writing tool calls in this report. This is + // renderer-agnostic domain logic, so it lives here rather than inside a specific widget. + updateTodoListFromReport(value); + + String thinkingBlockId = this.conversationWidget != null + ? this.conversationWidget.getActiveThinkingBlockId(value.getTurnId()) : null; // Track run_subagent tool call ID for associating subagent turns if (StringUtils.isBlank(value.getParentTurnId()) && value.getAgentRounds() != null) { @@ -983,13 +994,13 @@ public void onChatProgress(ChatProgressValue value) { return; } - if (this.chatContentViewer != null) { - this.chatContentViewer.processTurnEvent(value); + if (this.conversationWidget != null) { + this.conversationWidget.processTurnEvent(value); this.actionBar.resetSendButton(); this.topBanner.updateTitle(value.getSuggestedTitle()); } - String endThinkingBlockId = this.chatContentViewer != null - ? this.chatContentViewer.getActiveThinkingBlockId(value.getTurnId()) : null; + String endThinkingBlockId = this.conversationWidget != null + ? this.conversationWidget.getActiveThinkingBlockId(value.getTurnId()) : null; // Persist final conversation state and conversation title on end if (persistenceManager != null) { @@ -1225,7 +1236,9 @@ private void onSendInternal(String workDoneToken, String message, String agentSl if (createNewTurn) { // TODO: Move to createPartControl...eventBroker.subscribe(CopilotEventConstants.TOPIC_CHAT_ON_SEND...(line 114) // after the refactor. - this.chatContentViewer.startNewTurn(workDoneToken, message); + if (this.conversationWidget != null) { + this.conversationWidget.startNewUserTurn(workDoneToken, message); + } } } @@ -1295,13 +1308,15 @@ private void displayErrorAndResetSendButton(String workDoneToken, String message } String content = String.format(Messages.chat_chatContentView_errorTemplate, message, workDoneToken); SwtUtils.invokeOnDisplayThread(() -> { - chatContentViewer.renderErrorMessage(content); + if (conversationWidget != null) { + conversationWidget.renderErrorMessage(content); + } actionBar.resetSendButton(); }, parent); } private void handleCodingAgentMessage(CodingAgentMessageRequestParams params) { - if (params == null || this.chatContentViewer == null) { + if (params == null) { return; } @@ -1315,14 +1330,9 @@ private void handleCodingAgentMessage(CodingAgentMessageRequestParams params) { persistenceManager.addCodingAgentMessage(params, UiConstants.GITHUB_COPILOT_CODING_AGENT_SLUG); } - SwtUtils.invokeOnDisplayThread(() -> { - if (this.chatContentViewer != null && !this.chatContentViewer.isDisposed()) { - BaseTurnWidget turnWidget = this.chatContentViewer.getTurnWidget(params.getTurnId()); - if (turnWidget != null && !turnWidget.isDisposed()) { - turnWidget.createAgentMessageWidget(params); - } - } - }, parent); + if (this.conversationWidget != null && !this.conversationWidget.isDisposed()) { + this.conversationWidget.renderAgentMessage(params); + } } /** @@ -1439,8 +1449,8 @@ public void onCancel() { if (this.actionBar != null && !this.actionBar.isDisposed()) { this.actionBar.resetSendButton(); } - if (this.chatContentViewer != null && !this.chatContentViewer.isDisposed()) { - this.chatContentViewer.hideCompactingStatusOnLatestCopilotTurn(); + if (this.conversationWidget != null && !this.conversationWidget.isDisposed()) { + this.conversationWidget.hideCompactingStatusOnLatestCopilotTurn(); } } @@ -1491,10 +1501,36 @@ public String getSubagentConversationId() { } /** - * Get the current chat content viewer. + * Returns the active conversation widget. + */ + public IConversationWidget getConversationWidget() { + return this.conversationWidget; + } + + /** + * Updates the {@link TodoListService} from any todo-writing tool calls carried by a report + * progress event. Kept in the view (not a widget) because it is renderer-agnostic domain logic + * shared by both the SWT and browser conversation widgets. */ - public ChatContentViewer getChatContentViewer() { - return this.chatContentViewer; + private void updateTodoListFromReport(ChatProgressValue value) { + if (value == null || value.getAgentRounds() == null || value.getAgentRounds().isEmpty()) { + return; + } + TodoListService todoListService = chatServiceManager.getTodoListService(); + if (todoListService == null) { + return; + } + for (AgentRound round : value.getAgentRounds()) { + if (round.getToolCalls() == null) { + continue; + } + for (AgentToolCall toolCall : round.getToolCalls()) { + ToolSpecificData toolSpecificData = toolCall.getToolSpecificData(); + if (toolSpecificData != null && toolSpecificData.getTodoList() != null) { + todoListService.setTodoList(new ArrayList<>(toolSpecificData.getTodoList())); + } + } + } } /** @@ -1673,9 +1709,9 @@ public void dispose() { this.chatHistoryViewer.dispose(); this.chatHistoryViewer = null; } - if (this.chatContentViewer != null) { - this.chatContentViewer.dispose(); - this.chatContentViewer = null; + if (this.conversationWidget != null) { + this.conversationWidget.dispose(); + this.conversationWidget = null; } if (this.afterLoginWelcomeViewer != null) { this.afterLoginWelcomeViewer.dispose(); @@ -1884,21 +1920,16 @@ public void hideChatHistory() { * Scroll the chat content viewer to the bottom. */ public void scrollContentToBottom() { - if (chatContentViewer == null || chatContentViewer.isDisposed()) { + if (conversationWidget == null || conversationWidget.isDisposed()) { return; } - - SwtUtils.invokeOnDisplayThreadAsync(() -> { - chatContentViewer.refreshLayoutFull(); - chatContentViewer.scrollToBottomIfAutoScroll(); - }, chatContentViewer); + conversationWidget.scrollToBottom(); } /** - * Render model information in the Copilot turn widget. + * Render model information in the conversation widget. * * @param turnId the turn ID - * @param conversationId the conversation ID to use for persistence * @param modelName the model name * @param billingMultiplier the billing multiplier * @param reasoningEffort the reasoning effort sent for this turn (may be {@code null} when the model does not @@ -1906,134 +1937,23 @@ public void scrollContentToBottom() { */ private void renderModelInfoInTurnWidget(String turnId, String modelName, double billingMultiplier, String reasoningEffort) { - BaseTurnWidget turnWidget = this.chatContentViewer.getTurnWidget(turnId); - if (turnWidget instanceof CopilotTurnWidget copilotWidget) { - copilotWidget.renderModelInfo(modelName, billingMultiplier, reasoningEffort); - - // Refresh the scroller layout to ensure the footer is visible. - SwtUtils.invokeOnDisplayThreadAsync(() -> { - this.chatContentViewer.refreshLayoutFull(); - this.chatContentViewer.scrollToBottomIfAutoScroll(); - }, this.chatContentViewer); + if (this.conversationWidget == null || this.conversationWidget.isDisposed()) { + return; } + this.conversationWidget.renderModelInfo(turnId, modelName, billingMultiplier, reasoningEffort); } /** - * Restore a single turn from persisted conversation data. + * Restore a single turn from persisted conversation data. Delegates to the active + * {@link IConversationWidget} implementation. * * @param turn the turn data to restore */ private void restoreTurn(AbstractTurnData turn) { - if (turn == null || chatContentViewer == null) { - return; - } - - // Subagent turns: render their content inside the parent turn's subagent block - if (turn instanceof CopilotTurnData copilotTurn - && StringUtils.isNotBlank(copilotTurn.getParentTurnId())) { - BaseTurnWidget parentWidget = chatContentViewer.getTurnWidget(copilotTurn.getParentTurnId()); - if (parentWidget != null) { - String toolCallId = copilotTurn.getSubagentToolCallId(); - if (StringUtils.isNotBlank(toolCallId)) { - // Restore subagent content into the SubagentMessageBlock identified by the tool call ID - parentWidget.restoreSubagentContent(toolCallId, copilotTurn, persistenceManager.getDataFactory()); - } else { - // Fallback: append to parent widget directly (legacy data without subagentToolCallId) - restoreCopilotTurnContent(copilotTurn, parentWidget); - } - } + if (turn == null || conversationWidget == null || conversationWidget.isDisposed()) { return; } - - // Create user turn widget and populate with user message - if (turn instanceof UserTurnData userTurn) { - if (userTurn.getMessage() == null || StringUtils.isNotBlank(userTurn.getMessage().getText())) { - BaseTurnWidget userTurnWidget = chatContentViewer.getLatestOrCreateNewTurnWidget(turn.getTurnId(), false, true); - userTurnWidget.appendMessage(userTurn.getMessage().getText()); - userTurnWidget.flushMessageBuffer(); - return; - } - } else if (turn instanceof CopilotTurnData copilotTurn) { - BaseTurnWidget copilotTurnWidget = chatContentViewer.getLatestOrCreateNewTurnWidget(turn.getTurnId(), true, true); - restoreCopilotTurnContent(copilotTurn, copilotTurnWidget); - - copilotTurnWidget.flushMessageBuffer(); - - // Restore model info footer if model name is present - // This must be done AFTER flushMessageBuffer() to ensure footer appears at the bottom - ReplyData replyData = copilotTurn.getReply(); - if (replyData != null && StringUtils.isNotBlank(replyData.getModelName())) { - // Reasoning effort was captured and persisted at send time so the footer reflects what was actually used - // for this turn, not whatever the user has selected now. - renderModelInfoInTurnWidget(turn.getTurnId(), replyData.getModelName(), replyData.getBillingMultiplier(), - replyData.getReasoningEffort()); - } - } - } - - /** - * Restores the content of a CopilotTurnData (reply text, agent rounds, tool calls, errors, agent messages) into the - * given turn widget. Used for both main copilot turns and subagent turns. - */ - private void restoreCopilotTurnContent(CopilotTurnData copilotTurn, BaseTurnWidget turnWidget) { - ReplyData replyData = copilotTurn.getReply(); - if (replyData == null) { - return; - } - - ThinkingTurnWidget thinkingWidget = - turnWidget instanceof ThinkingTurnWidget ? (ThinkingTurnWidget) turnWidget : null; - - if (StringUtils.isNotBlank(replyData.getText())) { - turnWidget.appendMessage(replyData.getText()); - } - - if (replyData.getEditAgentRounds() != null && !replyData.getEditAgentRounds().isEmpty()) { - for (EditAgentRoundData round : replyData.getEditAgentRounds()) { - // Restore thinking block before the round's reply and tool calls - if (thinkingWidget != null && round.getThinkingBlock() != null) { - thinkingWidget.restoreThinkingBlock(round.getThinkingBlock()); - } - if (round.getReply() != null && !round.getReply().isEmpty()) { - turnWidget.appendMessage(round.getReply()); - } - if (round.getToolCalls() != null && !round.getToolCalls().isEmpty()) { - for (ToolCallData toolCallData : round.getToolCalls()) { - AgentToolCall agentToolCall = persistenceManager.getDataFactory() - .convertToolCallDataToAgentToolCall(toolCallData); - turnWidget.appendToolCallStatus(agentToolCall); - } - } - } - } - - if (replyData.getErrorMessages() != null && !replyData.getErrorMessages().isEmpty()) { - for (ErrorMessageData errorMessageData : replyData.getErrorMessages()) { - ErrorData errorData = errorMessageData.getError(); - SwtUtils.invokeOnDisplayThread(() -> { - String errorMessage = errorData != null ? errorData.getMessage() : Messages.chat_warnWidget_defaultErrorMsg; - int errorCode = errorData != null ? errorData.getCode() : 0; - String modelProviderName = errorData != null ? errorData.getModelProviderName() : null; - turnWidget.createWarnDialog(errorMessage, errorCode, modelProviderName); - }, parent); - } - } - - if (replyData.getAgentMessages() != null && !replyData.getAgentMessages().isEmpty()) { - for (AgentMessageData agentMessageData : replyData.getAgentMessages()) { - if (StringUtils.equals(agentMessageData.getAgentSlug(), UiConstants.GITHUB_COPILOT_CODING_AGENT_SLUG)) { - SwtUtils.invokeOnDisplayThread(() -> { - CodingAgentMessageRequestParams params = new CodingAgentMessageRequestParams(); - params.setTitle(agentMessageData.getTitle()); - params.setDescription(agentMessageData.getDescription()); - params.setPrLink(agentMessageData.getPrLink()); - params.setConversationId(this.conversationId); - params.setTurnId(copilotTurn.getTurnId()); - turnWidget.createAgentMessageWidget(params); - }, parent); - } - } - } + conversationWidget.restoreTurn(turn, persistenceManager.getDataFactory()); } /** diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ConversationHtmlBlockFactory.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ConversationHtmlBlockFactory.java new file mode 100644 index 00000000..e9e02c63 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ConversationHtmlBlockFactory.java @@ -0,0 +1,541 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import java.util.List; +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; +import org.commonmark.Extension; +import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension; +import org.commonmark.ext.gfm.tables.TablesExtension; +import org.commonmark.ext.task.list.items.TaskListItemsExtension; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.html.HtmlRenderer; + +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentToolCall; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ThinkingBlockData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ToolCallData; +import com.microsoft.copilot.eclipse.ui.chat.QuotaActions.QuotaAction; + +/** + * Factory for creating HTML block fragments used by {@link BrowserConversationWidget}. + * + *

Each method produces a self-contained HTML DIV block having an + * {@code id} attribute. The widget inserts or updates these blocks in the browser DOM + * using the generic JavaScript API for code block manipulation. + */ +public class ConversationHtmlBlockFactory { + + // --- SVG icon constants (shared with BrowserConversationWidget) --- + + /** Lightbulb icon used for sealed (completed) thinking blocks. */ + static final String BULB_SVG = "" + + ""; + + /** Pull request icon used in agent message blocks. */ + static final String PR_SVG = "" + + ""; + + /** Terminal/command icon used in tool confirmation blocks. */ + static final String TERMINAL_SVG = "" + + "" + + ""; + + /** Warning triangle icon used in quota warning and error blocks. */ + static final String WARNING_SVG = "" + + ""; + + private final Parser markdownParser; + private final HtmlRenderer htmlRenderer; + private String copyIconDataUri = ""; + private String insertIconDataUri = ""; + + /** Creates a new factory with GFM tables and other extensions. */ + public ConversationHtmlBlockFactory() { + List extensions = List.of( + TablesExtension.create(), + TaskListItemsExtension.create(), + StrikethroughExtension.create() + ); + this.markdownParser = Parser.builder().extensions(extensions).build(); + this.htmlRenderer = HtmlRenderer.builder() + .extensions(extensions) + // prevent HTML injection in potentially malicious LLM-generated Markdown code + .escapeHtml(true) + // avoid potentially malicious URL schemes in LLM-generated Markdown code + .sanitizeUrls(true) + .build(); + } + + /** + * Sets the data URIs for code block action button icons. + * + * @param copyIconUri base64 data URI for the copy icon + * @param insertIconUri base64 data URI for the insert icon + */ + public void setCodeBlockIcons(String copyIconUri, String insertIconUri) { + this.copyIconDataUri = copyIconUri != null ? copyIconUri : ""; + this.insertIconDataUri = insertIconUri != null ? insertIconUri : ""; + } + + /** + * User turn container ID: {@code turnId-user}. User and copilot turns share the same + * server-assigned turn ID (workDoneToken), so each role gets a distinct container ID. + */ + public static String userTurnContainerId(String turnId) { + return turnId + "-user"; + } + + /** + * Copilot turn container ID: {@code turnId-copilot}. Paired with + * {@link #userTurnContainerId(String)} to avoid duplicate DOM IDs. + */ + public static String copilotTurnContainerId(String turnId) { + return turnId + "-copilot"; + } + + /** Content container ID, scoped by role: {@code turnId-user-content} or + * {@code turnId-copilot-content}. + */ + public static String contentBlockId(String turnId, boolean isCopilot) { + String containerId = isCopilot + ? copilotTurnContainerId(turnId) : userTurnContainerId(turnId); + return containerId + "-content"; + } + + /** Sequential copilot child block ID: {@code turnId-N}. */ + public static String copilotChildBlockId(String turnId, int index) { + return turnId + "-" + index; + } + + /** Tool call block ID: {@code turnId-tc-N}. */ + public static String toolCallBlockId(String turnId, int index) { + return turnId + "-tc-" + index; + } + + /** Compacting status block ID: {@code turnId-compacting}. */ + public static String compactingBlockId(String turnId) { + return turnId + "-compacting"; + } + + /** Model info block ID: {@code turnId-model-info}. */ + public static String modelInfoBlockId(String turnId) { + return turnId + "-model-info"; + } + + /** Agent message block ID: {@code turnId-agent-msg-timestamp}. */ + public static String agentMessageBlockId(String turnId) { + return turnId + "-agent-msg-" + System.currentTimeMillis(); + } + + /** Streaming indicator block ID: {@code turnId-streaming}. */ + public static String streamingIndicatorId(String turnId) { + return turnId + "-streaming"; + } + + /** Confirmation block ID: {@code turnId-confirm}. */ + public static String confirmationBlockId(String turnId) { + return turnId + "-confirm"; + } + + /** Subagent block ID: {@code parentTurnId-subagent-toolCallId}. */ + public static String subagentBlockId(String parentTurnId, String toolCallId) { + return parentTurnId + "-subagent-" + toolCallId; + } + + /** Subagent content-area ID (where the nested subagent turn's child blocks are inserted). */ + public static String subagentContentAreaId(String parentTurnId, String toolCallId) { + return subagentBlockId(parentTurnId, toolCallId) + "-content"; + } + + /** + * Creates the outer turn container with header (avatar + name) and empty content div. + */ + public String createTurnContainerHtmlBlock(String turnId, boolean isCopilot, + String avatarDataUri, String displayName) { + String containerId = isCopilot + ? copilotTurnContainerId(turnId) : userTurnContainerId(turnId); + String cssClass = isCopilot ? "turn turn-copilot" : "turn turn-user"; + StringBuilder html = new StringBuilder(); + html.append("

"); + html.append("
"); + if (avatarDataUri != null && !avatarDataUri.isEmpty()) { + html.append("\"\"/"); + } + html.append("") + .append(escapeHtml(displayName)).append(""); + html.append("
"); + html.append("
"); + html.append("
"); + return html.toString(); + } + + /** + * Creates a nested subagent block: a bordered card with a header (copilot avatar + title) and an + * inner content area into which the subagent turn's child blocks are inserted. Mirrors the SWT + * {@code SubagentTurnWidget} (copilot avatar + tool-call-derived title) and shares the + * {@code subagent-message-block} CSS class for visual parity with the other message cards. + */ + public String createSubagentBlockHtmlBlock(String blockId, String contentAreaId, + String avatarDataUri, String title) { + StringBuilder html = new StringBuilder(); + html.append("
"); + html.append("
"); + if (StringUtils.isNotBlank(avatarDataUri)) { + html.append("\"\"/"); + } + html.append(escapeHtml(StringUtils.isNotBlank(title) ? title : "Subagent")) + .append("
"); + html.append("
"); + html.append("
"); + return html.toString(); + } + + /** Creates a collapsible thinking block (open by default during streaming, with spinner). */ + public String createThinkingHtmlBlock(String blockId, String thinkingText) { + StringBuilder html = new StringBuilder(); + html.append("
"); + html.append("
"); + html.append("") + .append("") + .append("Thinking…"); + html.append("
") + .append(escapeHtml(thinkingText)).append("
"); + html.append("
"); + html.append("
"); + return html.toString(); + } + + /** Creates a sealed/completed thinking block (closed, no spinner, with optional title). */ + public String createSealedThinkingHtmlBlock(String blockId, String thinkingText, String title) { + StringBuilder html = new StringBuilder(); + html.append("
"); + html.append("
"); + String summary = StringUtils.isNotBlank(title) + ? escapeHtml(title) : "Thinking…"; + html.append("").append(BULB_SVG) + .append("") + .append(summary).append(""); + html.append("
") + .append(escapeHtml(thinkingText)).append("
"); + html.append("
"); + html.append("
"); + return html.toString(); + } + + /** 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) { + StringBuilder html = new StringBuilder(); + String status = toolCall.getStatus(); + String statusClass = toolCallStatusClass(status); + html.append("
"); + html.append(toolCallIcon(status)); + html.append("") + .append(escapeHtml(toolCall.getName())).append(""); + if (toolCall.getProgressMessage() != null + && !toolCall.getProgressMessage().isEmpty()) { + html.append(" ") + .append(escapeHtml(toolCall.getProgressMessage())).append(""); + } + html.append("
"); + return html.toString(); + } + + /** Creates a tool call status block from persisted ToolCallData. */ + public String createRestoredToolCallHtmlBlock(String blockId, ToolCallData tc) { + StringBuilder html = new StringBuilder(); + String status = tc.getStatus(); + String statusClass = toolCallStatusClass(status); + html.append("
"); + html.append(toolCallIcon(status)); + html.append("") + .append(escapeHtml(tc.getName())).append(""); + if (StringUtils.isNotBlank(tc.getProgressMessage())) { + html.append(" ") + .append(escapeHtml(tc.getProgressMessage())).append(""); + } + html.append("
"); + return html.toString(); + } + + /** + * Returns the icon HTML for a tool call status. Running state shows animated dots; + * completed shows a green checkmark; error shows a red cross. + * + *

Note: The "running" state is sent by the language server for in-progress tool calls. + * Most tool calls complete quickly, so the running indicator may only flash briefly. + */ + private static String toolCallIcon(String status) { + if ("completed".equals(status)) { + return " "; + } else if ("error".equals(status)) { + return " "; + } else if ("cancelled".equals(status)) { + return " "; + } + // running or unknown — spinning circle (same as thinking spinner) + return "" + + " "; + } + + private static String toolCallStatusClass(String status) { + if ("completed".equals(status)) { + return " tc-completed"; + } else if ("error".equals(status) || "cancelled".equals(status)) { + return " tc-failed"; + } + return ""; + } + + /** Creates a copilot response block with rendered Markdown content. */ + public String createCopilotReplyHtmlBlock(String blockId, String markdownText) { + String renderedHtml = renderMarkdown(markdownText); + return "

" + + renderedHtml + "
"; + } + + /** Creates a user request block with rendered Markdown content. */ + public String createUserRequestHtmlBlock(String blockId, String markdownText) { + String renderedHtml = renderMarkdown(markdownText); + return "
" + + renderedHtml + "
"; + } + + /** Creates an error message block (simple, no action buttons). */ + public String createErrorMessageHtmlBlock(String blockId, String errorMessage) { + return createWarningMessageHtmlBlock(blockId, errorMessage, List.of()); + } + + /** + * Creates a warning message block with an SVG warning icon, message text, and optional + * action buttons. Used for quota-exceeded (402) errors and generic turn-level errors. + * + * @param blockId unique DOM element ID + * @param message the warning/error message to display + * @param actions quota actions to render as buttons; empty list for no buttons + * @return self-contained HTML div block + */ + public String createWarningMessageHtmlBlock(String blockId, String message, + List actions) { + StringBuilder html = new StringBuilder(); + html.append("
"); + html.append("
") + .append(WARNING_SVG) + .append("").append(escapeHtml(message)).append("") + .append("
"); + if (actions != null && !actions.isEmpty()) { + html.append("
"); + for (QuotaAction action : actions) { + String cssClass = action.primary() ? "btn-confirm btn-primary" : "btn-confirm"; + html.append(""); + } + html.append("
"); + } + html.append("
"); + return html.toString(); + } + + /** Creates a standalone error turn block (for top-level errors). */ + public String createErrorTurnHtmlBlock(String blockId, String renderedContent) { + return "
" + + "
" + renderedContent + "
"; + } + + /** Creates a compacting status block. */ + public String createCompactingStatusHtmlBlock(String blockId) { + return "
⏳ Compacting…
"; + } + + /** Creates a model info footer block. */ + public String createModelInfoHtmlBlock(String blockId, String modelName, + double billingMultiplier, String reasoningEffort) { + StringBuilder html = new StringBuilder(); + html.append("
"); + html.append("").append(escapeHtml(modelName)) + .append(""); + if (billingMultiplier > 0 && billingMultiplier != 1.0) { + html.append(" (") + .append(billingMultiplier).append("x)"); + } + if (reasoningEffort != null && !reasoningEffort.isEmpty()) { + String capitalized = reasoningEffort.substring(0, 1).toUpperCase() + + reasoningEffort.substring(1); + html.append(" - ") + .append(escapeHtml(capitalized)).append(""); + } + html.append("
"); + return html.toString(); + } + + /** Creates an agent message block (e.g., coding agent PR link). */ + public String createAgentMessageHtmlBlock(String blockId, String title, + String description, String prLink) { + StringBuilder html = new StringBuilder(); + html.append("
"); + if (StringUtils.isNotBlank(title)) { + html.append("
") + .append(PR_SVG) + .append(escapeHtml(title)) + .append("
"); + } + if (StringUtils.isNotBlank(description)) { + html.append("

") + .append(escapeHtml(description)).append("

"); + } + if (StringUtils.isNotBlank(prLink)) { + html.append("
"); + html.append(""); + html.append(""); + html.append("
"); + } + html.append("
"); + return html.toString(); + } + + /** Creates a streaming indicator (animated dots) shown while waiting for copilot response. */ + public String createStreamingIndicatorHtmlBlock(String blockId) { + return "
" + + "" + + "
"; + } + + /** + * Creates an inline confirmation block with title, optional message, optional command panel, + * and action buttons. Mimics the layout of the SWT {@code InvokeToolConfirmationDialog}. + */ + @SuppressWarnings("unchecked") + public String createConfirmationHtmlBlock(String blockId, ConfirmationContent content, Object input) { + StringBuilder html = new StringBuilder(); + html.append("
"); + + // Title with terminal/command icon + html.append("
") + .append(TERMINAL_SVG) + .append(escapeHtml(content.getTitle())).append("
"); + + // Message + if (StringUtils.isNotBlank(content.getMessage())) { + html.append("
") + .append(escapeHtml(content.getMessage())).append("
"); + } + + // Command panel (extracted from input map) + if (input instanceof Map) { + Map inputMap = (Map) input; + Object command = inputMap.get("command"); + if (command != null) { + html.append("
") + .append(escapeHtml(command.toString())).append("
"); + } + Object explanation = inputMap.get("explanation"); + if (explanation != null) { + html.append("
") + .append(escapeHtml(explanation.toString())).append("
"); + } + } + + // Action buttons + html.append("
"); + List actions = content.getActions(); + if (actions != null) { + for (int i = 0; i < actions.size(); i++) { + ConfirmationAction action = actions.get(i); + String cssClass = action.isPrimary() ? "btn-confirm btn-primary" : "btn-confirm"; + String onclick = action.isAccept() + ? "window.acceptToolAction(" + i + ")" + : "window.dismissToolAction()"; + html.append(""); + } + } + html.append("
"); + html.append("
"); + return html.toString(); + } + + /** Renders Markdown to HTML and injects code block action buttons. */ + public String renderMarkdown(String markdown) { + if (markdown == null || markdown.isEmpty()) { + return ""; + } + String html = htmlRenderer.render(markdownParser.parse(markdown)); + return injectCodeBlockButtons(html); + } + + /** + * Post-processes rendered HTML to inject Copy/Insert action buttons into code blocks. + * Uses the platform icons loaded via {@link #setCodeBlockIcons}. + */ + public String injectCodeBlockButtons(String html) { + String copyImg = copyIconDataUri.isEmpty() ? "" + : ""; + String insertImg = insertIconDataUri.isEmpty() ? "" + : ""; + String buttons = "
" + + "" + + "
"; + return html.replace("", buttons + ""); + } + + /** Escapes special HTML characters in the given text. Returns empty string for null. */ + public static String escapeHtml(String text) { + if (text == null) { + return ""; + } + return text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/IConversationWidget.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/IConversationWidget.java new file mode 100644 index 00000000..acbac96a --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/IConversationWidget.java @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import java.util.concurrent.CompletableFuture; + +import org.eclipse.swt.custom.StyledText; +import org.eclipse.swt.widgets.Control; + +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatProgressValue; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolConfirmationResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.codingagent.CodingAgentMessageRequestParams; +import com.microsoft.copilot.eclipse.core.persistence.AbstractTurnData; +import com.microsoft.copilot.eclipse.core.persistence.ConversationDataFactory; + +/** + * Abstraction for the conversation content area in the chat view. Implementations render chat turns + * either via ({@link StyledTextConversationWidget}) using {@link StyledText} + * or via ({@link BrowserConversationWidget}) using an Eclipse-internal web browser rendering HTML code. + */ +public interface IConversationWidget { + + /** + * Returns the underlying SWT control for layout purposes. + */ + Control getControl(); + + /** + * Returns whether this widget has been disposed. + */ + boolean isDisposed(); + + /** + * Disposes this widget and releases all resources. + */ + void dispose(); + + /** + * Requests a layout pass on the underlying control. + */ + void requestLayout(); + + /** + * Sets the conversation ID for this widget. + */ + void setConversationId(String conversationId); + + /** + * Begins a new user / copilot turn for the given turn ID. + * + * @param turnId the unique ID of the turn + * @param isCopilot true if this is a Copilot turn, false for user turns + * @param isHistory true if the turn is being restored from history + */ + void beginTurn(String turnId, boolean isCopilot, boolean isHistory); + + /** + * Processes a chat progress event (report or end phase). The widget updates the turn content + * accordingly. + */ + void processTurnEvent(ChatProgressValue value); + + /** + * Creates a new user turn entry in the conversation. + * + * @param turnId the turn ID + * @param message the user's message text + */ + void startNewUserTurn(String turnId, String message); + + /** + * Scrolls the conversation content to the bottom. + */ + void scrollToBottom(); + + /** + * Refreshes the internal scroller layout (e.g., after content changes). + */ + void refreshScrollerLayout(); + + /** + * Renders an error message in the conversation area. + */ + void renderErrorMessage(String content); + + /** + * Shows a "compacting" status indicator on the latest Copilot turn. + */ + void showCompactingStatusOnLatestCopilotTurn(); + + /** + * Hides the "compacting" status indicator on the latest Copilot turn. + */ + void hideCompactingStatusOnLatestCopilotTurn(); + + /** + * Returns the active thinking block ID for the given turn, or null if none. + */ + String getActiveThinkingBlockId(String turnId); + + /** + * Restores a single turn from persisted conversation data. Implementations render user turns, + * copilot turns (with thinking blocks, tool calls, errors, agent messages), and model info + * footers. + * + * @param turn the turn data to restore (either {@code UserTurnData} or {@code CopilotTurnData}) + * @param dataFactory factory for converting persisted data to runtime objects + */ + void restoreTurn(AbstractTurnData turn, ConversationDataFactory dataFactory); + + /** + * Renders model info footer below a copilot turn (model name, billing multiplier, reasoning + * effort). + * + * @param turnId the turn ID to attach the footer to + * @param modelName the model name to display + * @param billingMultiplier the billing multiplier (0 means not shown) + * @param reasoningEffort the reasoning effort level (may be null) + */ + void renderModelInfo(String turnId, String modelName, double billingMultiplier, + String reasoningEffort); + + /** + * Renders a coding agent message (e.g., PR link) in the specified turn. + * + * @param params the agent message parameters containing turn ID, title, description, and link + */ + void renderAgentMessage(CodingAgentMessageRequestParams params); + + /** + * Requests tool execution confirmation from the user. Implementations render a confirmation UI + * (inline HTML in browser view, or SWT dialog in SWT view) and return a future that completes + * when the user accepts or dismisses. + * + * @param turnId the turn ID where the confirmation should appear + * @param content confirmation content with title, message, and action buttons + * @param input tool input (may contain "command", "explanation", "action" keys) + * @return future that completes with the user's confirmation result + */ + CompletableFuture requestToolConfirmation( + String turnId, ConfirmationContent content, Object input); + + /** + * Cancels any pending tool confirmation for the given turn. Completes the pending future with + * DISMISS and removes the confirmation UI. + * + * @param turnId the turn ID whose confirmation should be cancelled + */ + void cancelToolConfirmation(String turnId); + + /** + * Returns the selected {@link ConfirmationAction} from the last completed confirmation, or null + * if the user dismissed or no confirmation was shown. Used by the caller to cache decisions. + */ + ConfirmationAction getLastSelectedConfirmationAction(); +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/StyledTextConversationWidget.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/StyledTextConversationWidget.java new file mode 100644 index 00000000..394fd4c3 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/StyledTextConversationWidget.java @@ -0,0 +1,307 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import java.util.concurrent.CompletableFuture; + +import org.apache.commons.lang3.StringUtils; +import org.eclipse.swt.SWT; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; + +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentToolCall; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatProgressValue; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolConfirmationResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolConfirmationResult.ToolConfirmationResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.codingagent.CodingAgentMessageRequestParams; +import com.microsoft.copilot.eclipse.core.persistence.AbstractTurnData; +import com.microsoft.copilot.eclipse.core.persistence.ConversationDataFactory; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.AgentMessageData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.EditAgentRoundData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ErrorData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ErrorMessageData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ReplyData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ToolCallData; +import com.microsoft.copilot.eclipse.core.persistence.UserTurnData; +import com.microsoft.copilot.eclipse.ui.UiConstants; +import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; +import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; + +/** + * {@link IConversationWidget} implementation backed by the existing SWT {@link ChatContentViewer} + * using {@link StyledText} to render the content. + * This is a thin adapter that delegates all calls to the underlying viewer. + */ +public class StyledTextConversationWidget implements IConversationWidget { + + private final ChatContentViewer viewer; + + /** + * Creates a new {@link StyledTextConversationWidget} that internally creates a {@link ChatContentViewer}. + * + * @param parent the parent composite + * @param serviceManager the chat service manager + */ + public StyledTextConversationWidget(Composite parent, ChatServiceManager serviceManager) { + this.viewer = new ChatContentViewer(parent, SWT.NONE, serviceManager); + } + + /** + * Returns the underlying {@link ChatContentViewer} for SWT-specific operations that are not part + * of the {@link IConversationWidget} interface (e.g., {@code getTurnWidget()}). + */ + public ChatContentViewer getChatContentViewer() { + return viewer; + } + + @Override + public Control getControl() { + return viewer; + } + + @Override + public boolean isDisposed() { + return viewer.isDisposed(); + } + + @Override + public void dispose() { + viewer.dispose(); + } + + @Override + public void requestLayout() { + viewer.requestLayout(); + } + + @Override + public void setConversationId(String conversationId) { + viewer.setConversationId(conversationId); + } + + @Override + public void beginTurn(String turnId, boolean isCopilot, boolean isHistory) { + viewer.getLatestOrCreateNewTurnWidget(turnId, isCopilot, isHistory); + } + + @Override + public void processTurnEvent(ChatProgressValue value) { + viewer.processTurnEvent(value); + } + + @Override + public void startNewUserTurn(String turnId, String message) { + viewer.startNewTurn(turnId, message); + } + + @Override + public void scrollToBottom() { + viewer.getDisplay().asyncExec(() -> { + if (viewer.isDisposed()) { + return; + } + viewer.refreshLayoutFull(); + viewer.scrollToBottomIfAutoScroll(); + }); + } + + @Override + public void refreshScrollerLayout() { + viewer.refreshLayoutFull(); + } + + @Override + public void renderErrorMessage(String content) { + viewer.renderErrorMessage(content); + } + + @Override + public void showCompactingStatusOnLatestCopilotTurn() { + viewer.showCompactingStatusOnLatestCopilotTurn(); + } + + @Override + public void hideCompactingStatusOnLatestCopilotTurn() { + viewer.hideCompactingStatusOnLatestCopilotTurn(); + } + + @Override + public String getActiveThinkingBlockId(String turnId) { + return viewer.getActiveThinkingBlockId(turnId); + } + + @Override + public void restoreTurn(AbstractTurnData turn, ConversationDataFactory dataFactory) { + if (turn == null) { + return; + } + + // Subagent turns: render inside parent turn's subagent block + if (turn instanceof CopilotTurnData copilotTurn + && StringUtils.isNotBlank(copilotTurn.getParentTurnId())) { + BaseTurnWidget parentWidget = viewer.getTurnWidget(copilotTurn.getParentTurnId()); + if (parentWidget != null) { + String toolCallId = copilotTurn.getSubagentToolCallId(); + if (StringUtils.isNotBlank(toolCallId)) { + parentWidget.restoreSubagentContent(toolCallId, copilotTurn, dataFactory); + } else { + restoreCopilotTurnContent(copilotTurn, parentWidget, dataFactory); + } + } + return; + } + + // User turn + if (turn instanceof UserTurnData userTurn) { + if (userTurn.getMessage() == null + || StringUtils.isNotBlank(userTurn.getMessage().getText())) { + BaseTurnWidget userTurnWidget = + viewer.getLatestOrCreateNewTurnWidget(turn.getTurnId(), false, true); + userTurnWidget.appendMessage(userTurn.getMessage().getText()); + userTurnWidget.flushMessageBuffer(); + } + return; + } + + // Copilot turn + if (turn instanceof CopilotTurnData copilotTurn) { + BaseTurnWidget copilotTurnWidget = + viewer.getLatestOrCreateNewTurnWidget(turn.getTurnId(), true, true); + restoreCopilotTurnContent(copilotTurn, copilotTurnWidget, dataFactory); + copilotTurnWidget.flushMessageBuffer(); + + // Restore model info footer + ReplyData replyData = copilotTurn.getReply(); + if (replyData != null && StringUtils.isNotBlank(replyData.getModelName())) { + renderModelInfo(turn.getTurnId(), replyData.getModelName(), + replyData.getBillingMultiplier(), replyData.getReasoningEffort()); + } + } + } + + @Override + public void renderModelInfo(String turnId, String modelName, double billingMultiplier, + String reasoningEffort) { + if (viewer.isDisposed()) { + return; + } + BaseTurnWidget turnWidget = viewer.getTurnWidget(turnId); + if (turnWidget instanceof CopilotTurnWidget copilotWidget) { + copilotWidget.renderModelInfo(modelName, billingMultiplier, reasoningEffort); + SwtUtils.invokeOnDisplayThreadAsync(viewer::refreshLayoutFull, viewer); + } + } + + @Override + public void renderAgentMessage(CodingAgentMessageRequestParams params) { + if (viewer.isDisposed() || params == null) { + return; + } + SwtUtils.invokeOnDisplayThread(() -> { + BaseTurnWidget turnWidget = viewer.getTurnWidget(params.getTurnId()); + if (turnWidget != null && !turnWidget.isDisposed()) { + turnWidget.createAgentMessageWidget(params); + } + }, viewer); + } + + @Override + public CompletableFuture requestToolConfirmation( + String turnId, ConfirmationContent content, Object input) { + BaseTurnWidget turnWidget = viewer.getTurnWidget(turnId); + if (turnWidget == null) { + return CompletableFuture.completedFuture( + new LanguageModelToolConfirmationResult(ToolConfirmationResult.DISMISS)); + } + BaseTurnWidget activeTurnWidget = turnWidget.getActiveTurnWidget(); + CompletableFuture future = + activeTurnWidget.requestToolExecutionConfirmation(content, input); + viewer.refreshLayoutFull(); + return future; + } + + @Override + public void cancelToolConfirmation(String turnId) { + BaseTurnWidget turnWidget = viewer.getTurnWidget(turnId); + if (turnWidget != null) { + turnWidget.getActiveTurnWidget().cancelToolConfirmation(); + } + } + + @Override + public ConfirmationAction getLastSelectedConfirmationAction() { + // In the SWT implementation, the selected action is retrieved from the dialog directly + // by AgentToolService. Return null here; SWT path uses dialog.getSelectedAction(). + return null; + } + + private void restoreCopilotTurnContent(CopilotTurnData copilotTurn, BaseTurnWidget turnWidget, + ConversationDataFactory dataFactory) { + ReplyData replyData = copilotTurn.getReply(); + if (replyData == null) { + return; + } + + ThinkingTurnWidget thinkingWidget = + turnWidget instanceof ThinkingTurnWidget ? (ThinkingTurnWidget) turnWidget : null; + + if (StringUtils.isNotBlank(replyData.getText())) { + turnWidget.appendMessage(replyData.getText()); + } + + if (replyData.getEditAgentRounds() != null && !replyData.getEditAgentRounds().isEmpty()) { + for (EditAgentRoundData round : replyData.getEditAgentRounds()) { + if (thinkingWidget != null && round.getThinkingBlock() != null) { + thinkingWidget.restoreThinkingBlock(round.getThinkingBlock()); + } + if (round.getReply() != null && !round.getReply().isEmpty()) { + turnWidget.appendMessage(round.getReply()); + } + if (round.getToolCalls() != null && !round.getToolCalls().isEmpty()) { + for (ToolCallData toolCallData : round.getToolCalls()) { + AgentToolCall agentToolCall = + dataFactory.convertToolCallDataToAgentToolCall(toolCallData); + turnWidget.appendToolCallStatus(agentToolCall); + } + } + } + } + + // Flush buffered text before creating error/agent widgets so that reply text + // always appears above them in the layout. + turnWidget.flushMessageBuffer(); + + if (replyData.getErrorMessages() != null && !replyData.getErrorMessages().isEmpty()) { + for (ErrorMessageData errorMessageData : replyData.getErrorMessages()) { + ErrorData errorData = errorMessageData.getError(); + SwtUtils.invokeOnDisplayThread(() -> { + String errorMessage = errorData != null + ? errorData.getMessage() : Messages.chat_warnWidget_defaultErrorMsg; + int errorCode = errorData != null ? errorData.getCode() : 0; + String modelProviderName = errorData != null ? errorData.getModelProviderName() : null; + turnWidget.createWarnDialog(errorMessage, errorCode, modelProviderName); + }, viewer); + } + } + + if (replyData.getAgentMessages() != null && !replyData.getAgentMessages().isEmpty()) { + for (AgentMessageData agentMessageData : replyData.getAgentMessages()) { + if (StringUtils.equals(agentMessageData.getAgentSlug(), + UiConstants.GITHUB_COPILOT_CODING_AGENT_SLUG)) { + SwtUtils.invokeOnDisplayThread(() -> { + CodingAgentMessageRequestParams params = new CodingAgentMessageRequestParams(); + params.setTitle(agentMessageData.getTitle()); + params.setDescription(agentMessageData.getDescription()); + params.setPrLink(agentMessageData.getPrLink()); + params.setTurnId(copilotTurn.getTurnId()); + turnWidget.createAgentMessageWidget(params); + }, viewer); + } + } + } + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AgentToolService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AgentToolService.java index c7a013c2..7bb87eaa 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AgentToolService.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/AgentToolService.java @@ -36,10 +36,8 @@ import com.microsoft.copilot.eclipse.terminal.api.IRunInTerminalTool; import com.microsoft.copilot.eclipse.terminal.api.TerminalServiceManager; import com.microsoft.copilot.eclipse.ui.CopilotUi; -import com.microsoft.copilot.eclipse.ui.chat.BaseTurnWidget; -import com.microsoft.copilot.eclipse.ui.chat.ChatContentViewer; import com.microsoft.copilot.eclipse.ui.chat.ChatView; -import com.microsoft.copilot.eclipse.ui.chat.InvokeToolConfirmationDialog; +import com.microsoft.copilot.eclipse.ui.chat.IConversationWidget; import com.microsoft.copilot.eclipse.ui.chat.confirmation.AttachedFileRegistry; import com.microsoft.copilot.eclipse.ui.chat.confirmation.ConfirmationService; import com.microsoft.copilot.eclipse.ui.chat.tools.BaseTool; @@ -279,36 +277,28 @@ public CompletableFuture onToolConfirmation new LanguageModelToolConfirmationResult(ToolConfirmationResult.DISMISS)); } - BaseTurnWidget turnWidget = boundChatView.getChatContentViewer().getTurnWidget(params.getTurnId()); - if (turnWidget == null) { - LanguageModelToolConfirmationResult result = new LanguageModelToolConfirmationResult( - ToolConfirmationResult.DISMISS); - return CompletableFuture.completedFuture(result); + IConversationWidget widget = boundChatView.getConversationWidget(); + if (widget == null || widget.isDisposed()) { + return CompletableFuture.completedFuture( + new LanguageModelToolConfirmationResult(ToolConfirmationResult.DISMISS)); } - // Get the active turn widget (may be a subagent widget if in subagent context) - BaseTurnWidget activeTurnWidget = turnWidget.getActiveTurnWidget(); - - AtomicReference> ref = new AtomicReference<>(); ConfirmationContent content = autoApproveResult.getContent(); + AtomicReference> ref = + new AtomicReference<>(); SwtUtils.invokeOnDisplayThread(() -> { - ref.set(activeTurnWidget.requestToolExecutionConfirmation( - content, params.getInput())); - boundChatView.getChatContentViewer().refreshLayoutFull(); + ref.set(widget.requestToolConfirmation( + params.getTurnId(), content, params.getInput())); }); CompletableFuture future = ref.get(); if (future != null && content != null) { - // Capture dialog reference before it can be reset by a new request - final InvokeToolConfirmationDialog dialog = - activeTurnWidget.getConfirmDialog(); + final IConversationWidget widgetRef = widget; final String sessConvId = sessionConversationId; future = future.thenApply(result -> { - ConfirmationAction selected = dialog != null - ? dialog.getSelectedAction() : null; + ConfirmationAction selected = widgetRef.getLastSelectedConfirmationAction(); if (selected != null && selected.isAccept()) { - confirmationService.cacheDecision(selected, params, - sessConvId); + confirmationService.cacheDecision(selected, params, sessConvId); } return result; }); @@ -321,19 +311,9 @@ private boolean validToolConfirmInvokeParams(String conversationId, String turnI return false; } - // Check if the conversation ID matches either the main conversation ID or the subagent conversation ID - boolean conversationIdMatches = Objects.equals(conversationId, boundChatView.getConversationId()) + // Check if the conversation ID matches either the main or subagent conversation + return Objects.equals(conversationId, boundChatView.getConversationId()) || Objects.equals(conversationId, boundChatView.getSubagentConversationId()); - - if (!conversationIdMatches) { - return false; - } - - ChatContentViewer chatContentViewer = boundChatView.getChatContentViewer(); - if (chatContentViewer == null || chatContentViewer.getTurnWidget(turnId) == null) { - return false; - } - return true; } /** diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/UserPreferenceService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/UserPreferenceService.java index 0ca4b969..6a9a930c 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/UserPreferenceService.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/UserPreferenceService.java @@ -503,10 +503,12 @@ public void bindChatView(ChatView chatView) { * Unbind the currently bound chat view if any. */ public void unbindChatView() { - if (chatViewSideEffect != null) { - chatViewSideEffect.dispose(); - chatViewSideEffect = null; - } + ensureRealm(() -> { + if (chatViewSideEffect != null) { + chatViewSideEffect.dispose(); + chatViewSideEffect = null; + } + }); } /** diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/ChatPreferencesPage.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/ChatPreferencesPage.java index 11a791ef..dcc3dcf7 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/ChatPreferencesPage.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/ChatPreferencesPage.java @@ -51,6 +51,17 @@ public void createFieldEditors() { GridDataFactory gdf = GridDataFactory.fillDefaults().span(2, 1).align(SWT.FILL, SWT.FILL).grab(true, false); + // checkbox: choose the conversation renderer (browser-based vs. StyledText-based). + Composite appearanceComposite = createSectionComposite(parent, gdf); + BooleanFieldEditor browserRendererField = new BooleanFieldEditor(Constants.USE_BROWSER_BASED_CHAT_RENDERER, + Messages.preferences_page_browser_renderer, SWT.WRAP, appearanceComposite); + applyFieldWidthHint(browserRendererField, appearanceComposite); + browserRendererField.getDescriptionControl(appearanceComposite) + .setToolTipText(Messages.preferences_page_browser_renderer_tooltip); + addField(browserRendererField); + + addSeparator(parent); + Composite workspaceContextComposite = createSectionComposite(parent, gdf); BooleanFieldEditor workspaceContextField = new BooleanFieldEditor(Constants.WORKSPACE_CONTEXT_ENABLED, Messages.preferences_page_watched_files, SWT.WRAP, workspaceContextComposite); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializer.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializer.java index 6b43717c..3ea6edba 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializer.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/CopilotPreferenceInitializer.java @@ -39,6 +39,7 @@ public void initializeDefaultPreferences() { pref.setDefault(Constants.CUSTOM_INSTRUCTIONS_CHAT_LOAD_SCOPE, CustomInstructionsChatLoadScope.DEFAULT_VALUE.getValue()); pref.setDefault(Constants.AUTO_BREAKPOINT_RESPONSE, false); + pref.setDefault(Constants.USE_BROWSER_BASED_CHAT_RENDERER, false); pref.setDefault(Constants.MCP, """ { "servers": { diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java index 7582fd7a..3fa03091 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/Messages.java @@ -60,6 +60,8 @@ public class Messages extends NLS { public static String preferences_page_restart_question; public static String preferences_page_sub_agent; public static String preferences_page_sub_agent_note_content; + public static String preferences_page_browser_renderer; + public static String preferences_page_browser_renderer_tooltip; public static String preferences_page_mcp; public static String preferences_page_proxy_config_link; public static String preferences_page_proxy_settings; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties index f041db59..734170fe 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/preferences/messages.properties @@ -99,6 +99,8 @@ preferences_page_watched_files_note_content= Allow the use of @workspace in Ask preferences_page_restart_question=You need to restart Eclipse to apply the changes. Would you like to restart now? preferences_page_sub_agent= Enable sub-agent preferences_page_sub_agent_note_content= Allow Copilot to use sub-agents for complex multi-step tasks. +preferences_page_browser_renderer= Activate browser-based conversation rendering +preferences_page_browser_renderer_tooltip=Either render conversations in a web browser with GFM tables support (and more) or use the seasoned StyledText-based SWT rendering preferences_page_restart_required= Restart Required # CustomModesPreferencePage customModes_page_description=Configure custom agents stored as .agent.md files in .github/agents directory. diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/UiUtils.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/UiUtils.java index 5d1ab2a9..32675ccc 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/UiUtils.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/UiUtils.java @@ -35,7 +35,7 @@ import org.eclipse.core.filesystem.IFileStore; import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IResource; -import org.eclipse.core.runtime.preferences.InstanceScope; +import org.eclipse.core.runtime.Platform; import org.eclipse.e4.ui.model.application.ui.basic.MPart; import org.eclipse.e4.ui.services.IStylingEngine; import org.eclipse.e4.ui.workbench.modeling.EPartService; @@ -88,7 +88,7 @@ import org.eclipse.ui.part.IShowInTarget; import org.eclipse.ui.part.ShowInContext; import org.eclipse.ui.texteditor.ITextEditor; -import org.osgi.service.prefs.Preferences; +import org.osgi.framework.Bundle; import com.microsoft.copilot.eclipse.core.CopilotCore; import com.microsoft.copilot.eclipse.core.utils.PlatformUtils; @@ -502,15 +502,74 @@ public static void applyChatFont(Control control) { /** * Returns true if Eclipse is currently using a dark theme. * + *

The active e4 CSS theme is queried reflectively so that this bundle needs no compile-time + * dependency on the friend-restricted {@code org.eclipse.e4.ui.css.swt.theme} package (which + * would otherwise raise a discouraged-access warning and force a hard bundle dependency). When + * e4 CSS theming is unavailable — for example in a minimal RCP application without the theming + * bundle — the method falls back to the persisted {@code themeid} preference and finally defaults + * to light. + * * @return true if dark theme is active, false otherwise */ public static boolean isDarkTheme() { - Preferences preferences = InstanceScope.INSTANCE.getNode("org.eclipse.e4.ui.css.swt.theme"); - String themeCssUri = preferences.get("themeid", ""); - if (themeCssUri.toLowerCase().contains("dark")) { - return true; + Boolean activeThemeDark = activeThemeIsDark(); + if (activeThemeDark != null) { + return activeThemeDark; } - return false; + return persistedThemeIsDark(); + } + + /** + * Reflectively resolves whether the active e4 CSS theme is a dark theme. + * + *

The theme bundle's own class loader is used to load the provisional, friend-restricted + * {@code IThemeEngine}/{@code ITheme} types, so this bundle keeps no compile-time reference to + * them and the theming dependency can stay optional. Any failure (theming bundle absent, service + * unavailable, or a linkage/reflection error) is treated as "cannot determine". + * + * @return {@code Boolean.TRUE} or {@code Boolean.FALSE} when the active theme could be resolved, + * or {@code null} when e4 CSS theming is unavailable so the caller can fall back + */ + private static Boolean activeThemeIsDark() { + Bundle themeBundle = Platform.getBundle("org.eclipse.e4.ui.css.swt.theme"); + if (themeBundle == null) { + return null; + } + try { + Class themeEngineClass = themeBundle.loadClass("org.eclipse.e4.ui.css.swt.theme.IThemeEngine"); + Object themeEngine = PlatformUI.getWorkbench().getService(themeEngineClass); + if (themeEngine == null) { + return null; + } + Object activeTheme = themeEngineClass.getMethod("getActiveTheme").invoke(themeEngine); + if (activeTheme == null) { + return null; + } + Class themeClass = themeBundle.loadClass("org.eclipse.e4.ui.css.swt.theme.ITheme"); + Object themeId = themeClass.getMethod("getId").invoke(activeTheme); + if (themeId == null) { + return null; + } + return themeId.toString().toLowerCase().contains("dark"); + } catch (ReflectiveOperationException | RuntimeException | LinkageError e) { + return null; + } + } + + /** + * Fallback theme detection based on the persisted e4 CSS {@code themeid} preference. + * + *

This is less accurate than the active-theme query (the persisted value can lag the runtime + * theme, e.g. when following the OS theme), but it needs no theming bundle at all and never + * throws, so it is a safe last resort. + * + * @return true if the persisted theme id denotes a dark theme, false otherwise (including light + * and unknown) + */ + private static boolean persistedThemeIsDark() { + String themeId = Platform.getPreferencesService() + .getString("org.eclipse.e4.ui.css.swt.theme", "themeid", "", null); + return themeId.toLowerCase().contains("dark"); } /** diff --git a/launch/Build and test Copilot for Eclipse.launch b/launch/Build and test Copilot for Eclipse.launch new file mode 100644 index 00000000..837045cf --- /dev/null +++ b/launch/Build and test Copilot for Eclipse.launch @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/launch/Run Checkstyle in Copilot for Eclipse.launch b/launch/Run Checkstyle in Copilot for Eclipse.launch new file mode 100644 index 00000000..30546b16 --- /dev/null +++ b/launch/Run Checkstyle in Copilot for Eclipse.launch @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + +