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.test/src/com/microsoft/copilot/eclipse/core/utils/BundleUtilsTests.java b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/utils/BundleUtilsTests.java new file mode 100644 index 00000000..c5601b35 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/utils/BundleUtilsTests.java @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.utils; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Base64; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.osgi.framework.Bundle; + +class BundleUtilsTests { + + private static final String PATH = "resources/sample.dat"; + + private static URL writeTempFile(Path dir, String name, byte[] content) throws Exception { + Path file = dir.resolve(name); + Files.write(file, content); + return file.toUri().toURL(); + } + + private static Bundle mockBundle(String path, URL entry) { + Bundle bundle = mock(Bundle.class); + when(bundle.getEntry(path)).thenReturn(entry); + return bundle; + } + + @Test + void readResourceAsString_returnsUtf8Content(@TempDir Path dir) throws Exception { + String content = "Hello, \u00e4\u00f6\u00fc \u2013 SVG "; + URL url = writeTempFile(dir, "sample.dat", content.getBytes(StandardCharsets.UTF_8)); + Bundle bundle = mockBundle(PATH, url); + + assertEquals(content, BundleUtils.readResourceAsString(bundle, PATH)); + } + + @Test + void readResourceAsBytes_returnsRawBytes(@TempDir Path dir) throws Exception { + byte[] content = {0, 1, 2, 3, (byte) 0xFF, 10, 20}; + URL url = writeTempFile(dir, "sample.dat", content); + Bundle bundle = mockBundle(PATH, url); + + assertArrayEquals(content, BundleUtils.readResourceAsBytes(bundle, PATH)); + } + + @Test + void readResourceAsDataUri_encodesBase64WithMimeType(@TempDir Path dir) throws Exception { + byte[] content = {10, 20, 30, 40}; + URL url = writeTempFile(dir, "sample.dat", content); + Bundle bundle = mockBundle(PATH, url); + + String dataUri = BundleUtils.readResourceAsDataUri(bundle, PATH, "image/png"); + + String prefix = "data:image/png;base64,"; + assertTrue(dataUri.startsWith(prefix), "unexpected data URI: " + dataUri); + byte[] decoded = Base64.getDecoder().decode(dataUri.substring(prefix.length())); + assertArrayEquals(content, decoded); + } + + @Test + void readResourceAsString_missingEntry_returnsNull() { + Bundle bundle = mockBundle(PATH, null); + assertNull(BundleUtils.readResourceAsString(bundle, PATH)); + } + + @Test + void readResourceAsBytes_missingEntry_returnsNull() { + Bundle bundle = mockBundle(PATH, null); + assertNull(BundleUtils.readResourceAsBytes(bundle, PATH)); + } + + @Test + void readResourceAsDataUri_missingEntry_returnsEmptyString() { + Bundle bundle = mockBundle(PATH, null); + assertEquals("", BundleUtils.readResourceAsDataUri(bundle, PATH, "image/png")); + } + + @Test + void readResourceAsBytes_nullBundle_returnsNull() { + assertNull(BundleUtils.readResourceAsBytes(null, PATH)); + } + + @Test + void readResourceAsBytes_nullPath_returnsNull() { + assertNull(BundleUtils.readResourceAsBytes(mock(Bundle.class), null)); + } +} 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.core/src/com/microsoft/copilot/eclipse/core/utils/BundleUtils.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/BundleUtils.java new file mode 100644 index 00000000..ad2d5bd9 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/BundleUtils.java @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.utils; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import org.osgi.framework.Bundle; + +import com.microsoft.copilot.eclipse.core.CopilotCore; + +/** + * Utility methods for loading resource files (text, binary, images such as PNG or SVG) + * bundled inside an OSGi {@link Bundle}. + * + *

All methods degrade gracefully: if the resource cannot be located or read they log the + * failure and return {@code null} (or an empty string for the data-URI helper) instead of + * throwing, so that a missing icon never breaks the surrounding feature. + */ +public final class BundleUtils { + + private BundleUtils() { + } + + /** + * Reads a bundle resource as a UTF-8 string. Intended for text resources such as SVG icons. + * + * @param bundle the bundle containing the resource + * @param path the bundle-relative resource path (e.g. {@code "resources/html/icons/x.svg"}) + * @return the file content as a UTF-8 string, or {@code null} if it cannot be read + */ + public static String readResourceAsString(Bundle bundle, String path) { + byte[] bytes = readResourceAsBytes(bundle, path); + return bytes == null ? null : new String(bytes, StandardCharsets.UTF_8); + } + + /** + * Reads a bundle resource as a byte array. Intended for binary resources such as PNG icons. + * + * @param bundle the bundle containing the resource + * @param path the bundle-relative resource path + * @return the file content as bytes, or {@code null} if it cannot be read + */ + public static byte[] readResourceAsBytes(Bundle bundle, String path) { + if (bundle == null || path == null) { + return null; + } + URL entry = bundle.getEntry(path); + if (entry == null) { + CopilotCore.LOGGER.error("Bundle resource not found: " + path, + new FileNotFoundException(path)); + return null; + } + try (InputStream is = entry.openStream()) { + return is.readAllBytes(); + } catch (IOException e) { + CopilotCore.LOGGER.error("Failed to read bundle resource: " + path, e); + return null; + } + } + + /** + * Reads a bundle resource and encodes it as a {@code data:} URI with the given MIME type, + * for embedding a binary resource (e.g. a PNG icon) directly into HTML. + * + * @param bundle the bundle containing the resource + * @param path the bundle-relative resource path + * @param mimeType the MIME type to use in the data URI (e.g. {@code "image/png"}) + * @return the {@code data:} URI string, or an empty string if the resource cannot be read + */ + public static String readResourceAsDataUri(Bundle bundle, String path, String mimeType) { + byte[] bytes = readResourceAsBytes(bundle, path); + if (bytes == null) { + return ""; + } + return "data:" + mimeType + ";base64," + Base64.getEncoder().encodeToString(bytes); + } +} 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/BrowserConversationJavaJsBridgeTests.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationJavaJsBridgeTests.java new file mode 100644 index 00000000..83530fdc --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationJavaJsBridgeTests.java @@ -0,0 +1,107 @@ +// 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 the static, side-effect-free members of + * {@link BrowserConversationJavaJsBridge}: {@code escapeForJs} and the block-script builders. No + * SWT {@code Display} or {@code Browser} is required. These lock the exact JavaScript emitted so + * the "byte-identical output" contract of the Java↔JS boundary cannot regress unnoticed. + */ +class BrowserConversationJavaJsBridgeTests { + + @Test + void escapeForJsReturnsEmptyForNull() { + assertEquals("", BrowserConversationJavaJsBridge.escapeForJs(null)); + } + + @ParameterizedTest(name = "escapeForJs({0}) = {1}") + @MethodSource("escapeForJsCases") + void escapeForJsEscapesCharactersCorrectly(String input, String expected) { + assertEquals(expected, BrowserConversationJavaJsBridge.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\\'") + ); + } + + @ParameterizedTest(name = "insertBlockScript({0}, {1}) = {2}") + @MethodSource("insertBlockScriptCases") + void insertBlockScriptEmitsExactJs(String parentId, String html, String expected) { + assertEquals(expected, BrowserConversationJavaJsBridge.insertBlockScript(parentId, html)); + } + + static Stream insertBlockScriptCases() { + return Stream.of( + Arguments.of("chat-container", "

hi
", + "window.insertBlock('chat-container', '
hi
')"), + Arguments.of("turn-1", "it's bold", + "window.insertBlock('turn-1', 'it\\'s bold')"), + Arguments.of("a\\b", "line1\nline2", + "window.insertBlock('a\\\\b', 'line1\\nline2')") + ); + } + + @ParameterizedTest(name = "insertBlockBeforeScript({0}, {1}, {2}) = {3}") + @MethodSource("insertBlockBeforeScriptCases") + void insertBlockBeforeScriptEmitsExactJs(String parentId, String html, String beforeId, + String expected) { + assertEquals(expected, + BrowserConversationJavaJsBridge.insertBlockBeforeScript(parentId, html, beforeId)); + } + + static Stream insertBlockBeforeScriptCases() { + return Stream.of( + Arguments.of("turn-1-copilot", "
card
", "turn-1-model-info", + "window.insertBlockBefore('turn-1-copilot', '
card
', 'turn-1-model-info')"), + Arguments.of("p'1", "h'2", "b'3", + "window.insertBlockBefore('p\\'1', 'h\\'2', 'b\\'3')") + ); + } + + @ParameterizedTest(name = "replaceBlockScript({0}, {1}) = {2}") + @MethodSource("replaceBlockScriptCases") + void replaceBlockScriptEmitsExactJs(String blockId, String html, String expected) { + assertEquals(expected, BrowserConversationJavaJsBridge.replaceBlockScript(blockId, html)); + } + + static Stream replaceBlockScriptCases() { + return Stream.of( + Arguments.of("turn-1-0", "

updated

", + "window.replaceBlock('turn-1-0', '

updated

')"), + Arguments.of("id'x", "a\tb", + "window.replaceBlock('id\\'x', 'a\\tb')") + ); + } + + @ParameterizedTest(name = "removeBlockScript({0}) = {1}") + @MethodSource("removeBlockScriptCases") + void removeBlockScriptEmitsExactJs(String blockId, String expected) { + assertEquals(expected, BrowserConversationJavaJsBridge.removeBlockScript(blockId)); + } + + static Stream removeBlockScriptCases() { + return Stream.of( + Arguments.of("turn-1-confirm", "window.removeBlock('turn-1-confirm')"), + Arguments.of("id'x", "window.removeBlock('id\\'x')") + ); + } +} 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..eabcc5bf --- /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, null); + }); + } + + @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/BrowserConversationWidgetTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidgetTest.java new file mode 100644 index 00000000..afbbedc7 --- /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, null); + }); + } + + @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..611e027d --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ConversationHtmlBlockFactoryTest.java @@ -0,0 +1,845 @@ +// 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.ConfirmationActionScope; +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"); + } + + @Test + void rendersPlainPrimaryButton_whenSingleAcceptAction() { + ConfirmationContent content = new ConfirmationContent( + "Run in terminal?", null, + List.of( + ConfirmationAction.allowOnce("Allow"), + ConfirmationAction.skip("Deny"))); + + String html = factory.createConfirmationHtmlBlock("confirm-4", content, null); + + assertTrue(html.contains("btn-confirm btn-primary"), + "Single accept action should be a plain primary button"); + assertFalse(html.contains("split-button"), + "No split-button wrapper without alternative accept actions"); + assertFalse(html.contains("dropdown-menu"), + "No dropdown without alternative accept actions"); + assertTrue(html.contains("window.acceptToolAction(0)"), + "Primary button should accept action index 0"); + assertTrue(html.contains("window.dismissToolAction()"), + "Deny button should call dismissToolAction"); + } + + @Test + void rendersSplitButtonDropdown_whenMultipleAcceptActions() { + ConfirmationContent content = new ConfirmationContent( + "Run in terminal?", null, + List.of( + ConfirmationAction.allowOnce("Allow once"), + new ConfirmationAction("Allow for session", true, + ConfirmationActionScope.SESSION, null, false), + new ConfirmationAction("Always allow", true, + ConfirmationActionScope.GLOBAL, null, false), + ConfirmationAction.skip("Deny"))); + + String html = factory.createConfirmationHtmlBlock("confirm-5", content, null); + + assertTrue(html.contains("class=\"split-button\""), + "Multiple accept actions should produce a split-button"); + assertTrue(html.contains("split-primary"), "Primary sub-button should be present"); + assertTrue(html.contains("data-dropdown-toggle"), "Caret toggle should be present"); + assertTrue(html.contains("class=\"dropdown-menu\" hidden"), + "Dropdown menu should start hidden"); + // Primary (index 0) is the button; alternatives (indexes 1 and 2) are dropdown items. + assertTrue(html.contains("window.acceptToolAction(0)"), "Primary uses index 0"); + assertTrue(html.contains("window.acceptToolAction(1)"), "First alternative uses index 1"); + assertTrue(html.contains("window.acceptToolAction(2)"), "Second alternative uses index 2"); + assertTrue(html.contains("Allow for session") && html.contains("Always allow"), + "Dropdown should list the alternative accept actions"); + assertTrue(html.contains("window.dismissToolAction()"), + "Dismiss stays a separate button"); + } + } + + @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"); + } + + @Test + void activeThinkingBlock_rendersMarkdownBold() { + String html = factory.createThinkingHtmlBlock("think-7", "This is **important** text"); + assertTrue(html.contains("important"), + "Bold Markdown in a streaming thinking body should render as "); + } + + @Test + void sealedThinkingBlock_rendersMarkdownInBodyAndTitle() { + String html = factory.createSealedThinkingHtmlBlock( + "think-8", "Considered **options** and **trade-offs**", "**Bold** title"); + + assertTrue(html.contains("options"), + "Bold Markdown in the thinking body should render as "); + assertTrue(html.contains("Bold title"), + "Bold Markdown in the thinking title should render inline as "); + } + + @Test + void renderMarkdownInline_stripsWrappingParagraph() { + String html = factory.renderMarkdownInline("**Bold** title"); + assertEquals("Bold title", html, + "Inline render should strip the wrapping

commonmark emits for a lone paragraph"); + } + + @Test + void renderMarkdownInline_returnsEmpty_forNullOrBlank() { + assertEquals("", factory.renderMarkdownInline(null)); + assertEquals("", factory.renderMarkdownInline("")); + } + } + + @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..e0b5f116 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ConversationRendererVisualDemoSideBySide.java @@ -0,0 +1,606 @@ +// 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.osgi.util.NLS; +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.ConfirmationActionScope; +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.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.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)); + + ChatServiceManager demoServiceManager = createDemoChatServiceManager(); + + // --- Left: Browser-based renderer --- + Composite leftPane = createLabeledPane(sash, + "BrowserConversationWidget (HTML/CSS)"); + BrowserConversationWidget browserWidget = + new BrowserConversationWidget(leftPane, demoServiceManager); + + // --- Right: StyledText-based renderer --- + Composite rightPane = createLabeledPane(sash, + "StyledTextConversationWidget (SWT StyledText)"); + StyledTextConversationWidget[] styledTextHolder = { + new StyledTextConversationWidget(rightPane, demoServiceManager)}; + + sash.setWeights(50, 50); + + // Theme toggle listeners — recreate StyledText widget to pick up new theme colors + darkButton.addListener(SWT.Selection, e -> { + Browser browser = (Browser) browserWidget.getControl(); + browser.execute("window.setTheme('dark')"); + switchEclipseTheme("org.eclipse.e4.ui.css.theme.e4_dark"); + styledTextHolder[0] = recreateStyledTextWidget( + rightPane, demoServiceManager, styledTextHolder[0]); + }); + lightButton.addListener(SWT.Selection, e -> { + Browser browser = (Browser) browserWidget.getControl(); + browser.execute("window.setTheme('light')"); + switchEclipseTheme("org.eclipse.e4.ui.css.theme.e4_default"); + styledTextHolder[0] = recreateStyledTextWidget( + rightPane, demoServiceManager, styledTextHolder[0]); + }); + + // Feed identical content to both widgets after the browser page has loaded. + // BrowserConversationWidget needs its page loaded before insertHtmlBlockChild works, + // so we schedule the content injection asynchronously. + display.asyncExec(() -> feedDemoContent(browserWidget, styledTextHolder[0])); + + shell.open(); + } + + // --- Demo content constants (shared between both rendering paths) --- + + private static final String USER_MSG_1 = + "How do I create a **GFM table** in Markdown? Also show me a code example."; + + private static final String COPILOT_REPLY_TABLE = """ + Here's a GFM table example: + + | Feature | StyledText | Browser | + |----------------|:----------:|:-------:| + | Tables | \u274c | \u2705 | + | Code blocks | \u2705 | \u2705 | + | Task lists | \u274c | \u2705 | + | Inline code | \u2705 | \u2705 | + | Bold/Italic | \u2705 | \u2705 | + + And here's a Java code example: + + ```java + public class HelloWorld { + public static void main(String[] args) { + System.out.println("Hello, world!"); + if (args.length > 0) { + System.out.println("Args: " + String.join(", ", args)); + } + } + } + ``` + + You can also use **inline code** like `System.out.println()` in your text. + + Here's a task list: + + - [x] Create table structure + - [x] Add code block + - [ ] Test rendering + - [ ] Submit PR"""; + + private static final String THINKING_CONTENT = + "The user wants to know about GFM tables and code examples. " + + "I'll provide a table example and a fenced code block with Java."; + + private static final String THINKING_TITLE = "Planning response"; + + private static final String USER_MSG_2 = + "Thanks! Can you also show me how bold and italic text render?"; + + private static final String COPILOT_REPLY_FORMATTING = """ + Sure! Here are some formatting examples: + + - **Bold text** is wrapped in double asterisks + - *Italic text* uses single asterisks + - ***Bold and italic*** uses triple asterisks + - ~~Strikethrough~~ uses tildes + + > This is a blockquote that can span + > multiple lines. + + And a numbered list: + + 1. First item + 2. Second item + 3. Third item"""; + + private static final String ERROR_MSG = "Let me try a different approach..."; + private static final String ERROR_DETAIL = + "Context window exceeded. The conversation is too long."; + + private static final String USER_MSG_FIX_TEST = + "The login test is failing. Investigate it with a subagent and open a PR with the fix."; + private static final String USER_MSG_BUILD = + "Great. Now run the build to make sure everything still compiles."; + private static final String USER_MSG_ERROR = + "Summarize this entire 500-page design document in a single response."; + private static final String USER_MSG_QUOTA = + "Generate the full implementation using the most capable premium model."; + private static final String USER_MSG_INVESTIGATE = + "Before you make changes, investigate the current setup and run whatever tools you need."; + private static final String USER_MSG_LIVE = + "Walk me through the migration approach step by step."; + + private static final String SUBAGENT_THINKING_CONTENT = + "The failing test points at the login flow. I'll delegate a focused investigation to a " + + "subagent, then summarize the finding and open a pull request."; + private static final String BUILD_REPLY_TEXT = + "I'll run the build now. This needs to execute a terminal command:"; + + private static final String AGENT_MSG_TITLE = "Pull Request Created"; + private static final String AGENT_MSG_DESC = + "Implemented the requested feature with comprehensive unit tests and integration tests. " + + "All existing tests continue to pass. The PR includes documentation updates as well."; + private static final String AGENT_MSG_PR_LINK = + "https://github.com/microsoft/copilot-for-eclipse/pull/330"; + + private static final String CONFIRM_TITLE = "Run command in terminal?"; + private static final String CONFIRM_MESSAGE = + "Copilot wants to execute a shell command."; + private static final String CONFIRM_COMMAND = "Start-Sleep -Seconds 5"; + private static final String CONFIRM_COMMAND_NAME = "Start-Sleep"; + private static final String CONFIRM_EXPLANATION = + "Pause the script for five seconds"; + + private static final String THINKING_ACTIVE_TEXT = + "Analyzing the project structure to determine the best approach. " + + "I need to check if there are existing test files and " + + "understand the module dependencies before making changes..."; + + private static final String QUOTA_REPLY_TEXT = "I was unable to complete the request."; + private static final String QUOTA_ERROR_MSG = + "You've used all your premium requests for the month. " + + "Consider upgrading your plan for continued access."; + + private static final String SUBAGENT_PARENT_REPLY = + "I'll delegate the investigation to a subagent."; + private static final String SUBAGENT_REPLY_TEXT = + "Searched the codebase and found the failing assertion in `LoginServiceTest`. " + + "The mock returns `null` where a session token is expected."; + + private static Composite createLabeledPane(Composite parent, String title) { + Composite pane = new Composite(parent, SWT.NONE); + pane.setLayout(new GridLayout(1, false)); + Label label = new Label(pane, SWT.NONE); + label.setText(title); + label.setLayoutData(new GridData(SWT.FILL, SWT.TOP, true, false)); + Composite content = new Composite(pane, SWT.NONE); + content.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); + content.setLayout(new FillLayout()); + return content; + } + + /** + * Creates a mock {@link ChatServiceManager} that reuses the real {@link AvatarService} from the + * running Eclipse instance (for a real user avatar) and a real {@link ChatFontService}, but + * returns a deterministic {@link CheckQuotaResult} so the quota-exceeded turn reliably renders its + * "Upgrade Plan" action button regardless of the live account's plan or billing state. + * + *

The auth manager is a {@link Mockito#spy(Object) spy} of the real one: identity-related calls + * (e.g. {@code getUserName()}) still return real data, while {@code getQuotaStatus()} is stubbed to + * a {@code free} plan with token-based billing enabled and {@code canUpgradePlan = true}, which + * {@link QuotaActions#forPlan} maps to a single primary "Upgrade Plan" action. + */ + private static ChatServiceManager createDemoChatServiceManager() { + ChatServiceManager mockManager = Mockito.mock(ChatServiceManager.class); + + // Spy the real AuthStatusManager so identity (avatar, user name) stays real, but override the + // quota status with a deterministic value so the demo's Upgrade Plan button always appears. + AuthStatusManager realAuth = CopilotCore.getPlugin().getAuthStatusManager(); + AuthStatusManager spyAuth = Mockito.spy(realAuth); + CheckQuotaResult demoQuota = new CheckQuotaResult( + null, null, null, null, null, CopilotPlan.free, true, Boolean.TRUE); + Mockito.doReturn(demoQuota).when(spyAuth).getQuotaStatus(); + Mockito.when(mockManager.getAuthStatusManager()).thenReturn(spyAuth); + + // Use a real AvatarService backed by the real auth (loads GitHub avatar) + AvatarService realAvatarService = new AvatarService(realAuth); + Mockito.when(mockManager.getAvatarService()).thenReturn(realAvatarService); + + // ChatFontService — real instance (no OSGi dependencies in constructor) + ChatFontService fontService = new ChatFontService(); + Mockito.when(mockManager.getChatFontService()).thenReturn(fontService); + + return mockManager; + } + + /** + * Feeds identical synthetic conversation data to both widgets. The conversation interleaves user + * turns with copilot turns (so every copilot turn gets its own header). History (restored) turns + * and live (streamed) turns are kept in separate turns: restoring a turn and then streaming into + * the same turn id would duplicate the browser turn container and collide child-block ids. The + * active (non-sealed) thinking block is therefore a live-only turn placed mid-conversation (never + * the trailing round, which would otherwise reserve empty space below it in StyledText). Covers + * all block types: text with formatting, tables, code blocks, task lists, thinking blocks (sealed + * and active), tool calls (all statuses), tool confirmation, error and quota warnings, subagent + * nesting, agent messages, and model info. + */ + private static void feedDemoContent( + BrowserConversationWidget browser, StyledTextConversationWidget styledText) { + feedWidgets(browser, styledText); + } + + /** + * Feeds demo content to one or both widgets. When {@code browser} is {@code null}, only the + * StyledText widget is populated (used after theme-switch recreation). + */ + private static void feedWidgets( + BrowserConversationWidget browser, StyledTextConversationWidget styledText) { + + // ConversationDataFactory needs an AuthStatusManager for tool call conversion + AuthStatusManager mockAuth = Mockito.mock(AuthStatusManager.class); + Mockito.when(mockAuth.getUserName()).thenReturn("demo-user"); + ConversationDataFactory dataFactory = new ConversationDataFactory(mockAuth); + + // The conversation interleaves user turns with copilot turns so every copilot turn gets its own + // header. History (restored) turns and the single live (streamed) turn are kept separate. + + // --- Exchange 1 (turns 1-2): table + code + task list (reply only) --- + restoreUser(browser, styledText, dataFactory, "turn-1", USER_MSG_1); + + CopilotTurnData tableTurn = new CopilotTurnData(); + tableTurn.setTurnId("turn-2"); + ReplyData tableReply = tableTurn.getReply(); + tableReply.setText(COPILOT_REPLY_TABLE); + tableReply.setModelName("GPT-5 mini"); + tableReply.setBillingMultiplier(1.0); + tableReply.setReasoningEffort("medium"); + restoreBoth(browser, styledText, tableTurn, dataFactory); + + // --- Exchange 2 (turns 3-4): markdown formatting examples --- + restoreUser(browser, styledText, dataFactory, "turn-3", USER_MSG_2); + + CopilotTurnData formattingTurn = new CopilotTurnData(); + formattingTurn.setTurnId("turn-4"); + ReplyData formattingReply = formattingTurn.getReply(); + formattingReply.setText(COPILOT_REPLY_FORMATTING); + formattingReply.setModelName("Claude Opus 4"); + formattingReply.setBillingMultiplier(2.5); + formattingReply.setReasoningEffort("high"); + restoreBoth(browser, styledText, formattingTurn, dataFactory); + + // --- Exchange 3 (turns 5-6): sealed thinking block with tool-call examples (restored) --- + restoreUser(browser, styledText, dataFactory, "turn-5", USER_MSG_INVESTIGATE); + + ThinkingBlockData thinkingBlock = new ThinkingBlockData(); + thinkingBlock.setId("turn-6-thinking"); + thinkingBlock.setContent(THINKING_CONTENT); + thinkingBlock.setTitle(THINKING_TITLE); + thinkingBlock.setState(ThinkingBlockState.COMPLETED); + + EditAgentRoundData thinkingRound = new EditAgentRoundData(); + thinkingRound.setRoundId(1); + thinkingRound.setThinkingBlock(thinkingBlock); + thinkingRound.setToolCalls(List.of( + toolCall("tc-1", "file_search", "completed", "Found 3 results in src/"), + toolCall("tc-2", "run_in_terminal", "cancelled", "User cancelled"), + toolCall("tc-3", "edit_file", "error", "Permission denied: /etc/hosts"), + toolCall("tc-4", "grep_search", "running", "Searching..."))); + + CopilotTurnData thinkingTurn = new CopilotTurnData(); + thinkingTurn.setTurnId("turn-6"); + ReplyData thinkingReply = thinkingTurn.getReply(); + thinkingReply.setEditAgentRounds(List.of(thinkingRound)); + thinkingReply.setModelName("GPT-5 mini"); + thinkingReply.setBillingMultiplier(1.0); + thinkingReply.setReasoningEffort("medium"); + restoreBoth(browser, styledText, thinkingTurn, dataFactory); + + // --- Exchange 4 (turns 7-8): active (non-sealed) thinking block via the live API --- + // Kept in its own live-only turn (never mixed with a restored turn, which would duplicate the + // browser turn container and collide child-block ids). Positioned mid-conversation so it is not + // the trailing round — a short trailing active-thinking turn would reserve empty scroller space + // below it in the StyledText windowed renderer. + restoreUser(browser, styledText, dataFactory, "turn-7", USER_MSG_LIVE); + + if (browser != null) { + browser.beginTurn("turn-8", true, false); + } + styledText.beginTurn("turn-8", true, false); + + ChatProgressValue thinkingEvent = new ChatProgressValue(); + thinkingEvent.setKind(WorkDoneProgressKind.report); + thinkingEvent.setTurnId("turn-8"); + thinkingEvent.setThinking(new Thinking("turn-8-active", THINKING_ACTIVE_TEXT, null)); + if (browser != null) { + browser.processTurnEvent(thinkingEvent); + } + styledText.processTurnEvent(thinkingEvent); + + // --- Exchange 5 (turns 9-10): reply + thinking + nested subagent + PR agent message --- + restoreUser(browser, styledText, dataFactory, "turn-9", USER_MSG_FIX_TEST); + + ThinkingBlockData subThinking = new ThinkingBlockData(); + subThinking.setId("turn-10-thinking"); + subThinking.setContent(SUBAGENT_THINKING_CONTENT); + subThinking.setTitle("Delegating investigation"); + subThinking.setState(ThinkingBlockState.COMPLETED); + + EditAgentRoundData subRound = new EditAgentRoundData(); + subRound.setRoundId(1); + subRound.setThinkingBlock(subThinking); + subRound.setToolCalls(List.of( + toolCall("tc-subagent", "run_subagent", "completed", "Investigated the failing test"))); + + CopilotTurnData subParentTurn = new CopilotTurnData(); + subParentTurn.setTurnId("turn-10"); + ReplyData subParentReply = subParentTurn.getReply(); + subParentReply.setText(SUBAGENT_PARENT_REPLY); + subParentReply.setEditAgentRounds(List.of(subRound)); + AgentMessageData prMessage = new AgentMessageData(); + prMessage.setTitle(AGENT_MSG_TITLE); + prMessage.setDescription(AGENT_MSG_DESC); + prMessage.setPrLink(AGENT_MSG_PR_LINK); + prMessage.setAgentSlug("github-copilot-coding-agent"); + subParentReply.setAgentMessages(List.of(prMessage)); + subParentReply.setModelName("Claude Opus 4"); + subParentReply.setBillingMultiplier(2.5); + subParentReply.setReasoningEffort("high"); + restoreBoth(browser, styledText, subParentTurn, dataFactory); + + // Nested subagent turn (parentTurnId + subagentToolCallId route it into the subagent block). + CopilotTurnData subChildTurn = new CopilotTurnData(); + subChildTurn.setTurnId("turn-10-sub"); + subChildTurn.setParentTurnId("turn-10"); + subChildTurn.setSubagentToolCallId("tc-subagent"); + subChildTurn.getReply().setText(SUBAGENT_REPLY_TEXT); + restoreBoth(browser, styledText, subChildTurn, dataFactory); + + // --- Exchange 6 (turns 11-12): running tool with a live tool-confirmation request --- + restoreUser(browser, styledText, dataFactory, "turn-11", USER_MSG_BUILD); + + CopilotTurnData confirmTurn = new CopilotTurnData(); + confirmTurn.setTurnId("turn-12"); + ReplyData confirmReply = confirmTurn.getReply(); + confirmReply.setText(BUILD_REPLY_TEXT); + EditAgentRoundData confirmRound = new EditAgentRoundData(); + confirmRound.setRoundId(1); + confirmRound.setToolCalls(List.of( + toolCall("tc-confirm", "run_in_terminal", "running", "Awaiting confirmation..."))); + confirmReply.setEditAgentRounds(List.of(confirmRound)); + confirmReply.setModelName("GPT-5 mini"); + confirmReply.setBillingMultiplier(1.0); + restoreBoth(browser, styledText, confirmTurn, dataFactory); + + // Show tool confirmation UI (live interaction on the copilot turn just restored). + // Mirror the real run_in_terminal confirmation built by TerminalConfirmationHandler: + // a primary "Allow Once" plus the session/global command-name, exact-command and + // allow-all accept actions (rendered inside the split-button drop-down), and a + // separate "Skip" dismiss button. Labels/scopes match production so the demo reflects + // real behavior instead of a generic Allow/Deny pair. + String cmdLabel = "'" + CONFIRM_COMMAND_NAME + "'"; + ConfirmationContent confirmation = new ConfirmationContent( + CONFIRM_TITLE, CONFIRM_MESSAGE, + List.of( + ConfirmationAction.allowOnce(Messages.confirmation_action_allowOnce), + demoAcceptAction( + NLS.bind(Messages.confirmation_action_allowNamesSession, cmdLabel), + ConfirmationActionScope.SESSION), + demoAcceptAction( + NLS.bind(Messages.confirmation_action_alwaysAllowNames, cmdLabel), + ConfirmationActionScope.GLOBAL), + demoAcceptAction(Messages.confirmation_action_allowExactSession, + ConfirmationActionScope.SESSION), + demoAcceptAction(Messages.confirmation_action_alwaysAllowExact, + ConfirmationActionScope.GLOBAL), + demoAcceptAction(Messages.confirmation_action_allowAllCommands, + ConfirmationActionScope.SESSION), + ConfirmationAction.skip(Messages.confirmation_action_skip))); + Map confirmInput = Map.of( + "command", CONFIRM_COMMAND, + "explanation", CONFIRM_EXPLANATION); + if (browser != null) { + browser.requestToolConfirmation("turn-12", confirmation, confirmInput); + } + styledText.requestToolConfirmation("turn-12", confirmation, confirmInput); + + // --- Exchange 7 (turns 13-14): agent message with a non-quota error warning (400) --- + restoreUser(browser, styledText, dataFactory, "turn-13", USER_MSG_ERROR); + + CopilotTurnData errorTurn = new CopilotTurnData(); + errorTurn.setTurnId("turn-14"); + ReplyData errorReply = errorTurn.getReply(); + errorReply.setText(ERROR_MSG); + ErrorData errorData = new ErrorData(); + errorData.setMessage(ERROR_DETAIL); + errorData.setCode(400); + ErrorMessageData errorMessageData = new ErrorMessageData(); + errorMessageData.setError(errorData); + errorReply.setErrorMessages(List.of(errorMessageData)); + errorReply.setModelName("GPT-5 mini"); + errorReply.setBillingMultiplier(1.0); + restoreBoth(browser, styledText, errorTurn, dataFactory); + + // --- Exchange 8 (turns 15-16): quota-exceeded warning (402) with action buttons --- + restoreUser(browser, styledText, dataFactory, "turn-15", USER_MSG_QUOTA); + + CopilotTurnData quotaTurn = new CopilotTurnData(); + quotaTurn.setTurnId("turn-16"); + ReplyData quotaReply = quotaTurn.getReply(); + quotaReply.setText(QUOTA_REPLY_TEXT); + ErrorData quotaError = new ErrorData(); + quotaError.setMessage(QUOTA_ERROR_MSG); + quotaError.setCode(402); + ErrorMessageData quotaMessageData = new ErrorMessageData(); + quotaMessageData.setError(quotaError); + quotaReply.setErrorMessages(List.of(quotaMessageData)); + quotaReply.setModelName("Claude Opus 4"); + quotaReply.setBillingMultiplier(2.5); + quotaReply.setReasoningEffort("high"); + restoreBoth(browser, styledText, quotaTurn, dataFactory); + } + + /** Restores a turn on both widgets; skips browser if null. */ + private static void restoreBoth(BrowserConversationWidget browser, + StyledTextConversationWidget styledText, AbstractTurnData turn, + ConversationDataFactory dataFactory) { + if (browser != null) { + browser.restoreTurn(turn, dataFactory); + } + styledText.restoreTurn(turn, dataFactory); + } + + /** Restores a user turn on both widgets; skips browser if null. */ + private static void restoreUser(BrowserConversationWidget browser, + StyledTextConversationWidget styledText, ConversationDataFactory dataFactory, + String turnId, String message) { + UserTurnData userTurn = new UserTurnData(); + userTurn.setTurnId(turnId); + userTurn.setMessage(new MessageData(message)); + restoreBoth(browser, styledText, userTurn, dataFactory); + } + + /** Creates a tool call with the given id, name, status and progress message. */ + private static ToolCallData toolCall(String id, String name, String status, String progress) { + ToolCallData toolCall = new ToolCallData(); + toolCall.setId(id); + toolCall.setName(name); + toolCall.setStatus(status); + toolCall.setProgressMessage(progress); + return toolCall; + } + + /** + * Builds a non-primary accept action for the demo confirmation, mirroring the + * session/global allow actions produced by {@code TerminalConfirmationHandler}. + * Only the label, accept flag and (non-)primary flag affect rendering; the scope + * is carried for realism. + */ + private static ConfirmationAction demoAcceptAction(String label, + ConfirmationActionScope scope) { + return new ConfirmationAction(label, true, scope, null, false); + } + + /** + * Disposes the old StyledText widget and creates a fresh one with updated theme colors. + * The StyledText-based widget reads theme colors at construction time and does not + * dynamically refresh them, so recreation is necessary on theme change. + */ + private static StyledTextConversationWidget recreateStyledTextWidget( + Composite parent, ChatServiceManager serviceManager, + StyledTextConversationWidget oldWidget) { + if (oldWidget != null && !oldWidget.getControl().isDisposed()) { + oldWidget.getControl().dispose(); + } + StyledTextConversationWidget newWidget = + new StyledTextConversationWidget(parent, serviceManager); + parent.requestLayout(); + + // Re-feed demo content to the new widget (browser-only stub as first arg) + parent.getDisplay().asyncExec(() -> feedStyledTextOnly(newWidget)); + return newWidget; + } + + /** + * Feeds demo content to a single StyledText widget (used after theme-switch recreation). + */ + private static void feedStyledTextOnly(StyledTextConversationWidget styledText) { + feedWidgets(null, styledText); + } + + /** + * Switches the Eclipse platform CSS theme (affects StyledText colors). + */ + private static void switchEclipseTheme(String themeId) { + IThemeEngine themeEngine = + (IThemeEngine) PlatformUI.getWorkbench().getService(IThemeEngine.class); + if (themeEngine != null) { + themeEngine.setTheme(themeId, true); + } + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/SvgIconsTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/SvgIconsTest.java new file mode 100644 index 00000000..11c55e0f --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/SvgIconsTest.java @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.junit.jupiter.api.Test; + +class SvgIconsTest { + + private static Map attrs(String... pairs) { + Map map = new LinkedHashMap<>(); + for (int i = 0; i + 1 < pairs.length; i += 2) { + map.put(pairs[i], pairs[i + 1]); + } + return map; + } + + @Test + void applyRootAttributes_injectsMissingAttributes() { + String svg = ""; + String result = SvgIcons.applyRootAttributes(svg, + attrs("class", "thinking-icon", "width", "14", "height", "14", "fill", "none")); + + assertTrue(result.contains("class=\"thinking-icon\""), result); + assertTrue(result.contains("width=\"14\""), result); + assertTrue(result.contains("height=\"14\""), result); + assertTrue(result.contains("fill=\"none\""), result); + } + + @Test + void applyRootAttributes_overridesExistingAttribute() { + String svg = ""; + String result = SvgIcons.applyRootAttributes(svg, attrs("fill", "currentColor")); + + assertTrue(result.contains("fill=\"currentColor\""), result); + assertFalse(result.contains("#000000"), result); + } + + @Test + void applyRootAttributes_stripsXmlnsDeclaration() { + String svg = "" + + ""; + String result = SvgIcons.applyRootAttributes(svg, attrs("width", "14")); + + assertFalse(result.contains("xmlns"), result); + } + + @Test + void applyRootAttributes_stripsXmlProlog() { + String svg = "\n" + + ""; + String result = SvgIcons.applyRootAttributes(svg, attrs("width", "14")); + + assertTrue(result.startsWith(""; + String result = SvgIcons.applyRootAttributes(svg, attrs("width", "14")); + + assertTrue(result.contains("viewBox=\"0 0 24 24\""), result); + assertTrue(result.contains(""), result); + assertTrue(result.contains(""), result); + assertTrue(result.endsWith(""), result); + } + + @Test + void applyRootAttributes_nullInput_returnsEmptyString() { + assertEquals("", SvgIcons.applyRootAttributes(null, attrs("width", "14"))); + } + + @Test + void applyRootAttributes_nonSvgInput_returnedUnchanged() { + String notSvg = "just some text"; + assertEquals(notSvg, SvgIcons.applyRootAttributes(notSvg, attrs("width", "14"))); + } + + @Test + void applyRootAttributes_style_addedWhenMissing() { + String svg = ""; + String result = SvgIcons.applyRootAttributes(svg, + attrs("style", "vertical-align: middle; margin-right: 4px;")); + + assertTrue(result.contains("style=\"vertical-align: middle; margin-right: 4px;\""), result); + } + + @Test + void applyRootAttributes_style_addsMissingSubPropertiesKeepingExisting() { + String svg = "" + + ""; + String result = SvgIcons.applyRootAttributes(svg, + attrs("style", "vertical-align: middle; margin-right: 4px;")); + + String style = styleOf(result); + assertEquals("0.5", declaration(style, "opacity")); + assertEquals("block", declaration(style, "display")); + assertEquals("middle", declaration(style, "vertical-align")); + assertEquals("4px", declaration(style, "margin-right")); + } + + @Test + void applyRootAttributes_style_overridesExistingSubPropertiesKeepingOthers() { + String svg = "" + + ""; + String result = SvgIcons.applyRootAttributes(svg, + attrs("style", "vertical-align: middle; margin-right: 4px;")); + + String style = styleOf(result); + assertEquals("middle", declaration(style, "vertical-align")); + assertEquals("0.5", declaration(style, "opacity")); + assertEquals("4px", declaration(style, "margin-right")); + } + + private static String styleOf(String svg) { + Matcher matcher = Pattern.compile("style=\"([^\"]*)\"").matcher(svg); + assertTrue(matcher.find(), "no style attribute in: " + svg); + return matcher.group(1); + } + + private static String declaration(String style, String property) { + for (String declaration : style.split(";")) { + int colon = declaration.indexOf(':'); + if (colon >= 0 && declaration.substring(0, colon).trim().equals(property)) { + return declaration.substring(colon + 1).trim(); + } + } + return null; + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/UiUtilsThemeTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/UiUtilsThemeTest.java new file mode 100644 index 00000000..c4d13014 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/UiUtilsThemeTest.java @@ -0,0 +1,209 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import org.eclipse.core.runtime.Platform; +import org.eclipse.e4.ui.css.swt.theme.ITheme; +import org.eclipse.e4.ui.css.swt.theme.IThemeEngine; +import org.eclipse.swt.SWT; +import org.eclipse.swt.graphics.RGB; +import org.eclipse.swt.widgets.Display; +import org.eclipse.ui.IWorkbench; +import org.eclipse.ui.PlatformUI; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.osgi.framework.Bundle; + +/** + * Unit tests for {@link UiUtils#isDarkTheme()} and {@link UiUtils#isDark(RGB)}. + * + *

Theme 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 through mocked {@link PlatformUI} and {@link Platform} statics, covering both the case where + * e4 CSS theming is available and the graceful fallback to the widget-background luminance when it + * is not. The pure luma decision is exercised directly via {@link UiUtils#isDark(RGB)}. + */ +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 id 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()); + } + } + + // --------------------------------------------------------------------------- + // Theming unavailable: fall back to the actual widget-background luminance + // (never the stale persisted themeid, which may not reflect the real appearance). + // --------------------------------------------------------------------------- + + @Test + void isDarkTheme_activeThemeNull_fallsBackToBackground() throws Exception { + try (MockedStatic platformUi = mockStatic(PlatformUI.class); + MockedStatic platform = mockStatic(Platform.class)) { + IWorkbench workbench = givenThemingAvailable(platformUi, platform); + givenActiveTheme(workbench, null); + + assertEquals(actualWidgetBackgroundIsDark(), UiUtils.isDarkTheme()); + } + } + + @Test + void isDarkTheme_themeBundleAbsent_fallsBackToBackground() { + try (MockedStatic platformUi = mockStatic(PlatformUI.class); + MockedStatic platform = mockStatic(Platform.class)) { + platform.when(() -> Platform.getBundle(THEME_BUNDLE)).thenReturn(null); + + assertEquals(actualWidgetBackgroundIsDark(), UiUtils.isDarkTheme()); + } + } + + @Test + void isDarkTheme_themeServiceNull_fallsBackToBackground() 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); + + assertEquals(actualWidgetBackgroundIsDark(), UiUtils.isDarkTheme()); + } + } + + @Test + void isDarkTheme_reflectionFailure_fallsBackToBackgroundWithoutThrowing() 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); + + assertEquals(actualWidgetBackgroundIsDark(), UiUtils.isDarkTheme()); + } + } + + // --------------------------------------------------------------------------- + // Pure luma decision (ITU-R BT.601). No SWT Display required. + // --------------------------------------------------------------------------- + + @Test + void isDark_black_returnsTrue() { + assertTrue(UiUtils.isDark(new RGB(0, 0, 0))); + } + + @Test + void isDark_white_returnsFalse() { + assertFalse(UiUtils.isDark(new RGB(255, 255, 255))); + } + + @Test + void isDark_belowMidGray_returnsTrue() { + // luma = 100 (< 128) -> dark + assertTrue(UiUtils.isDark(new RGB(100, 100, 100))); + } + + @Test + void isDark_aboveMidGray_returnsFalse() { + // luma = 160 (>= 128) -> light + assertFalse(UiUtils.isDark(new RGB(160, 160, 160))); + } + + @Test + void isDark_appliesPerceptualWeighting() { + // Pure green is perceived as bright (luma ~= 149.7 -> light), while pure blue is perceived as + // dark (luma ~= 29.1 -> dark), even though a plain RGB average would rank them identically. + assertFalse(UiUtils.isDark(new RGB(0, 255, 0))); + assertTrue(UiUtils.isDark(new RGB(0, 0, 255))); + } + + // --------------------------------------------------------------------------- + // 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); + } + + /** + * Reads the real widget-background color the same way the production fallback does, so the + * theming-unavailable tests can assert that {@link UiUtils#isDarkTheme()} delegates to the + * background luminance regardless of the concrete theme of the test environment. + */ + private static boolean actualWidgetBackgroundIsDark() { + Display display = Display.getDefault(); + RGB[] holder = new RGB[1]; + display.syncExec(() -> + holder[0] = display.getSystemColor(SWT.COLOR_WIDGET_BACKGROUND).getRGB()); + return UiUtils.isDark(holder[0]); + } +} 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..788a0619 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/resources/html/chat-view.html @@ -0,0 +1,247 @@ + + + + + + + + + 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..f769f59a --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/resources/html/css/chat-browser-base.css @@ -0,0 +1,427 @@ +/* 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: normal; + 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; +} + +/* Split-button (primary accept action + caret opening a dropdown of alternatives). */ +.split-button { + position: relative; + display: inline-flex; +} + +.btn-confirm.split-primary { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.btn-confirm.split-caret { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-left: 1px solid rgba(255, 255, 255, 0.4); + padding-left: 6px; + padding-right: 6px; + line-height: 1; +} + +.dropdown-menu { + position: absolute; + top: calc(100% + 2px); + left: 0; + z-index: 10; + display: flex; + flex-direction: column; + min-width: 100%; + padding: 4px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); +} + +.dropdown-menu[hidden] { + display: none; +} + +.dropdown-item { + padding: 4px 8px; + border: none; + border-radius: 3px; + background: transparent; + color: var(--fg-primary); + cursor: pointer; + font-size: 13px; + text-align: left; + white-space: nowrap; +} + +.dropdown-item:hover { + background: var(--bg-hover); +} 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/resources/html/icons/pull-request.svg b/com.microsoft.copilot.eclipse.ui/resources/html/icons/pull-request.svg new file mode 100644 index 00000000..05af7163 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/resources/html/icons/pull-request.svg @@ -0,0 +1 @@ + diff --git a/com.microsoft.copilot.eclipse.ui/resources/html/icons/terminal.svg b/com.microsoft.copilot.eclipse.ui/resources/html/icons/terminal.svg new file mode 100644 index 00000000..adff59f7 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/resources/html/icons/terminal.svg @@ -0,0 +1 @@ + diff --git a/com.microsoft.copilot.eclipse.ui/resources/html/icons/thinking-bulb.svg b/com.microsoft.copilot.eclipse.ui/resources/html/icons/thinking-bulb.svg new file mode 100644 index 00000000..32bfa890 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/resources/html/icons/thinking-bulb.svg @@ -0,0 +1 @@ + diff --git a/com.microsoft.copilot.eclipse.ui/resources/html/icons/warning.svg b/com.microsoft.copilot.eclipse.ui/resources/html/icons/warning.svg new file mode 100644 index 00000000..fc23de61 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/resources/html/icons/warning.svg @@ -0,0 +1 @@ + diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java index 9f099223..3b5eb2fd 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java @@ -30,7 +30,6 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentToolCall; 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.lsp.protocol.quota.CheckQuotaResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CopilotPlan; import com.microsoft.copilot.eclipse.core.persistence.ConversationDataFactory; import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData; @@ -623,11 +622,11 @@ protected Composite createWarnDialog(String message, int code, String modelProvi boolean overageEnabled = false; Boolean canUpgradePlan = null; if (code == 402 && !byokQuotaExceeded) { - CheckQuotaResult quotaStatus = this.serviceManager.getAuthStatusManager().getQuotaStatus(); - planForActions = quotaStatus.copilotPlan(); - overageEnabled = quotaStatus.premiumInteractions() != null - && quotaStatus.premiumInteractions().overagePermitted(); - canUpgradePlan = quotaStatus.canUpgradePlan(); + QuotaActions.QuotaPlanContext context = QuotaActions.QuotaPlanContext.from( + this.serviceManager.getAuthStatusManager().getQuotaStatus()); + planForActions = context.plan(); + overageEnabled = context.overageEnabled(); + canUpgradePlan = context.canUpgradePlan(); } WarnWidget warnWidget = new WarnWidget(this, SWT.NONE, displayMessage, planForActions, overageEnabled, canUpgradePlan); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationJavaJsBridge.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationJavaJsBridge.java new file mode 100644 index 00000000..6cc93536 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationJavaJsBridge.java @@ -0,0 +1,344 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import java.util.LinkedList; +import java.util.Queue; +import java.util.function.Consumer; + +import org.eclipse.swt.browser.Browser; +import org.eclipse.swt.browser.BrowserFunction; +import org.eclipse.swt.widgets.Display; + +import com.microsoft.copilot.eclipse.core.CopilotCore; + +/** + * Encapsulates the Java↔JavaScript boundary for {@link BrowserConversationWidget}: it CREATES + * and RUNS JavaScript against the chat {@link Browser} and REGISTERS the Java call-back functions + * that browser-side JavaScript invokes. + * + *

The owning widget keeps all SWT widget lifecycle: it creates and disposes the {@link Browser} + * and registers the {@code ProgressListener}, then passes the browser to this bridge. The bridge + * owns the page-load gating state (whether the page has loaded plus the queues of scripts and + * result-producing tasks deferred until it has); the widget's load listener calls + * {@link #notifyPageLoaded()} to flip the flag and flush those queues. + * + *

All domain reactions to JavaScript callbacks are delegated to {@link JavaCallbacks} so this + * bridge stays free of clipboard, editor, workbench and logging dependencies. + */ +public class BrowserConversationJavaJsBridge { + + /** + * Java call-back functions invoked by browser-side JavaScript. Implemented by the owning widget + * so all domain behavior stays there. Each method corresponds to one registered + * {@link BrowserFunction}. This does not cover the {@link #executeScriptForResult} result path, + * which uses a per-call {@link Consumer}. + */ + public interface JavaCallbacks { + + /** Copies {@code code} to the system clipboard. */ + void copyToClipboard(String code); + + /** Inserts {@code code} at the cursor of the active text editor. */ + void insertAtCursor(String code); + + /** Accepts the pending tool confirmation with the given action index. */ + void acceptToolAction(int actionIndex); + + /** Dismisses the pending tool confirmation. */ + void dismissToolAction(); + + /** Handles a generic copilot action (e.g. {@code openLink}, {@code openJobList}). */ + void copilotAction(String action, String param); + + /** Routes a browser-side (JavaScript) error message to the Eclipse error log. */ + void logError(String message); + } + + private final Browser browser; + private final JavaCallbacks callbacks; + + private boolean pageLoaded; + private final Queue pendingScripts = new LinkedList<>(); + /** + * Result-producing evaluations deferred until the page has loaded and {@link #pendingScripts} + * have flushed. Used so that operations needing a real return value (e.g. verifying a tool + * confirmation card was inserted) reflect the actual post-load DOM instead of an optimistic + * assumption made while the page was still loading. + */ + private final Queue pendingResultTasks = new LinkedList<>(); + + /** + * Creates the bridge over an already-created {@link Browser} and registers the Java call-back + * functions. The browser's lifecycle (creation, layout, disposal and load-listener registration) + * remains the responsibility of the owning widget. + * + * @param browser the chat browser to drive (created and owned by the widget) + * @param callbacks the domain callbacks invoked by browser-side JavaScript + */ + public BrowserConversationJavaJsBridge(Browser browser, JavaCallbacks callbacks) { + this.browser = browser; + this.callbacks = callbacks; + registerBrowserFunctions(); + } + + /** + * Marks the page as loaded and flushes any scripts and result-producing tasks queued while it was + * still loading. Must be called on the UI thread from the widget's page-load listener. + */ + public void notifyPageLoaded() { + pageLoaded = true; + while (!pendingScripts.isEmpty()) { + browser.execute(pendingScripts.poll()); + } + // Run deferred result-producing evaluations only after the DOM built by the queued scripts + // above exists, so their outcome reflects the real post-load DOM. + while (!pendingResultTasks.isEmpty()) { + pendingResultTasks.poll().run(); + } + } + + /** Applies the light/dark theme by invoking the {@code window.setTheme} helper. */ + public void setDarkTheme(boolean dark) { + browser.execute("window.setTheme('" + (dark ? "dark" : "light") + "')"); + } + + /** Scrolls the chat viewport to the bottom and re-arms auto-scroll (see chat-view.html). */ + public void scrollToBottom() { + executeScript("window.forceScrollToBottom()"); + } + + /** Appends {@code html} as the last child of the element with {@code parentId}. */ + public void insertBlock(String parentId, String html) { + executeScript(insertBlockScript(parentId, html)); + } + + /** + * Inserts {@code html} before the sibling {@code beforeId} within {@code parentId} and reports + * whether the DOM insertion actually happened to {@code onResult} (always on the UI thread). + */ + public void insertBlockBefore(String parentId, String html, String beforeId, + Consumer onResult) { + executeScriptForResult(insertBlockBeforeScript(parentId, html, beforeId), onResult); + } + + /** Replaces the element {@code blockId} with {@code html}. */ + public void replaceBlock(String blockId, String html) { + executeScript(replaceBlockScript(blockId, html)); + } + + /** Removes the element with {@code blockId}. */ + public void removeBlock(String blockId) { + executeScript(removeBlockScript(blockId)); + } + + /** + * Collapses a thinking block by removing the {@code open} attribute from its {@code

} + * element and replacing the spinner with the given bulb SVG icon. + */ + public void collapseThinkingBlock(String blockId, String bulbSvg) { + String bulbSvgJs = bulbSvg.replace("\"", "\\\"").replace("'", "\\'"); + String script = """ + var b=document.getElementById('%s');\ + if(b){var d=b.querySelector('details');if(d)d.removeAttribute('open');\ + var s=b.querySelector('.thinking-spinner');\ + if(s){var icon=document.createElement('span');\ + icon.innerHTML='%s';\ + s.parentNode.replaceChild(icon.firstChild,s);}}""" + .formatted(escapeForJs(blockId), bulbSvgJs); + executeScript(script); + } + + /** + * Updates the rendered (Markdown -> HTML) body of a thinking block and auto-scrolls to the + * bottom if the user hasn't scrolled up. Avoids replacing the entire block (which resets scroll). + * The caller passes pre-rendered HTML so live streaming matches the sealed/restored rendering. + */ + public void updateThinkingBodyText(String blockId, String bodyHtml) { + String script = """ + var b=document.getElementById('%s');\ + if(b){var bd=b.querySelector('.thinking-body');if(bd){\ + bd.innerHTML='%s';\ + var thr=60;if(bd.scrollHeight-(bd.scrollTop+bd.clientHeight)<=thr)\ + bd.scrollTop=bd.scrollHeight;}}""" + .formatted(escapeForJs(blockId), escapeForJs(bodyHtml)); + executeScript(script); + } + + /** + * Updates the summary/title of a thinking block without replacing the entire block. The caller + * passes pre-rendered inline HTML (Markdown -> HTML) so bold/inline formatting is honored. + */ + public void updateThinkingBlockTitle(String blockId, String titleHtml) { + String script = """ + var b=document.getElementById('%s');\ + if(b){var s=b.querySelector('summary');if(s)s.innerHTML='%s';}""" + .formatted(escapeForJs(blockId), escapeForJs(titleHtml)); + executeScript(script); + } + + private void registerBrowserFunctions() { + new BrowserFunction(browser, "copyToClipboard") { + @Override + public Object function(Object[] arguments) { + if (arguments.length > 0 && arguments[0] instanceof String code) { + callbacks.copyToClipboard(code); + } + return null; + } + }; + + new BrowserFunction(browser, "insertAtCursor") { + @Override + public Object function(Object[] arguments) { + if (arguments.length > 0 && arguments[0] instanceof String code) { + callbacks.insertAtCursor(code); + } + return null; + } + }; + + new BrowserFunction(browser, "acceptToolAction") { + @Override + public Object function(Object[] arguments) { + if (arguments.length > 0 && arguments[0] instanceof Double actionIndex) { + callbacks.acceptToolAction(actionIndex.intValue()); + } + return null; + } + }; + + new BrowserFunction(browser, "dismissToolAction") { + @Override + public Object function(Object[] arguments) { + callbacks.dismissToolAction(); + return null; + } + }; + + new BrowserFunction(browser, "copilotAction") { + @Override + public Object function(Object[] arguments) { + if (arguments.length < 2) { + return null; + } + callbacks.copilotAction(String.valueOf(arguments[0]), String.valueOf(arguments[1])); + return null; + } + }; + + // Routes browser-side (JavaScript) error diagnostics to the Eclipse error log so they are + // visible to the user; the browser console is not surfaced anywhere in the IDE. + new BrowserFunction(browser, "copilotLogError") { + @Override + public Object function(Object[] arguments) { + if (arguments.length > 0 && arguments[0] != null) { + callbacks.logError(String.valueOf(arguments[0])); + } + return null; + } + }; + } + + private void executeScript(String script) { + if (browser.isDisposed()) { + return; + } + Display.getDefault().asyncExec(() -> { + if (browser.isDisposed()) { + return; + } + if (pageLoaded) { + browser.execute(script); + } else { + pendingScripts.add(script); + } + }); + } + + /** + * Evaluates a JavaScript expression and reports whether the call was successful. The + * {@code expression} must evaluate to a boolean indicating success (e.g. a call to a + * {@code window.*} helper that returns {@code true}/{@code false}). The {@code onResult} callback + * is always invoked on the UI thread. Evaluation is deferred behind any previously queued scripts + * to preserve ordering. While the page is still loading, both the execution and the result + * capture are deferred until after the initial load completes and {@link #pendingScripts} have + * flushed, so the reported outcome reflects the real post-load DOM rather than an optimistic + * assumption. This lets callers such as the tool-confirmation flow reliably detect a genuine + * insertion failure (and fall back to dismiss) without ever guessing success. + */ + private void executeScriptForResult(String expression, Consumer onResult) { + if (browser.isDisposed()) { + onResult.accept(false); + return; + } + Display.getDefault().asyncExec(() -> { + if (browser.isDisposed()) { + onResult.accept(false); + return; + } + if (!pageLoaded) { + // Defer both the execution and its result capture until the page has loaded and queued + // scripts have flushed, so success/failure reflects the real DOM instead of a guess. + pendingResultTasks.add(() -> onResult.accept(evaluateForBoolean(expression))); + return; + } + onResult.accept(evaluateForBoolean(expression)); + }); + } + + /** + * Executes {@code expression} via {@link Browser#evaluate(String)} and returns whether it + * evaluated to boolean {@code true}. Must be called on the UI thread with the page loaded. Any + * evaluation failure is logged and reported as {@code false}. + */ + private boolean evaluateForBoolean(String expression) { + try { + Object result = browser.evaluate("return " + expression + ";"); + return Boolean.TRUE.equals(result); + } catch (RuntimeException e) { + CopilotCore.LOGGER.error("Failed to evaluate browser script: " + expression, e); + return false; + } + } + + /** Escapes a string for safe embedding in a single-quoted JavaScript string literal. */ + public static String escapeForJs(String text) { + if (text == null) { + return ""; + } + return text.replace("\\", "\\\\") + .replace("'", "\\'") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } + + /** + * Builds the JavaScript call that appends {@code html} as the last child of the element with + * {@code parentId}. The invoked helper returns whether the insertion was performed. + */ + public static String insertBlockScript(String parentId, String html) { + return "window.insertBlock('" + escapeForJs(parentId) + "', '" + escapeForJs(html) + "')"; + } + + /** + * Builds the JavaScript call that inserts {@code html} before the sibling {@code beforeId} within + * the element with {@code parentId}. The invoked helper returns whether the insertion was done. + */ + public static String insertBlockBeforeScript(String parentId, String html, String beforeId) { + return "window.insertBlockBefore('" + escapeForJs(parentId) + "', '" + + escapeForJs(html) + "', '" + escapeForJs(beforeId) + "')"; + } + + /** Builds the JavaScript call that replaces the element {@code blockId} with {@code html}. */ + public static String replaceBlockScript(String blockId, String html) { + return "window.replaceBlock('" + escapeForJs(blockId) + "', '" + escapeForJs(html) + "')"; + } + + /** Builds the JavaScript call that removes the element with {@code blockId}. */ + public static String removeBlockScript(String blockId) { + return "window.removeBlock('" + escapeForJs(blockId) + "')"; + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidget.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidget.java new file mode 100644 index 00000000..d562c7d7 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidget.java @@ -0,0 +1,1089 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import java.net.URL; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import org.apache.commons.lang3.StringUtils; +import org.eclipse.core.runtime.FileLocator; +import org.eclipse.jface.dialogs.MessageDialog; +import org.eclipse.jface.text.BadLocationException; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.ITextSelection; +import org.eclipse.swt.SWT; +import org.eclipse.swt.browser.Browser; +import org.eclipse.swt.browser.ProgressAdapter; +import org.eclipse.swt.browser.ProgressEvent; +import org.eclipse.swt.dnd.Clipboard; +import org.eclipse.swt.dnd.TextTransfer; +import org.eclipse.swt.dnd.Transfer; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Display; +import org.eclipse.ui.IEditorPart; +import org.eclipse.ui.texteditor.IDocumentProvider; +import org.eclipse.ui.texteditor.ITextEditor; +import org.osgi.framework.Bundle; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentRound; +import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentToolCall; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatProgressValue; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolConfirmationResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolConfirmationResult.ToolConfirmationResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.Thinking; +import com.microsoft.copilot.eclipse.core.lsp.protocol.codingagent.CodingAgentMessageRequestParams; +import com.microsoft.copilot.eclipse.core.persistence.AbstractTurnData; +import com.microsoft.copilot.eclipse.core.persistence.ConversationDataFactory; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.AgentMessageData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.EditAgentRoundData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ErrorData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ErrorMessageData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ReplyData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ThinkingBlockData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ToolCallData; +import com.microsoft.copilot.eclipse.core.persistence.UserTurnData; +import com.microsoft.copilot.eclipse.core.utils.BundleUtils; +import com.microsoft.copilot.eclipse.ui.CopilotUi; +import com.microsoft.copilot.eclipse.ui.UiConstants; +import com.microsoft.copilot.eclipse.ui.chat.QuotaActions.QuotaAction; +import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; +import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; +import com.microsoft.copilot.eclipse.ui.utils.UiUtils; + +/** + * {@link IConversationWidget} implementation that renders copilot chat turns in an + * Eclipse-internal web {@link Browser} widget using an HTML/JavaScript frontend. + * + *

This widget creates, updates, and removes HTML code blocks wrapped in DIV + * blocks with unique IDs. It delegates HTML code generation to {@link ConversationHtmlBlockFactory} + * and the commonmark Java library for rendering Markdown code, + * including GitHub-flavoured Markdown tables. + * The {@link Browser} receives the HTML code based on an HTML template (chat-view.html) + * and offers a simple API for adding, replacing, or removing HTML div blocks with a certain ID. + * + *

HTML block ID scheme: turn containers use the server-assigned turn ID. + * Child blocks within a turn use {@code turnId-N} where N is a sequential counter. + * The CSS class on each child block identifies its type + * (thinking-block, tool-call, response, etc.). + */ +public class BrowserConversationWidget + implements IConversationWidget, BrowserConversationJavaJsBridge.JavaCallbacks { + + private static final String COPILOT_AVATAR_PATH = + "/icons/chat/chat_message_copilot_avatar.png"; + private static final String USER_AVATAR_PATH = "/icons/chat/chat_message_user_avatar.png"; + private static final String COPILOT_DISPLAY_NAME = "GitHub Copilot"; + private static final String GITHUB_AVATAR_URL = + "https://avatars.githubusercontent.com/%s?s=24&v=4"; + + private final Browser browser; + private final ConversationHtmlBlockFactory htmlFactory; + /** + * Chat services used for quota resolution (and token-based-billing detection). Injected so the + * browser renderer resolves quota from the same source as the StyledText renderer instead of the + * global singleton, which keeps the two renderers consistent and makes quota behavior testable. + */ + private final ChatServiceManager serviceManager; + + // Turn streaming state (per-turn, keyed by turnId). + private final Map turnStates = new HashMap<>(); + private String currentTurnId; + private String lastCopilotTurnId; + private String conversationId; + + // Subagent nesting (single level, mirrors SWT BaseTurnWidget). Set while the parent's + // run_subagent tool call is running; consumed by the subagent's beginTurn. + private String activeSubagentContentAreaId; + // Maps a run_subagent tool call id to its nested content-area id (used on restore). + private final Map subagentContentAreaByToolCallId = new HashMap<>(); + + // Tool confirmation state + private CompletableFuture pendingConfirmationFuture; + private String pendingConfirmationTurnId; + private List pendingConfirmationActions; + private ConfirmationAction lastSelectedAction; + + /** Java↔JavaScript bridge: creates/runs JS and registers Java call-back functions. */ + private final BrowserConversationJavaJsBridge bridge; + private String copilotAvatarDataUri; + private String userAvatarDataUri; + private String userName; + + /** Types of child blocks within a copilot turn. */ + private enum ChildBlockType { + THINKING, TOOL_CALL, RESPONSE + } + + /** + * Per-turn streaming state, keyed by turnId. Keeping the block counter and current-block pointers + * per turn means a parent turn that resumes after a nested subagent keeps its own counter, so no + * duplicate DOM ids are produced. {@code contentAreaId} is where this turn's child blocks are + * inserted: the turn's own content area, or — for a subagent turn — the nested subagent content + * area. + */ + private static final class TurnStreamState { + private final String contentAreaId; + private int childBlockCounter; + private ChildBlockType currentBlockType; + private String currentChildBlockId; + private String currentToolCallId; + private String currentThinkingBlockId; + private StringBuilder currentThinkingText; + private StringBuilder currentReplyText; + private boolean streamingIndicatorVisible; + + TurnStreamState(String contentAreaId) { + this.contentAreaId = contentAreaId; + } + + void resetTransient() { + currentBlockType = null; + currentChildBlockId = null; + currentToolCallId = null; + currentThinkingBlockId = null; + currentThinkingText = null; + currentReplyText = null; + } + } + + /** + * Creates a new browser-based conversation widget. + * + * @param parent the parent composite + * @param serviceManager the chat services used to resolve quota status; may be {@code null}, in + * which case quota-driven action buttons are simply not rendered + */ + public BrowserConversationWidget(Composite parent, ChatServiceManager serviceManager) { + this.serviceManager = serviceManager; + browser = new Browser(parent, SWT.NONE); + browser.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); + htmlFactory = new ConversationHtmlBlockFactory(); + + // Load avatar icons as base64 data URIs + Bundle bundle = CopilotUi.getPlugin().getBundle(); + this.copilotAvatarDataUri = loadIconAsDataUri(bundle, COPILOT_AVATAR_PATH); + this.userAvatarDataUri = loadIconAsDataUri(bundle, USER_AVATAR_PATH); + + // Load code block action icons from Eclipse platform bundles + Bundle uiBundle = org.eclipse.core.runtime.Platform.getBundle("org.eclipse.ui"); + String copyUri = loadIconAsDataUri(uiBundle, "icons/full/etool16/copy_edit.png"); + Bundle textEditorBundle = org.eclipse.core.runtime.Platform.getBundle( + UiConstants.WORKBENCH_TEXTEDITOR); + String insertUri = loadIconAsDataUri(textEditorBundle, UiConstants.INSERT_ICON); + htmlFactory.setCodeBlockIcons(copyUri, insertUri); + + bridge = new BrowserConversationJavaJsBridge(browser, this); + + browser.addProgressListener(new ProgressAdapter() { + @Override + public void completed(ProgressEvent event) { + bridge.setDarkTheme(UiUtils.isDarkTheme()); + bridge.notifyPageLoaded(); + } + }); + + try { + URL fileUrl = bundle.getEntry("resources/html/chat-view.html"); + URL resolvedUrl = FileLocator.toFileURL(fileUrl); + browser.setUrl(resolvedUrl.toExternalForm()); + } catch (Exception e) { + CopilotCore.LOGGER.error("Failed to load chat-view.html in browser widget", e); + } + } + + @Override + public Control getControl() { + return browser; + } + + @Override + public boolean isDisposed() { + return browser.isDisposed(); + } + + @Override + public void dispose() { + if (!browser.isDisposed()) { + browser.dispose(); + } + } + + @Override + public void requestLayout() { + browser.requestLayout(); + } + + @Override + public void setConversationId(String conversationId) { + this.conversationId = conversationId; + } + + @Override + public void beginTurn(String turnId, boolean isCopilot, boolean isHistory) { + // Idempotency guard: the turn container may already have been created, e.g. by + // ensureCopilotTurnContainer() when a tool confirmation arrived before this begin event. + // Re-creating it would insert a duplicate container into the DOM. + if (turnStates.containsKey(turnId)) { + currentTurnId = turnId; + if (isCopilot) { + lastCopilotTurnId = turnId; + } + return; + } + // Subagent begin: nest inside the parent's active subagent block instead of creating a + // top-level turn container. Its child blocks are routed to the nested content area. + if (isCopilot && activeSubagentContentAreaId != null) { + currentTurnId = turnId; + turnStates.put(turnId, new TurnStreamState(activeSubagentContentAreaId)); + return; + } + currentTurnId = turnId; + if (isCopilot) { + lastCopilotTurnId = turnId; + } + turnStates.put(turnId, + new TurnStreamState(ConversationHtmlBlockFactory.contentBlockId(turnId, isCopilot))); + String turnHtml = htmlFactory.createTurnContainerHtmlBlock( + turnId, isCopilot, + isCopilot ? copilotAvatarDataUri : getUserAvatarUri(), + isCopilot ? COPILOT_DISPLAY_NAME : getUserDisplayName()); + bridge.insertBlock(ConversationHtmlBlockFactory.CHAT_CONTAINER_ID, turnHtml); + if (isCopilot && !isHistory) { + showStreamingIndicator(turnId); + } + } + + @Override + public void processTurnEvent(ChatProgressValue value) { + String turnId = value.getTurnId(); + + switch (value.getKind()) { + case report -> { + currentTurnId = turnId; + ensureTurnState(turnId, value); + removeStreamingIndicator(turnId); + processThinking(turnId, value); + processAgentRounds(turnId, value); + processReply(turnId, extractReplyChunk(value)); + } + case end -> { + removeStreamingIndicator(turnId); + finalizeTurn(turnId); + } + default -> { + // begin is handled by beginTurn(), called separately by ChatView + } + } + + // Handle error messages (can arrive on any event kind, including report and end) + String errorMessage = ChatErrorMessages.resolveDisplayMessage(value); + if (StringUtils.isNotEmpty(errorMessage)) { + processErrorMessage(turnId, errorMessage, value.getCode(), value.getErrorModelProviderName()); + } + } + + @Override + public void startNewUserTurn(String turnId, String message) { + String turnHtml = htmlFactory.createTurnContainerHtmlBlock( + turnId, false, getUserAvatarUri(), getUserDisplayName()); + bridge.insertBlock(ConversationHtmlBlockFactory.CHAT_CONTAINER_ID, turnHtml); + String contentId = ConversationHtmlBlockFactory.contentBlockId(turnId, false); + String blockId = contentId + "-0"; + String blockHtml = htmlFactory.createUserRequestHtmlBlock(blockId, message); + bridge.insertBlock(contentId, blockHtml); + // Re-arm auto-scroll for the new turn and jump to the bottom so the user turn is visible and + // the streamed reply is followed. insertBlock's own scroll respects the auto-scroll flag, + // which the user may have turned off by scrolling up during the previous turn. + scrollToBottom(); + } + + @Override + public void scrollToBottom() { + bridge.scrollToBottom(); + } + + @Override + public void refreshScrollerLayout() { + // Browser handles its own layout; no action needed + } + + @Override + public void renderErrorMessage(String content) { + String errorId = ConversationHtmlBlockFactory.errorBlockId(); + String renderedContent = htmlFactory.renderMarkdown(content); + String errorHtml = htmlFactory.createErrorTurnHtmlBlock(errorId, renderedContent); + bridge.insertBlock(ConversationHtmlBlockFactory.CHAT_CONTAINER_ID, errorHtml); + // Scroll the newly inserted error banner into view so the user notices it, mirroring the + // SWT renderer's showControl-based scroll for error banners. + scrollToBottom(); + } + + @Override + public void showCompactingStatusOnLatestCopilotTurn() { + String turnId = lastCopilotTurnId != null ? lastCopilotTurnId : currentTurnId; + if (turnId != null) { + String blockId = ConversationHtmlBlockFactory.compactingBlockId(turnId); + String html = htmlFactory.createCompactingStatusHtmlBlock(blockId); + bridge.insertBlock(ConversationHtmlBlockFactory.contentBlockId(turnId, true), html); + } + } + + @Override + public void hideCompactingStatusOnLatestCopilotTurn() { + String turnId = lastCopilotTurnId != null ? lastCopilotTurnId : currentTurnId; + if (turnId != null) { + bridge.removeBlock(ConversationHtmlBlockFactory.compactingBlockId(turnId)); + } + } + + @Override + public String getActiveThinkingBlockId(String turnId) { + TurnStreamState state = turnId != null ? turnStates.get(turnId) : null; + return state != null ? state.currentThinkingBlockId : null; + } + + @Override + public void restoreTurn(AbstractTurnData turn, ConversationDataFactory dataFactory) { + if (turn == null) { + return; + } + // Subagent turn: render nested inside the parent turn's subagent block. + if (turn instanceof CopilotTurnData copilotTurn + && StringUtils.isNotBlank(copilotTurn.getParentTurnId())) { + restoreSubagentTurn(copilotTurn); + return; + } + if (turn instanceof UserTurnData userTurn) { + if (userTurn.getMessage() != null + && StringUtils.isNotBlank(userTurn.getMessage().getText())) { + startNewUserTurn(turn.getTurnId(), userTurn.getMessage().getText()); + } + return; + } + if (turn instanceof CopilotTurnData copilotTurn) { + String turnId = turn.getTurnId(); + lastCopilotTurnId = turnId; + String turnHtml = htmlFactory.createTurnContainerHtmlBlock( + turnId, true, copilotAvatarDataUri, COPILOT_DISPLAY_NAME); + bridge.insertBlock(ConversationHtmlBlockFactory.CHAT_CONTAINER_ID, turnHtml); + restoreCopilotTurnContent(turnId, + ConversationHtmlBlockFactory.contentBlockId(turnId, true), copilotTurn); + ReplyData replyData = copilotTurn.getReply(); + if (replyData != null && StringUtils.isNotBlank(replyData.getModelName())) { + renderModelInfo(turnId, replyData.getModelName(), + replyData.getBillingMultiplier(), replyData.getReasoningEffort()); + } + } + } + + /** + * Restores a subagent turn into the nested subagent block that was opened while restoring the + * parent turn's {@code run_subagent} tool call. Falls back to the parent's content area when the + * subagent tool-call id is missing (mirrors the SWT blank-toolCallId path). + */ + private void restoreSubagentTurn(CopilotTurnData copilotTurn) { + String toolCallId = copilotTurn.getSubagentToolCallId(); + String contentAreaId = StringUtils.isNotBlank(toolCallId) + ? subagentContentAreaByToolCallId.get(toolCallId) : null; + if (contentAreaId == null) { + contentAreaId = + ConversationHtmlBlockFactory.contentBlockId(copilotTurn.getParentTurnId(), true); + } + restoreCopilotTurnContent(copilotTurn.getTurnId(), contentAreaId, copilotTurn); + } + + @Override + public void renderModelInfo(String turnId, String modelName, double billingMultiplier, + String reasoningEffort) { + // When token-based billing is enabled, the per-turn billing multiplier is no longer + // a meaningful price signal — hide it, matching StyledText widget behavior. + double effectiveMultiplier = billingMultiplier; + try { + if (serviceManager != null && serviceManager.getAuthStatusManager() + .getQuotaStatus().tokenBasedBillingEnabled()) { + effectiveMultiplier = 0; + } + } catch (Exception e) { + // Defensive: if quota status unavailable, fall back to showing multiplier + } + String blockId = ConversationHtmlBlockFactory.modelInfoBlockId(turnId); + String html = htmlFactory.createModelInfoHtmlBlock( + blockId, modelName, effectiveMultiplier, reasoningEffort); + bridge.insertBlock(ConversationHtmlBlockFactory.copilotTurnContainerId(turnId), html); + } + + @Override + public void renderAgentMessage(CodingAgentMessageRequestParams params) { + if (params == null) { + return; + } + String turnId = params.getTurnId(); + if (StringUtils.isBlank(turnId)) { + return; + } + String blockId = ConversationHtmlBlockFactory.agentMessageBlockId(turnId); + String html = htmlFactory.createAgentMessageHtmlBlock( + blockId, params.getTitle(), params.getDescription(), params.getPrLink()); + bridge.insertBlock(ConversationHtmlBlockFactory.copilotTurnContainerId(turnId), html); + } + + @Override + public CompletableFuture requestToolConfirmation( + String turnId, ConfirmationContent content, Object input) { + // Cancel any existing pending confirmation + cancelToolConfirmation(turnId); + + // Safety net: without actionable buttons the card can never be resolved by the user, so + // returning a pending future would stall the agent indefinitely. Dismiss immediately instead. + if (content == null || content.getActions() == null || content.getActions().isEmpty()) { + return CompletableFuture.completedFuture( + new LanguageModelToolConfirmationResult(ToolConfirmationResult.DISMISS)); + } + + // The confirmation request may arrive before the turn's begin event (e.g. when the terminal + // call is the turn's first action), so the copilot turn container may not exist yet. Ensure it + // exists before inserting the card; otherwise the insertion silently no-ops and the agent hangs. + ensureCopilotTurnContainer(turnId); + + CompletableFuture future = new CompletableFuture<>(); + pendingConfirmationFuture = future; + pendingConfirmationTurnId = turnId; + pendingConfirmationActions = content.getActions(); + lastSelectedAction = null; + + String blockId = ConversationHtmlBlockFactory.confirmationBlockId(turnId); + String html = htmlFactory.createConfirmationHtmlBlock(blockId, content, input); + // Insert before the model-info block so confirmation appears above it + String modelInfoId = ConversationHtmlBlockFactory.modelInfoBlockId(turnId); + insertConfirmationBlock(turnId, html, modelInfoId, future); + scrollToBottom(); + + return future; + } + + /** + * Creates the copilot turn DOM container for {@code turnId} if {@link #beginTurn} has not run for + * it yet. Delegates to {@code beginTurn}, which is idempotent, so a subsequent real begin event + * for the same turn will not create a duplicate container. + */ + private void ensureCopilotTurnContainer(String turnId) { + if (turnStates.containsKey(turnId)) { + return; + } + beginTurn(turnId, true, false); + } + + /** + * Inserts the confirmation card and verifies the DOM insertion actually happened. If the insertion + * could not be performed (e.g. the target container is missing), the pending confirmation is + * dismissed as a fallback so the agent never stalls waiting on a card that was never rendered. + */ + private void insertConfirmationBlock(String turnId, String html, String beforeId, + CompletableFuture future) { + bridge.insertBlockBefore(ConversationHtmlBlockFactory.copilotTurnContainerId(turnId), html, + beforeId, success -> { + if (!success && future == pendingConfirmationFuture && !future.isDone()) { + CopilotCore.LOGGER.error(new IllegalStateException( + "Failed to insert tool confirmation card for turn " + turnId + + "; dismissing to avoid a stall")); + resolveConfirmation(-1, false); + } + }); + } + + @Override + public void cancelToolConfirmation(String turnId) { + if (pendingConfirmationFuture != null && !pendingConfirmationFuture.isDone()) { + pendingConfirmationFuture.complete( + new LanguageModelToolConfirmationResult(ToolConfirmationResult.DISMISS)); + } + pendingConfirmationFuture = null; + pendingConfirmationActions = null; + pendingConfirmationTurnId = null; + removeConfirmationBlock(turnId); + } + + @Override + public ConfirmationAction getLastSelectedConfirmationAction() { + return lastSelectedAction; + } + + private void resolveConfirmation(int actionIndex, boolean accepted) { + if (pendingConfirmationFuture == null || pendingConfirmationFuture.isDone()) { + return; + } + if (accepted && pendingConfirmationActions != null + && actionIndex >= 0 && actionIndex < pendingConfirmationActions.size()) { + lastSelectedAction = pendingConfirmationActions.get(actionIndex); + } + ToolConfirmationResult result = accepted + ? ToolConfirmationResult.ACCEPT : ToolConfirmationResult.DISMISS; + pendingConfirmationFuture.complete(new LanguageModelToolConfirmationResult(result)); + pendingConfirmationFuture = null; + pendingConfirmationActions = null; + if (pendingConfirmationTurnId != null) { + removeConfirmationBlock(pendingConfirmationTurnId); + pendingConfirmationTurnId = null; + } + } + + private void removeConfirmationBlock(String turnId) { + if (turnId == null) { + return; + } + String blockId = ConversationHtmlBlockFactory.confirmationBlockId(turnId); + bridge.removeBlock(blockId); + } + + /** + * Processes a thinking chunk: starts a new thinking block or updates the current one. + * Seals the thinking block if renderable output follows in the same event. + */ + private void processThinking(String turnId, ChatProgressValue value) { + Thinking thinking = value.getThinking(); + boolean hasThinking = thinking != null && thinking.text() != null + && !thinking.text().isBlank(); + + TurnStreamState state = stateFor(turnId); + if (hasThinking) { + if (state.currentBlockType != ChildBlockType.THINKING) { + state.currentThinkingText = new StringBuilder(thinking.text()); + state.currentBlockType = ChildBlockType.THINKING; + state.currentChildBlockId = ConversationHtmlBlockFactory.copilotChildBlockId( + turnId, state.childBlockCounter++); + state.currentThinkingBlockId = state.currentChildBlockId; + String blockHtml = htmlFactory.createThinkingHtmlBlock( + state.currentChildBlockId, thinking.text()); + bridge.insertBlock(state.contentAreaId, blockHtml); + } else { + state.currentThinkingText.append(thinking.text()); + bridge.updateThinkingBodyText(state.currentChildBlockId, + htmlFactory.renderMarkdown(state.currentThinkingText.toString())); + } + } + + // Seal thinking if renderable output follows in the same event + if (hasRenderableOutput(value) && state.currentBlockType == ChildBlockType.THINKING) { + sealCurrentThinkingBlock(turnId); + } + } + + /** + * Seals the current thinking block: collapses it, removes the spinner, and requests a title + * from the language server. Mirrors the behavior of {@code ThinkingTurnWidget.sealThinking()}. + * Uses targeted DOM manipulation instead of replacing the entire block. + */ + private void sealCurrentThinkingBlock(String turnId) { + TurnStreamState state = stateFor(turnId); + if (state.currentThinkingBlockId == null || state.currentThinkingText == null) { + state.currentBlockType = null; + return; + } + String blockId = state.currentThinkingBlockId; + String thinkingContent = state.currentThinkingText.toString(); + state.currentBlockType = null; + + // Collapse details, remove spinner (targeted DOM ops, no full re-render) + bridge.collapseThinkingBlock(blockId, SvgIcons.get(SvgIcons.Icon.THINKING_BULB)); + + // Request title from language server (async) + requestThinkingTitle(blockId, thinkingContent, turnId); + + // The block is sealed; it is no longer the turn's active thinking block. + state.currentThinkingBlockId = null; + state.currentThinkingText = null; + } + + /** + * Requests a thinking block title from the language server and updates the summary on response. + * Follows the same logic as {@code ThinkingTurnWidget}: extracts bold titles from thinking text + * and sends either extractedTitles or the full content to the server. + */ + private void requestThinkingTitle(String blockId, String thinkingContent, String turnId) { + var ls = CopilotCore.getPlugin().getCopilotLanguageServer(); + if (ls == null || StringUtils.isBlank(thinkingContent)) { + return; + } + var params = ThinkingTitles.buildTitleParams( + thinkingContent, ThinkingTitles.extractTitles(thinkingContent)); + ls.generateThinkingTitle(params) + .thenAccept(resp -> { + if (resp != null && StringUtils.isNotBlank(resp.title())) { + bridge.updateThinkingBlockTitle(blockId, htmlFactory.renderMarkdownInline(resp.title())); + persistThinkingTitle(turnId, blockId, resp.title()); + } + }) + .exceptionally(ex -> null); + } + + private void persistThinkingTitle(String turnId, String thinkingBlockId, String title) { + if (serviceManager != null) { + ThinkingTitles.persistTitle(serviceManager.getPersistenceManager(), + conversationId, turnId, thinkingBlockId, title); + } + } + + /** + * Processes agent rounds: handles tool calls and agent-round replies. + */ + private void processAgentRounds(String turnId, ChatProgressValue value) { + List agentRounds = value.getAgentRounds(); + if (agentRounds == null || agentRounds.isEmpty()) { + return; + } + AgentRound round = agentRounds.get(0); + if (round.getToolCalls() != null && !round.getToolCalls().isEmpty()) { + AgentToolCall toolCall = round.getToolCalls().get(0); + processToolCall(turnId, toolCall); + } + } + + /** + * Processes a reply chunk: starts a new response block or updates the current one. + */ + private void processReply(String turnId, String replyChunk) { + if (replyChunk == null || replyChunk.isEmpty()) { + return; + } + TurnStreamState state = stateFor(turnId); + if (state.currentBlockType != ChildBlockType.RESPONSE) { + state.currentReplyText = new StringBuilder(replyChunk); + state.currentBlockType = ChildBlockType.RESPONSE; + state.currentChildBlockId = ConversationHtmlBlockFactory.copilotChildBlockId( + turnId, state.childBlockCounter++); + String blockHtml = htmlFactory.createCopilotReplyHtmlBlock( + state.currentChildBlockId, state.currentReplyText.toString()); + bridge.insertBlock(state.contentAreaId, blockHtml); + } else { + state.currentReplyText.append(replyChunk); + String blockHtml = htmlFactory.createCopilotReplyHtmlBlock( + state.currentChildBlockId, state.currentReplyText.toString()); + bridge.replaceBlock(state.currentChildBlockId, blockHtml); + } + } + + /** + * Processes a tool call: creates a new tool call block or updates an existing one. The + * {@code run_subagent} tool call is not rendered as a normal block — it drives subagent nesting + * (mirrors {@code BaseTurnWidget} in the SWT renderer). + */ + private void processToolCall(String turnId, AgentToolCall toolCall) { + if (toolCall != null && "run_subagent".equalsIgnoreCase(toolCall.getName())) { + handleSubagentToolCall(turnId, toolCall); + return; + } + TurnStreamState state = stateFor(turnId); + String toolCallId = toolCall.getId(); + boolean isSameToolCall = state.currentBlockType == ChildBlockType.TOOL_CALL + && state.currentChildBlockId != null + && toolCallId != null && toolCallId.equals(state.currentToolCallId); + + if (!isSameToolCall) { + state.currentBlockType = ChildBlockType.TOOL_CALL; + state.currentToolCallId = toolCallId; + String blockId = + ConversationHtmlBlockFactory.toolCallBlockId(turnId, state.childBlockCounter++); + state.currentChildBlockId = blockId; + String blockHtml = htmlFactory.createToolCallHtmlBlock(blockId, toolCall); + bridge.insertBlock(state.contentAreaId, blockHtml); + } else { + String blockHtml = htmlFactory.createToolCallHtmlBlock( + state.currentChildBlockId, toolCall); + bridge.replaceBlock(state.currentChildBlockId, blockHtml); + } + } + + /** + * Handles the parent turn's {@code run_subagent} tool call. On {@code running} it opens a nested + * subagent block under the parent's content area; on completion/cancellation/error it closes the + * active nesting. Mirrors {@code BaseTurnWidget.handleSubagentToolCall} in the SWT renderer. + */ + private void handleSubagentToolCall(String parentTurnId, AgentToolCall toolCall) { + String status = toolCall.getStatus() == null ? "" : toolCall.getStatus().toLowerCase(); + String toolCallId = toolCall.getId(); + switch (status) { + case "running" -> { + if (activeSubagentContentAreaId == null && StringUtils.isNotBlank(toolCallId)) { + activeSubagentContentAreaId = openSubagentBlock(parentTurnId, toolCallId, + subagentTitle(toolCall.getProgressMessage(), toolCall.getName())); + } + } + case "completed", "cancelled", "error" -> activeSubagentContentAreaId = null; + default -> { + // pending/unknown: nothing to do yet + } + } + } + + /** + * Inserts a nested subagent block under the given parent turn's content area (idempotent per + * tool-call id) and returns the id of its inner content area, where the subagent's child blocks + * are inserted. + */ + private String openSubagentBlock(String parentTurnId, String toolCallId, String title) { + String existing = subagentContentAreaByToolCallId.get(toolCallId); + if (existing != null) { + return existing; + } + String blockId = ConversationHtmlBlockFactory.subagentBlockId(parentTurnId, toolCallId); + String contentAreaId = + ConversationHtmlBlockFactory.subagentContentAreaId(parentTurnId, toolCallId); + String html = htmlFactory.createSubagentBlockHtmlBlock( + blockId, contentAreaId, copilotAvatarDataUri, title); + bridge.insertBlock(ConversationHtmlBlockFactory.contentBlockId(parentTurnId, true), html); + subagentContentAreaByToolCallId.put(toolCallId, contentAreaId); + return contentAreaId; + } + + /** Derives the subagent card title: progress message, else tool name, else "Subagent". */ + private static String subagentTitle(String progressMessage, String name) { + if (StringUtils.isNotBlank(progressMessage)) { + return progressMessage; + } + if (StringUtils.isNotBlank(name)) { + return name; + } + return "Subagent"; + } + + /** + * Processes a streaming error message: renders a warning block with the error text and, + * for quota-exceeded (402) errors, plan-driven action buttons. + */ + private void processErrorMessage(String turnId, String message, int code, + String modelProviderName) { + List actions = resolveQuotaActions(code, modelProviderName); + TurnStreamState state = stateFor(turnId); + String blockId = + ConversationHtmlBlockFactory.copilotChildBlockId(turnId, state.childBlockCounter++); + state.currentBlockType = null; + bridge.insertBlock(state.contentAreaId, + htmlFactory.createWarningMessageHtmlBlock(blockId, message, actions)); + // Scroll the newly inserted warning banner (e.g. quota-exceeded / 402 with plan-driven + // action buttons) into view so the user notices the required action, mirroring the SWT + // renderer's showControl-based scroll for warn banners. + scrollToBottom(); + } + + /** + * Resolves the quota action buttons appropriate for an error code. Returns an empty list + * for non-quota errors or when the required auth status is unavailable. + */ + private List resolveQuotaActions(int code, String modelProviderName) { + if (serviceManager == null) { + return List.of(); + } + try { + return QuotaActions.forQuotaStatus( + serviceManager.getAuthStatusManager().getQuotaStatus(), code, modelProviderName); + } catch (Exception e) { + CopilotCore.LOGGER.error("Failed to resolve quota actions", e); + return List.of(); + } + } + + /** Finalizes a turn: seals thinking, final response render, and resets its transient pointers. */ + private void finalizeTurn(String turnId) { + TurnStreamState state = turnStates.get(turnId); + if (state == null) { + return; + } + // Seal any active thinking block before the turn ends + if (state.currentBlockType == ChildBlockType.THINKING) { + sealCurrentThinkingBlock(turnId); + } else if (state.currentBlockType == ChildBlockType.RESPONSE + && state.currentReplyText != null && state.currentReplyText.length() > 0) { + String blockHtml = htmlFactory.createCopilotReplyHtmlBlock( + state.currentChildBlockId, state.currentReplyText.toString()); + bridge.replaceBlock(state.currentChildBlockId, blockHtml); + } + state.resetTransient(); + } + + private void restoreCopilotTurnContent(String turnId, String contentId, + CopilotTurnData copilotTurn) { + ReplyData replyData = copilotTurn.getReply(); + if (replyData == null) { + return; + } + + int blockIdx = 0; + + if (StringUtils.isNotBlank(replyData.getText())) { + String blockId = ConversationHtmlBlockFactory.copilotChildBlockId(turnId, blockIdx++); + bridge.insertBlock(contentId, + htmlFactory.createCopilotReplyHtmlBlock(blockId, replyData.getText())); + } + + if (replyData.getEditAgentRounds() != null) { + for (EditAgentRoundData round : replyData.getEditAgentRounds()) { + ThinkingBlockData thinkingBlock = round.getThinkingBlock(); + if (thinkingBlock != null && StringUtils.isNotBlank(thinkingBlock.getContent())) { + String blockId = ConversationHtmlBlockFactory.copilotChildBlockId(turnId, blockIdx++); + bridge.insertBlock(contentId, + htmlFactory.createRestoredThinkingHtmlBlock(blockId, thinkingBlock)); + } + // Reply text renders before tool calls so it appears above them (and above any nested + // subagent card opened by a run_subagent tool call), matching StyledTextConversationWidget. + if (StringUtils.isNotBlank(round.getReply())) { + String blockId = ConversationHtmlBlockFactory.copilotChildBlockId(turnId, blockIdx++); + bridge.insertBlock(contentId, + htmlFactory.createCopilotReplyHtmlBlock(blockId, round.getReply())); + } + if (round.getToolCalls() != null) { + for (ToolCallData tc : round.getToolCalls()) { + // A run_subagent tool call opens a nested block; the subagent turn (restored + // afterwards) renders into it rather than showing a normal tool-call block. + if (tc != null && "run_subagent".equalsIgnoreCase(tc.getName()) + && StringUtils.isNotBlank(tc.getId())) { + openSubagentBlock(turnId, tc.getId(), + subagentTitle(tc.getProgressMessage(), tc.getName())); + continue; + } + String blockId = ConversationHtmlBlockFactory.copilotChildBlockId(turnId, blockIdx++); + bridge.insertBlock(contentId, + htmlFactory.createRestoredToolCallHtmlBlock(blockId, tc)); + } + } + } + } + + if (replyData.getErrorMessages() != null) { + for (ErrorMessageData errorMessageData : replyData.getErrorMessages()) { + ErrorData errorData = errorMessageData.getError(); + String errorMessage = errorData != null + ? errorData.getMessage() : "An error occurred"; + int errorCode = errorData != null ? errorData.getCode() : 0; + String modelProviderName = errorData != null ? errorData.getModelProviderName() : null; + List actions = resolveQuotaActions(errorCode, modelProviderName); + String blockId = ConversationHtmlBlockFactory.copilotChildBlockId(turnId, blockIdx++); + bridge.insertBlock(contentId, + htmlFactory.createWarningMessageHtmlBlock(blockId, errorMessage, actions)); + } + } + + if (replyData.getAgentMessages() != null) { + for (AgentMessageData agentMsg : replyData.getAgentMessages()) { + if (StringUtils.equals(agentMsg.getAgentSlug(), + UiConstants.GITHUB_COPILOT_CODING_AGENT_SLUG)) { + String blockId = ConversationHtmlBlockFactory.copilotChildBlockId(turnId, blockIdx++); + bridge.insertBlock(contentId, + htmlFactory.createAgentMessageHtmlBlock(blockId, + agentMsg.getTitle(), agentMsg.getDescription(), + agentMsg.getPrLink())); + } + } + } + } + + private void showStreamingIndicator(String turnId) { + TurnStreamState state = stateFor(turnId); + String indicatorId = ConversationHtmlBlockFactory.streamingIndicatorId(turnId); + String html = htmlFactory.createStreamingIndicatorHtmlBlock(indicatorId); + bridge.insertBlock(state.contentAreaId, html); + state.streamingIndicatorVisible = true; + } + + private void removeStreamingIndicator(String turnId) { + TurnStreamState state = turnStates.get(turnId); + if (state == null || !state.streamingIndicatorVisible) { + return; + } + bridge.removeBlock(ConversationHtmlBlockFactory.streamingIndicatorId(turnId)); + state.streamingIndicatorVisible = false; + } + + private boolean hasRenderableOutput(ChatProgressValue value) { + if (StringUtils.isNotBlank(value.getReply())) { + return true; + } + if (value.getAgentRounds() != null && !value.getAgentRounds().isEmpty()) { + AgentRound round = value.getAgentRounds().get(0); + if (round.getReply() != null && !round.getReply().isEmpty()) { + return true; + } + if (round.getToolCalls() != null && !round.getToolCalls().isEmpty()) { + return true; + } + } + return false; + } + + private String extractReplyChunk(ChatProgressValue value) { + if (value.getAgentRounds() != null && !value.getAgentRounds().isEmpty()) { + return value.getAgentRounds().get(0).getReply(); + } + return value.getReply(); + } + + /** Returns the streaming state for a turn, creating a default top-level one if absent. */ + private TurnStreamState stateFor(String turnId) { + return turnStates.computeIfAbsent(turnId, + id -> new TurnStreamState(ConversationHtmlBlockFactory.contentBlockId(id, true))); + } + + /** + * Ensures a streaming state exists for a turn seen in a {@code report} event. Normally the state + * is created by {@link #beginTurn}; this defensively creates one (nested when a subagent is + * active) if the begin event was not observed. + */ + private TurnStreamState ensureTurnState(String turnId, ChatProgressValue value) { + TurnStreamState state = turnStates.get(turnId); + if (state != null) { + return state; + } + boolean nested = value != null && StringUtils.isNotBlank(value.getParentTurnId()) + && activeSubagentContentAreaId != null; + String contentAreaId = nested + ? activeSubagentContentAreaId + : ConversationHtmlBlockFactory.contentBlockId(turnId, true); + state = new TurnStreamState(contentAreaId); + turnStates.put(turnId, state); + return state; + } + + private String getUserDisplayName() { + if (userName != null && !userName.isEmpty()) { + return userName; + } + try { + return serviceManager.getAuthStatusManager().getUserName(); + } catch (Exception e) { + return "User"; + } + } + + /** + * Returns the user avatar URI. Uses the GitHub avatar URL if the user is signed in, + * otherwise falls back to the bundled default user icon. + */ + private String getUserAvatarUri() { + try { + String user = serviceManager.getAuthStatusManager().getUserName(); + if (StringUtils.isNotBlank(user)) { + return String.format(GITHUB_AVATAR_URL, user); + } + } catch (Exception e) { + // Fall through to default + } + return userAvatarDataUri; + } + + private static String loadIconAsDataUri(Bundle bundle, String path) { + return BundleUtils.readResourceAsDataUri(bundle, path, "image/png"); + } + + @Override + public void copyToClipboard(String code) { + Display.getDefault().asyncExec(() -> { + Clipboard clipboard = new Clipboard(Display.getDefault()); + clipboard.setContents( + new Object[]{code}, + new Transfer[]{TextTransfer.getInstance()}); + clipboard.dispose(); + }); + } + + @Override + public void insertAtCursor(String code) { + Display.getDefault().asyncExec(() -> { + if (browser.isDisposed()) { + return; + } + IEditorPart editor = SwtUtils.getActiveEditorPart(); + if (editor == null) { + showInsertError("Cannot Insert", "No active editor found."); + return; + } + + ITextEditor textEditor = editor instanceof ITextEditor te + ? te + : editor.getAdapter(ITextEditor.class); + if (textEditor == null) { + CopilotCore.LOGGER + .error(new IllegalStateException("The active editor doesn't support text insertion.")); + showInsertError("Cannot Insert", "The active editor doesn't support text insertion."); + return; + } + + IDocumentProvider provider = textEditor.getDocumentProvider(); + IDocument doc = provider == null ? null : provider.getDocument(textEditor.getEditorInput()); + if (doc == null) { + CopilotCore.LOGGER + .error(new IllegalStateException("Failed to get the document from the active editor.")); + showInsertError("Cannot Insert", "Failed to get the document from the active editor."); + return; + } + + try { + ITextSelection sel = (ITextSelection) textEditor.getSelectionProvider().getSelection(); + int offset = sel.getOffset(); + doc.replace(offset, sel.getLength(), code); + + // Set the cursor position after the inserted text + textEditor.selectAndReveal(offset + code.length(), 0); + } catch (BadLocationException e) { + CopilotCore.LOGGER.error("Failed to insert code at cursor", e); + showInsertError("Insert Failed", "An error occurred while inserting the code: " + + e.getMessage()); + } + }); + } + + /** + * Shows a modal error dialog for a failed "Insert into editor" action. This mirrors the user + * feedback the StyledText renderer ({@link SourceViewerComposite}) already provides for the same + * failure cases, so a code block that cannot be inserted no longer fails silently in the browser + * renderer. The modal dialog restores keyboard focus to the previously focused control (the chat + * view's browser) on its own when it is dismissed, so no explicit focus handling is needed here. + * + * @param title the dialog title + * @param message the human-readable reason the insertion could not be performed + */ + private void showInsertError(String title, String message) { + if (browser.isDisposed()) { + return; + } + MessageDialog.openError(browser.getShell(), title, message); + } + + @Override + public void acceptToolAction(int actionIndex) { + resolveConfirmation(actionIndex, true); + } + + @Override + public void dismissToolAction() { + resolveConfirmation(-1, false); + } + + @Override + public void copilotAction(String action, String param) { + switch (action) { + case "openLink": + UiUtils.openLink(param); + break; + case "openJobList": + UiUtils.openE4Part(Constants.GITHUB_JOBS_VIEW_ID); + break; + default: + break; + } + } + + @Override + public void logError(String message) { + CopilotCore.LOGGER.error(new IllegalStateException("Browser chat renderer: " + message)); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java index b3db64b4..c7e8c6b8 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatContentViewer.java @@ -3,17 +3,14 @@ package com.microsoft.copilot.eclipse.ui.chat; -import java.util.ArrayList; import java.util.HashMap; import java.util.IdentityHashMap; -import java.util.List; import java.util.Map; import java.util.Queue; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; -import java.util.regex.Pattern; import org.apache.commons.lang3.StringUtils; import org.eclipse.e4.core.services.events.IEventBroker; @@ -34,13 +31,9 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentToolCall; import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatProgressValue; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel; -import com.microsoft.copilot.eclipse.core.lsp.protocol.TodoItem; -import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolSpecificData; import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CheckQuotaResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CopilotPlan; -import com.microsoft.copilot.eclipse.ui.CopilotUi; import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; -import com.microsoft.copilot.eclipse.ui.chat.services.TodoListService; import com.microsoft.copilot.eclipse.ui.i18n.Messages; import com.microsoft.copilot.eclipse.ui.swt.CssConstants; import com.microsoft.copilot.eclipse.ui.utils.MenuUtils; @@ -59,13 +52,6 @@ public class ChatContentViewer extends Composite { private static final int SCROLL_THRESHOLD = 100; - /** - * Matches the trailing "| Request ID: ..." and "GitHub Request ID: ..." segments that the - * language server appends to user-facing error messages. - */ - private static final Pattern REQUEST_ID_SUFFIX = - Pattern.compile("\\s*\\|?\\s*(?:GitHub\\s+)?Request\\s+ID:\\s*\\S+\\.?", Pattern.CASE_INSENSITIVE); - private ChatServiceManager serviceManager; private String conversationId; @@ -253,8 +239,6 @@ private void doProcessTurnEvent(ChatProgressValue value) { return; } - ChatServiceManager chatServiceManager = CopilotUi.getPlugin().getChatServiceManager(); - if (value.getKind() == WorkDoneProgressKind.report) { if (turnWidget instanceof ThinkingTurnWidget thinkingTurn) { thinkingTurn.setConversationContext(conversationId, value.getTurnId()); @@ -277,9 +261,6 @@ private void doProcessTurnEvent(ChatProgressValue value) { if (agentRound.getToolCalls() != null && !agentRound.getToolCalls().isEmpty()) { AgentToolCall toolCall = agentRound.getToolCalls().get(0); turnWidget.appendToolCallStatus(toolCall); - - // Extract and process todo list from tool result details - processTodoListFromToolCall(chatServiceManager, value.getConversationId(), toolCall); } } else { // Handle chat mode responses @@ -294,15 +275,7 @@ private void doProcessTurnEvent(ChatProgressValue value) { turnWidget.flushMessageBuffer(); } - String errMsg = value.getErrorMessage(); - if (StringUtils.isNotEmpty(errMsg)) { - errMsg = REQUEST_ID_SUFFIX.matcher(errMsg).replaceAll(StringUtils.EMPTY).trim(); - } - String reason = value.getErrorReason(); - if (StringUtils.isNotEmpty(reason) && reason.equals("model_not_supported")) { - // TODO: add enable button for better UX. - errMsg = Messages.chat_model_unsupported_message; - } + String errMsg = ChatErrorMessages.resolveDisplayMessage(value); if (StringUtils.isNotEmpty(errMsg)) { // TODO: Remove this legacy fallback after TBB is officially released. // When the language server has not enabled token-based billing yet, fall back to the @@ -398,36 +371,6 @@ private static boolean hasRenderableAgentRound(ChatProgressValue value) { return false; } - /** - * Process todo list from tool call result. Extracts todo list data from the tool-specific data - * and updates the TodoListService. - * - * @param chatServiceManager the chat service manager - * @param conversationId the conversation ID - * @param toolCall the agent tool call containing tool-specific data - */ - private void processTodoListFromToolCall(ChatServiceManager chatServiceManager, String conversationId, - AgentToolCall toolCall) { - if (chatServiceManager == null || conversationId == null || toolCall == null) { - return; - } - - ToolSpecificData toolSpecificData = toolCall.getToolSpecificData(); - if (toolSpecificData == null || toolSpecificData.getTodoList() == null) { - return; - } - - TodoListService todoListService = chatServiceManager.getTodoListService(); - if (todoListService == null) { - return; - } - - List todos = toolSpecificData.getTodoList(); - if (todos != null) { - todoListService.setTodoList(new ArrayList<>(todos)); - } - } - /** * Shows the compacting status on the latest Copilot turn after flushing any buffered reply text. */ diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatErrorMessages.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatErrorMessages.java new file mode 100644 index 00000000..640b14e9 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatErrorMessages.java @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import java.util.regex.Pattern; + +import org.apache.commons.lang3.StringUtils; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatProgressValue; +import com.microsoft.copilot.eclipse.ui.i18n.Messages; + +/** + * Normalizes user-facing error messages carried by {@link ChatProgressValue} so that both the SWT + * ({@link ChatContentViewer}) and browser ({@link BrowserConversationWidget}) conversation widgets + * display identical text. + */ +public final class ChatErrorMessages { + + /** Server reason string indicating the selected model is not supported. */ + private static final String REASON_MODEL_NOT_SUPPORTED = "model_not_supported"; + + /** + * Matches the trailing "| Request ID: ..." and "GitHub Request ID: ..." segments that the + * language server appends to user-facing error messages. + */ + private static final Pattern REQUEST_ID_SUFFIX = Pattern.compile( + "\\s*\\|?\\s*(?:GitHub\\s+)?Request\\s+ID:\\s*\\S+\\.?", Pattern.CASE_INSENSITIVE); + + private ChatErrorMessages() { + } + + /** + * Resolves the display error message for a progress event: strips the trailing request-ID suffix + * and maps the {@code model_not_supported} reason to a friendly message. Returns the original + * message (which may be blank) when no normalization applies. + * + * @param value the progress event + * @return the normalized message to display, possibly blank + */ + public static String resolveDisplayMessage(ChatProgressValue value) { + if (value == null) { + return StringUtils.EMPTY; + } + String message = value.getErrorMessage(); + if (StringUtils.isNotEmpty(message)) { + message = REQUEST_ID_SUFFIX.matcher(message).replaceAll(StringUtils.EMPTY).trim(); + } + if (REASON_MODEL_NOT_SUPPORTED.equals(value.getErrorReason())) { + message = Messages.chat_model_unsupported_message; + } + return message; + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java index c5aa62c6..6cd81b0a 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java @@ -9,6 +9,7 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; @@ -25,6 +26,7 @@ import org.eclipse.e4.core.services.events.IEventBroker; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.jface.util.IPropertyChangeListener; import org.eclipse.lsp4e.LSPEclipseUtils; import org.eclipse.lsp4j.Range; import org.eclipse.lsp4j.WorkspaceFolder; @@ -69,19 +71,13 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotStatusResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.RateLimitWarningParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.TodoItem; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ToolSpecificData; import com.microsoft.copilot.eclipse.core.lsp.protocol.Turn; import com.microsoft.copilot.eclipse.core.lsp.protocol.codingagent.CodingAgentMessageRequestParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.QuotaWarningParams; import com.microsoft.copilot.eclipse.core.persistence.AbstractTurnData; import com.microsoft.copilot.eclipse.core.persistence.ConversationPersistenceManager; import com.microsoft.copilot.eclipse.core.persistence.ConversationXmlData; -import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData; -import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.AgentMessageData; -import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.EditAgentRoundData; -import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ErrorData; -import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ErrorMessageData; -import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ReplyData; -import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ToolCallData; import com.microsoft.copilot.eclipse.core.persistence.UserTurnData; import com.microsoft.copilot.eclipse.terminal.api.IRunInTerminalTool; import com.microsoft.copilot.eclipse.terminal.api.TerminalServiceManager; @@ -116,7 +112,7 @@ public class ChatView extends ViewPart implements ChatProgressListener, MessageL private Composite contentWrapper; private HandoffContainer handoffContainer; private ActionBar actionBar; - private ChatContentViewer chatContentViewer; + private IConversationWidget conversationWidget; private Composite loadingViewer; private Composite noSubscriptionViewer; private Composite beforeLoginWelcomeViewer; @@ -155,6 +151,9 @@ public class ChatView extends ViewPart implements ChatProgressListener, MessageL private EventHandler compressionStartedHandler; private EventHandler compressionCompletedHandler; + /** Listener that rebuilds the conversation page when the chat renderer preference is toggled. */ + private IPropertyChangeListener rendererPreferenceListener; + // Context activation for chat view keyboard shortcuts private static final String CHAT_VIEW_CONTEXT = "com.microsoft.copilot.eclipse.chatViewContext"; @@ -297,8 +296,8 @@ public void done(IJobChangeEvent event) { } // Clear existing content by recreating the chat content viewer - if (chatContentViewer != null) { - chatContentViewer.dispose(); + if (conversationWidget != null) { + conversationWidget.dispose(); createConversationPage(); } @@ -328,7 +327,9 @@ public void done(IJobChangeEvent event) { } // Scroll to bottom after restoring all turns - SwtUtils.invokeOnDisplayThreadAsync(this::scrollContentToBottom, chatContentViewer); + if (conversationWidget != null) { + conversationWidget.scrollToBottom(); + } // Hide chat history and show restored conversation hideChatHistory(); @@ -416,10 +417,10 @@ public void done(IJobChangeEvent event) { return; } SwtUtils.invokeOnDisplayThreadAsync(() -> { - if (this.chatContentViewer == null || this.chatContentViewer.isDisposed()) { + if (this.conversationWidget == null || this.conversationWidget.isDisposed()) { return; } - this.chatContentViewer.showCompactingStatusOnLatestCopilotTurn(); + this.conversationWidget.showCompactingStatusOnLatestCopilotTurn(); }, parent); }; this.eventBroker.subscribe(CopilotEventConstants.TOPIC_CHAT_COMPRESSION_STARTED, @@ -434,10 +435,10 @@ public void done(IJobChangeEvent event) { return; } SwtUtils.invokeOnDisplayThreadAsync(() -> { - if (this.chatContentViewer == null || this.chatContentViewer.isDisposed()) { + if (this.conversationWidget == null || this.conversationWidget.isDisposed()) { return; } - this.chatContentViewer.hideCompactingStatusOnLatestCopilotTurn(); + this.conversationWidget.hideCompactingStatusOnLatestCopilotTurn(); if (params.contextInfo() != null) { this.chatServiceManager.getContextWindowService().updateContextSize(params.contextInfo()); } @@ -448,6 +449,9 @@ public void done(IJobChangeEvent event) { // Register part listener to activate/deactivate chat view context for keyboard shortcuts registerPartListener(); + + // Rebuild the conversation page in place when the chat renderer preference is toggled. + registerRendererPreferenceListener(); } /** @@ -786,8 +790,90 @@ private void createLoadingPage() { */ private void createConversationPage() { clearChatView(this.contentWrapper); - this.chatContentViewer = new ChatContentViewer(this.contentWrapper, SWT.NONE, this.chatServiceManager); - this.chatContentViewer.requestLayout(); + this.conversationWidget = createConversationWidget(this.contentWrapper); + this.conversationWidget.requestLayout(); + } + + private IConversationWidget createConversationWidget(Composite parent) { + boolean useBrowser = CopilotUi.getPlugin().getPreferenceStore() + .getBoolean(Constants.USE_BROWSER_BASED_CHAT_RENDERER); + if (useBrowser) { + return new BrowserConversationWidget(parent, this.chatServiceManager); + } + return new StyledTextConversationWidget(parent, this.chatServiceManager); + } + + /** + * Register a preference listener that rebuilds the conversation page in place when the + * {@link Constants#USE_BROWSER_BASED_CHAT_RENDERER} preference changes, so a renderer switch takes + * effect on an already-open chat view without requiring an IDE restart. + */ + private void registerRendererPreferenceListener() { + IPreferenceStore preferenceStore = CopilotUi.getPlugin().getPreferenceStore(); + this.rendererPreferenceListener = event -> { + if (!Constants.USE_BROWSER_BASED_CHAT_RENDERER.equals(event.getProperty())) { + return; + } + if (Objects.equals(event.getOldValue(), event.getNewValue())) { + return; + } + SwtUtils.invokeOnDisplayThreadAsync(this::reloadConversationRenderer, parent); + }; + preferenceStore.addPropertyChangeListener(this.rendererPreferenceListener); + } + + /** + * Rebuilds the conversation page using the renderer currently selected by the + * {@link Constants#USE_BROWSER_BASED_CHAT_RENDERER} preference, preserving the active conversation. + * + *

The current conversation (if any) is reloaded from persistence so the visible history is not + * lost. The reload is skipped while a response is streaming, because the in-flight turn is not yet + * persisted and would be dropped by the widget swap; the new renderer then takes effect the next + * time the page is rebuilt. Must be invoked on the UI thread. + */ + public void reloadConversationRenderer() { + // Nothing to do if there is no conversation page currently shown. + if (this.contentWrapper == null || this.contentWrapper.isDisposed() || this.conversationWidget == null) { + return; + } + + // Avoid swapping the widget mid-stream; the in-flight turn is not yet persisted and would be lost. + if (this.actionBar != null && !this.actionBar.isSendButton()) { + return; + } + + String currentConversationId = this.conversationId; + + // Rebuild the page with the newly selected renderer. + createConversationPage(); + + // Re-restore the active conversation's turns so the visible history survives the renderer swap. + if (StringUtils.isNotBlank(currentConversationId) && this.persistenceManager != null) { + this.persistenceManager.loadConversation(currentConversationId).thenAccept(historyConversation -> { + if (historyConversation == null) { + return; + } + SwtUtils.invokeOnDisplayThreadAsync(() -> { + if (this.conversationWidget == null || this.conversationWidget.isDisposed()) { + return; + } + if (historyConversation.getTurns() != null && !historyConversation.getTurns().isEmpty()) { + for (AbstractTurnData turn : historyConversation.getTurns()) { + restoreTurn(turn); + } + } + List todos = historyConversation.getTodos(); + TodoListService todoService = chatServiceManager.getTodoListService(); + if (todoService != null && todos != null && !todos.isEmpty()) { + todoService.setTodoList(todos); + } + this.conversationWidget.scrollToBottom(); + }, contentWrapper); + }).exceptionally(ex -> { + CopilotCore.LOGGER.error("Failed to reload conversation after renderer change: " + currentConversationId, ex); + return null; + }); + } } /** @@ -836,9 +922,9 @@ private void clearChatView(Composite composite) { this.agentModeViewer.dispose(); this.agentModeViewer = null; } - if (this.chatContentViewer != null) { - this.chatContentViewer.dispose(); - this.chatContentViewer = null; + if (this.conversationWidget != null) { + this.conversationWidget.dispose(); + this.conversationWidget = null; } if (this.noSubscriptionViewer != null) { this.noSubscriptionViewer.dispose(); @@ -855,13 +941,13 @@ private void clearChatView(Composite composite) { */ @Override public void onChatProgress(ChatProgressValue value) { - if (this.actionBar.isSendButton()) { + if (this.actionBar == null || this.actionBar.isSendButton()) { return; } switch (value.getKind()) { case begin: - if (this.chatContentViewer != null) { - this.chatContentViewer.getLatestOrCreateNewTurnWidget(value.getTurnId(), true, false); + if (this.conversationWidget != null) { + this.conversationWidget.beginTurn(value.getTurnId(), true, false); } // Handle subagent conversation ID management @@ -885,8 +971,8 @@ public void onChatProgress(ChatProgressValue value) { this.conversationState = ConversationState.CONTINUED_CONVERSATION; } // Always sync conversationId — chatContentViewer may have been recreated - if (this.chatContentViewer != null) { - this.chatContentViewer.setConversationId(this.conversationId); + if (this.conversationWidget != null) { + this.conversationWidget.setConversationId(this.conversationId); } } @@ -934,14 +1020,20 @@ public void onChatProgress(ChatProgressValue value) { } if ((value.getAgentRounds() == null || value.getAgentRounds().isEmpty()) && (value.getReply() == null || value.getReply().isEmpty()) - && (value.getThinking() == null || StringUtils.isBlank(value.getThinking().text()))) { + && (value.getThinking() == null || StringUtils.isBlank(value.getThinking().text())) + && StringUtils.isEmpty(value.getErrorMessage())) { return; } - if (this.chatContentViewer != null) { - this.chatContentViewer.processTurnEvent(value); + if (this.conversationWidget != null) { + this.conversationWidget.processTurnEvent(value); } - String thinkingBlockId = this.chatContentViewer != null - ? this.chatContentViewer.getActiveThinkingBlockId(value.getTurnId()) : null; + + // Update the todo list from any todo-writing tool calls in this report. This is + // renderer-agnostic domain logic, so it lives here rather than inside a specific widget. + updateTodoListFromReport(value); + + String thinkingBlockId = this.conversationWidget != null + ? this.conversationWidget.getActiveThinkingBlockId(value.getTurnId()) : null; // Track run_subagent tool call ID for associating subagent turns if (StringUtils.isBlank(value.getParentTurnId()) && value.getAgentRounds() != null) { @@ -983,13 +1075,13 @@ public void onChatProgress(ChatProgressValue value) { return; } - if (this.chatContentViewer != null) { - this.chatContentViewer.processTurnEvent(value); + if (this.conversationWidget != null) { + this.conversationWidget.processTurnEvent(value); this.actionBar.resetSendButton(); this.topBanner.updateTitle(value.getSuggestedTitle()); } - String endThinkingBlockId = this.chatContentViewer != null - ? this.chatContentViewer.getActiveThinkingBlockId(value.getTurnId()) : null; + String endThinkingBlockId = this.conversationWidget != null + ? this.conversationWidget.getActiveThinkingBlockId(value.getTurnId()) : null; // Persist final conversation state and conversation title on end if (persistenceManager != null) { @@ -1225,7 +1317,9 @@ private void onSendInternal(String workDoneToken, String message, String agentSl if (createNewTurn) { // TODO: Move to createPartControl...eventBroker.subscribe(CopilotEventConstants.TOPIC_CHAT_ON_SEND...(line 114) // after the refactor. - this.chatContentViewer.startNewTurn(workDoneToken, message); + if (this.conversationWidget != null) { + this.conversationWidget.startNewUserTurn(workDoneToken, message); + } } } @@ -1295,13 +1389,15 @@ private void displayErrorAndResetSendButton(String workDoneToken, String message } String content = String.format(Messages.chat_chatContentView_errorTemplate, message, workDoneToken); SwtUtils.invokeOnDisplayThread(() -> { - chatContentViewer.renderErrorMessage(content); + if (conversationWidget != null) { + conversationWidget.renderErrorMessage(content); + } actionBar.resetSendButton(); }, parent); } private void handleCodingAgentMessage(CodingAgentMessageRequestParams params) { - if (params == null || this.chatContentViewer == null) { + if (params == null) { return; } @@ -1315,14 +1411,9 @@ private void handleCodingAgentMessage(CodingAgentMessageRequestParams params) { persistenceManager.addCodingAgentMessage(params, UiConstants.GITHUB_COPILOT_CODING_AGENT_SLUG); } - SwtUtils.invokeOnDisplayThread(() -> { - if (this.chatContentViewer != null && !this.chatContentViewer.isDisposed()) { - BaseTurnWidget turnWidget = this.chatContentViewer.getTurnWidget(params.getTurnId()); - if (turnWidget != null && !turnWidget.isDisposed()) { - turnWidget.createAgentMessageWidget(params); - } - } - }, parent); + if (this.conversationWidget != null && !this.conversationWidget.isDisposed()) { + this.conversationWidget.renderAgentMessage(params); + } } /** @@ -1439,8 +1530,8 @@ public void onCancel() { if (this.actionBar != null && !this.actionBar.isDisposed()) { this.actionBar.resetSendButton(); } - if (this.chatContentViewer != null && !this.chatContentViewer.isDisposed()) { - this.chatContentViewer.hideCompactingStatusOnLatestCopilotTurn(); + if (this.conversationWidget != null && !this.conversationWidget.isDisposed()) { + this.conversationWidget.hideCompactingStatusOnLatestCopilotTurn(); } } @@ -1491,10 +1582,36 @@ public String getSubagentConversationId() { } /** - * Get the current chat content viewer. + * Returns the active conversation widget. */ - public ChatContentViewer getChatContentViewer() { - return this.chatContentViewer; + public IConversationWidget getConversationWidget() { + return this.conversationWidget; + } + + /** + * Updates the {@link TodoListService} from any todo-writing tool calls carried by a report + * progress event. Kept in the view (not a widget) because it is renderer-agnostic domain logic + * shared by both the SWT and browser conversation widgets. + */ + private void updateTodoListFromReport(ChatProgressValue value) { + if (value == null || value.getAgentRounds() == null || value.getAgentRounds().isEmpty()) { + return; + } + TodoListService todoListService = chatServiceManager.getTodoListService(); + if (todoListService == null) { + return; + } + for (AgentRound round : value.getAgentRounds()) { + if (round.getToolCalls() == null) { + continue; + } + for (AgentToolCall toolCall : round.getToolCalls()) { + ToolSpecificData toolSpecificData = toolCall.getToolSpecificData(); + if (toolSpecificData != null && toolSpecificData.getTodoList() != null) { + todoListService.setTodoList(new ArrayList<>(toolSpecificData.getTodoList())); + } + } + } } /** @@ -1640,6 +1757,11 @@ public void dispose() { } } + if (this.rendererPreferenceListener != null) { + CopilotUi.getPlugin().getPreferenceStore().removePropertyChangeListener(this.rendererPreferenceListener); + this.rendererPreferenceListener = null; + } + if (this.chatServiceManager != null) { if (this.chatServiceManager.getUserPreferenceService() != null) { this.chatServiceManager.getUserPreferenceService().unbindChatView(); @@ -1673,9 +1795,9 @@ public void dispose() { this.chatHistoryViewer.dispose(); this.chatHistoryViewer = null; } - if (this.chatContentViewer != null) { - this.chatContentViewer.dispose(); - this.chatContentViewer = null; + if (this.conversationWidget != null) { + this.conversationWidget.dispose(); + this.conversationWidget = null; } if (this.afterLoginWelcomeViewer != null) { this.afterLoginWelcomeViewer.dispose(); @@ -1884,21 +2006,16 @@ public void hideChatHistory() { * Scroll the chat content viewer to the bottom. */ public void scrollContentToBottom() { - if (chatContentViewer == null || chatContentViewer.isDisposed()) { + if (conversationWidget == null || conversationWidget.isDisposed()) { return; } - - SwtUtils.invokeOnDisplayThreadAsync(() -> { - chatContentViewer.refreshLayoutFull(); - chatContentViewer.scrollToBottomIfAutoScroll(); - }, chatContentViewer); + conversationWidget.scrollToBottom(); } /** - * Render model information in the Copilot turn widget. + * Render model information in the conversation widget. * * @param turnId the turn ID - * @param conversationId the conversation ID to use for persistence * @param modelName the model name * @param billingMultiplier the billing multiplier * @param reasoningEffort the reasoning effort sent for this turn (may be {@code null} when the model does not @@ -1906,134 +2023,23 @@ public void scrollContentToBottom() { */ private void renderModelInfoInTurnWidget(String turnId, String modelName, double billingMultiplier, String reasoningEffort) { - BaseTurnWidget turnWidget = this.chatContentViewer.getTurnWidget(turnId); - if (turnWidget instanceof CopilotTurnWidget copilotWidget) { - copilotWidget.renderModelInfo(modelName, billingMultiplier, reasoningEffort); - - // Refresh the scroller layout to ensure the footer is visible. - SwtUtils.invokeOnDisplayThreadAsync(() -> { - this.chatContentViewer.refreshLayoutFull(); - this.chatContentViewer.scrollToBottomIfAutoScroll(); - }, this.chatContentViewer); + if (this.conversationWidget == null || this.conversationWidget.isDisposed()) { + return; } + this.conversationWidget.renderModelInfo(turnId, modelName, billingMultiplier, reasoningEffort); } /** - * Restore a single turn from persisted conversation data. + * Restore a single turn from persisted conversation data. Delegates to the active + * {@link IConversationWidget} implementation. * * @param turn the turn data to restore */ private void restoreTurn(AbstractTurnData turn) { - if (turn == null || chatContentViewer == null) { - return; - } - - // Subagent turns: render their content inside the parent turn's subagent block - if (turn instanceof CopilotTurnData copilotTurn - && StringUtils.isNotBlank(copilotTurn.getParentTurnId())) { - BaseTurnWidget parentWidget = chatContentViewer.getTurnWidget(copilotTurn.getParentTurnId()); - if (parentWidget != null) { - String toolCallId = copilotTurn.getSubagentToolCallId(); - if (StringUtils.isNotBlank(toolCallId)) { - // Restore subagent content into the SubagentMessageBlock identified by the tool call ID - parentWidget.restoreSubagentContent(toolCallId, copilotTurn, persistenceManager.getDataFactory()); - } else { - // Fallback: append to parent widget directly (legacy data without subagentToolCallId) - restoreCopilotTurnContent(copilotTurn, parentWidget); - } - } - return; - } - - // Create user turn widget and populate with user message - if (turn instanceof UserTurnData userTurn) { - if (userTurn.getMessage() == null || StringUtils.isNotBlank(userTurn.getMessage().getText())) { - BaseTurnWidget userTurnWidget = chatContentViewer.getLatestOrCreateNewTurnWidget(turn.getTurnId(), false, true); - userTurnWidget.appendMessage(userTurn.getMessage().getText()); - userTurnWidget.flushMessageBuffer(); - return; - } - } else if (turn instanceof CopilotTurnData copilotTurn) { - BaseTurnWidget copilotTurnWidget = chatContentViewer.getLatestOrCreateNewTurnWidget(turn.getTurnId(), true, true); - restoreCopilotTurnContent(copilotTurn, copilotTurnWidget); - - copilotTurnWidget.flushMessageBuffer(); - - // Restore model info footer if model name is present - // This must be done AFTER flushMessageBuffer() to ensure footer appears at the bottom - ReplyData replyData = copilotTurn.getReply(); - if (replyData != null && StringUtils.isNotBlank(replyData.getModelName())) { - // Reasoning effort was captured and persisted at send time so the footer reflects what was actually used - // for this turn, not whatever the user has selected now. - renderModelInfoInTurnWidget(turn.getTurnId(), replyData.getModelName(), replyData.getBillingMultiplier(), - replyData.getReasoningEffort()); - } - } - } - - /** - * Restores the content of a CopilotTurnData (reply text, agent rounds, tool calls, errors, agent messages) into the - * given turn widget. Used for both main copilot turns and subagent turns. - */ - private void restoreCopilotTurnContent(CopilotTurnData copilotTurn, BaseTurnWidget turnWidget) { - ReplyData replyData = copilotTurn.getReply(); - if (replyData == null) { + if (turn == null || conversationWidget == null || conversationWidget.isDisposed()) { return; } - - ThinkingTurnWidget thinkingWidget = - turnWidget instanceof ThinkingTurnWidget ? (ThinkingTurnWidget) turnWidget : null; - - if (StringUtils.isNotBlank(replyData.getText())) { - turnWidget.appendMessage(replyData.getText()); - } - - if (replyData.getEditAgentRounds() != null && !replyData.getEditAgentRounds().isEmpty()) { - for (EditAgentRoundData round : replyData.getEditAgentRounds()) { - // Restore thinking block before the round's reply and tool calls - if (thinkingWidget != null && round.getThinkingBlock() != null) { - thinkingWidget.restoreThinkingBlock(round.getThinkingBlock()); - } - if (round.getReply() != null && !round.getReply().isEmpty()) { - turnWidget.appendMessage(round.getReply()); - } - if (round.getToolCalls() != null && !round.getToolCalls().isEmpty()) { - for (ToolCallData toolCallData : round.getToolCalls()) { - AgentToolCall agentToolCall = persistenceManager.getDataFactory() - .convertToolCallDataToAgentToolCall(toolCallData); - turnWidget.appendToolCallStatus(agentToolCall); - } - } - } - } - - if (replyData.getErrorMessages() != null && !replyData.getErrorMessages().isEmpty()) { - for (ErrorMessageData errorMessageData : replyData.getErrorMessages()) { - ErrorData errorData = errorMessageData.getError(); - SwtUtils.invokeOnDisplayThread(() -> { - String errorMessage = errorData != null ? errorData.getMessage() : Messages.chat_warnWidget_defaultErrorMsg; - int errorCode = errorData != null ? errorData.getCode() : 0; - String modelProviderName = errorData != null ? errorData.getModelProviderName() : null; - turnWidget.createWarnDialog(errorMessage, errorCode, modelProviderName); - }, parent); - } - } - - if (replyData.getAgentMessages() != null && !replyData.getAgentMessages().isEmpty()) { - for (AgentMessageData agentMessageData : replyData.getAgentMessages()) { - if (StringUtils.equals(agentMessageData.getAgentSlug(), UiConstants.GITHUB_COPILOT_CODING_AGENT_SLUG)) { - SwtUtils.invokeOnDisplayThread(() -> { - CodingAgentMessageRequestParams params = new CodingAgentMessageRequestParams(); - params.setTitle(agentMessageData.getTitle()); - params.setDescription(agentMessageData.getDescription()); - params.setPrLink(agentMessageData.getPrLink()); - params.setConversationId(this.conversationId); - params.setTurnId(copilotTurn.getTurnId()); - turnWidget.createAgentMessageWidget(params); - }, parent); - } - } - } + conversationWidget.restoreTurn(turn, persistenceManager.getDataFactory()); } /** diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ConversationHtmlBlockFactory.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ConversationHtmlBlockFactory.java new file mode 100644 index 00000000..7abbf131 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ConversationHtmlBlockFactory.java @@ -0,0 +1,557 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; +import org.commonmark.Extension; +import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension; +import org.commonmark.ext.gfm.tables.TablesExtension; +import org.commonmark.ext.task.list.items.TaskListItemsExtension; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.html.HtmlRenderer; + +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentToolCall; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ThinkingBlockData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ToolCallData; +import com.microsoft.copilot.eclipse.ui.chat.QuotaActions.QuotaAction; + +/** + * Factory for creating HTML block fragments used by {@link BrowserConversationWidget}. + * + *

Each method produces a self-contained HTML DIV block having an + * {@code id} attribute. The widget inserts or updates these blocks in the browser DOM + * using the generic JavaScript API for code block manipulation. + */ +public class ConversationHtmlBlockFactory { + + /** + * DOM ID of the root chat container defined in {@code resources/html/chat-view.html}. New turn + * containers and top-level blocks are appended as its children. Must stay in sync with the + * {@code id} used in {@code chat-view.html}. + */ + public static final String CHAT_CONTAINER_ID = "chat-container"; + + private final Parser markdownParser; + private final HtmlRenderer htmlRenderer; + private String copyIconDataUri = ""; + private String insertIconDataUri = ""; + + /** Creates a new factory with GFM tables and other extensions. */ + public ConversationHtmlBlockFactory() { + List extensions = List.of( + TablesExtension.create(), + TaskListItemsExtension.create(), + StrikethroughExtension.create() + ); + this.markdownParser = Parser.builder().extensions(extensions).build(); + this.htmlRenderer = HtmlRenderer.builder() + .extensions(extensions) + // prevent HTML injection in potentially malicious LLM-generated Markdown code + .escapeHtml(true) + // avoid potentially malicious URL schemes in LLM-generated Markdown code + .sanitizeUrls(true) + .build(); + } + + /** + * Sets the data URIs for code block action button icons. + * + * @param copyIconUri base64 data URI for the copy icon + * @param insertIconUri base64 data URI for the insert icon + */ + public void setCodeBlockIcons(String copyIconUri, String insertIconUri) { + this.copyIconDataUri = copyIconUri != null ? copyIconUri : ""; + this.insertIconDataUri = insertIconUri != null ? insertIconUri : ""; + } + + /** + * User turn container ID: {@code turnId-user}. User and copilot turns share the same + * server-assigned turn ID (workDoneToken), so each role gets a distinct container ID. + */ + public static String userTurnContainerId(String turnId) { + return turnId + "-user"; + } + + /** + * Copilot turn container ID: {@code turnId-copilot}. Paired with + * {@link #userTurnContainerId(String)} to avoid duplicate DOM IDs. + */ + public static String copilotTurnContainerId(String turnId) { + return turnId + "-copilot"; + } + + /** Content container ID, scoped by role: {@code turnId-user-content} or + * {@code turnId-copilot-content}. + */ + public static String contentBlockId(String turnId, boolean isCopilot) { + String containerId = isCopilot + ? copilotTurnContainerId(turnId) : userTurnContainerId(turnId); + return containerId + "-content"; + } + + /** Sequential copilot child block ID: {@code turnId-N}. */ + public static String copilotChildBlockId(String turnId, int index) { + return turnId + "-" + index; + } + + /** Tool call block ID: {@code turnId-tc-N}. */ + public static String toolCallBlockId(String turnId, int index) { + return turnId + "-tc-" + index; + } + + /** Compacting status block ID: {@code turnId-compacting}. */ + public static String compactingBlockId(String turnId) { + return turnId + "-compacting"; + } + + /** Model info block ID: {@code turnId-model-info}. */ + public static String modelInfoBlockId(String turnId) { + return turnId + "-model-info"; + } + + /** Agent message block ID: {@code turnId-agent-msg-timestamp}. */ + public static String agentMessageBlockId(String turnId) { + return turnId + "-agent-msg-" + System.currentTimeMillis(); + } + + /** Error turn block ID: {@code error-timestamp}. Not scoped to a turn (errors may be turnless). */ + public static String errorBlockId() { + return "error-" + System.currentTimeMillis(); + } + + /** Streaming indicator block ID: {@code turnId-streaming}. */ + public static String streamingIndicatorId(String turnId) { + return turnId + "-streaming"; + } + + /** Confirmation block ID: {@code turnId-confirm}. */ + public static String confirmationBlockId(String turnId) { + return turnId + "-confirm"; + } + + /** Subagent block ID: {@code parentTurnId-subagent-toolCallId}. */ + public static String subagentBlockId(String parentTurnId, String toolCallId) { + return parentTurnId + "-subagent-" + toolCallId; + } + + /** Subagent content-area ID (where the nested subagent turn's child blocks are inserted). */ + public static String subagentContentAreaId(String parentTurnId, String toolCallId) { + return subagentBlockId(parentTurnId, toolCallId) + "-content"; + } + + /** + * Creates the outer turn container with header (avatar + name) and empty content div. + */ + public String createTurnContainerHtmlBlock(String turnId, boolean isCopilot, + String avatarDataUri, String displayName) { + String containerId = isCopilot + ? copilotTurnContainerId(turnId) : userTurnContainerId(turnId); + String cssClass = isCopilot ? "turn turn-copilot" : "turn turn-user"; + String avatar = (avatarDataUri != null && !avatarDataUri.isEmpty()) + ? "\"\"/".formatted(escapeHtml(avatarDataUri)) + : ""; + return """ +

%s\ + %s
\ +
""" + .formatted(escapeHtml(containerId), cssClass, avatar, + escapeHtml(displayName), escapeHtml(contentBlockId(turnId, isCopilot))); + } + + /** + * Creates a nested subagent block: a bordered card with a header (copilot avatar + title) and an + * inner content area into which the subagent turn's child blocks are inserted. Mirrors the SWT + * {@code SubagentTurnWidget} (copilot avatar + tool-call-derived title) and shares the + * {@code subagent-message-block} CSS class for visual parity with the other message cards. + */ + public String createSubagentBlockHtmlBlock(String blockId, String contentAreaId, + String avatarDataUri, String title) { + String avatar = StringUtils.isNotBlank(avatarDataUri) + ? "\"\"/".formatted(escapeHtml(avatarDataUri)) + : ""; + String titleText = escapeHtml(StringUtils.isNotBlank(title) ? title : "Subagent"); + return """ +
\ +
%s%s
\ +
""" + .formatted(escapeHtml(blockId), avatar, titleText, escapeHtml(contentAreaId)); + } + + /** Creates a collapsible thinking block (open by default during streaming, with spinner). */ + public String createThinkingHtmlBlock(String blockId, String thinkingText) { + return """ +
\ + \ + Thinking…\ +
%s
""" + .formatted(escapeHtml(blockId), renderMarkdown(thinkingText)); + } + + /** Creates a sealed/completed thinking block (closed, no spinner, with optional title). */ + public String createSealedThinkingHtmlBlock(String blockId, String thinkingText, String title) { + String summary = StringUtils.isNotBlank(title) + ? renderMarkdownInline(title) : "Thinking…"; + return """ +
\ + %s%s\ +
%s
""" + .formatted(escapeHtml(blockId), SvgIcons.get(SvgIcons.Icon.THINKING_BULB), + summary, renderMarkdown(thinkingText)); + } + + /** Creates a restored thinking block (closed, no spinner, with optional title). */ + public String createRestoredThinkingHtmlBlock(String blockId, ThinkingBlockData data) { + return createSealedThinkingHtmlBlock(blockId, data.getContent(), data.getTitle()); + } + + /** Creates a tool call status block from a live AgentToolCall. */ + public String createToolCallHtmlBlock(String blockId, AgentToolCall toolCall) { + String progressMsg = toolCall.getProgressMessage(); + String progressSpan = (progressMsg != null && !progressMsg.isEmpty()) + ? " %s".formatted(escapeHtml(progressMsg)) + : ""; + return toolCallHtmlBlock(blockId, toolCall.getStatus(), toolCall.getName(), progressSpan); + } + + /** Creates a tool call status block from persisted ToolCallData. */ + public String createRestoredToolCallHtmlBlock(String blockId, ToolCallData tc) { + String progressMsg = tc.getProgressMessage(); + String progressSpan = StringUtils.isNotBlank(progressMsg) + ? " %s".formatted(escapeHtml(progressMsg)) + : ""; + return toolCallHtmlBlock(blockId, tc.getStatus(), tc.getName(), progressSpan); + } + + /** + * Builds a tool-call status block. The {@code progressSpan} is a pre-rendered, optional + * progress fragment (empty string when absent) so each caller keeps its own presence check. + */ + private static String toolCallHtmlBlock(String blockId, String status, String name, + String progressSpan) { + return """ +
%s \ + %s%s
""" + .formatted(escapeHtml(blockId), toolCallStatusClass(status), + toolCallIcon(status), escapeHtml(name), progressSpan); + } + + /** + * Returns the icon HTML for a tool call status. Running state shows animated dots; + * completed shows a green checkmark; error shows a red cross. + * + *

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

%s
""".formatted(escapeHtml(blockId), renderedHtml); + } + + /** Creates a user request block with rendered Markdown content. */ + public String createUserRequestHtmlBlock(String blockId, String markdownText) { + String renderedHtml = renderMarkdown(markdownText); + return """ +
%s
""".formatted(escapeHtml(blockId), renderedHtml); + } + + /** Creates an error message block (simple, no action buttons). */ + public String createErrorMessageHtmlBlock(String blockId, String errorMessage) { + return createWarningMessageHtmlBlock(blockId, errorMessage, List.of()); + } + + /** + * Creates a warning message block with an SVG warning icon, message text, and optional + * action buttons. Used for quota-exceeded (402) errors and generic turn-level errors. + * + * @param blockId unique DOM element ID + * @param message the warning/error message to display + * @param actions quota actions to render as buttons; empty list for no buttons + * @return self-contained HTML div block + */ + public String createWarningMessageHtmlBlock(String blockId, String message, + List actions) { + String actionsBlock = ""; + if (actions != null && !actions.isEmpty()) { + StringBuilder buttons = new StringBuilder(); + for (QuotaAction action : actions) { + String cssClass = action.primary() ? "btn-confirm btn-primary" : "btn-confirm"; + buttons.append(""" + """ + .formatted(cssClass, escapeHtml(action.url()), + escapeHtml(action.tooltip()), escapeHtml(action.label()))); + } + actionsBlock = "
%s
".formatted(buttons); + } + return """ +
%s\ + %s
%s
""" + .formatted(escapeHtml(blockId), SvgIcons.get(SvgIcons.Icon.WARNING), + escapeHtml(message), actionsBlock); + } + + /** Creates a standalone error turn block (for top-level errors). */ + public String createErrorTurnHtmlBlock(String blockId, String renderedContent) { + return """ +
%s
""" + .formatted(escapeHtml(blockId), renderedContent); + } + + /** Creates a compacting status block. */ + public String createCompactingStatusHtmlBlock(String blockId) { + return """ +
⏳ Compacting…
""" + .formatted(escapeHtml(blockId)); + } + + /** Creates a model info footer block. */ + public String createModelInfoHtmlBlock(String blockId, String modelName, + double billingMultiplier, String reasoningEffort) { + String billing = (billingMultiplier > 0 && billingMultiplier != 1.0) + ? " (%sx)".formatted(billingMultiplier) + : ""; + String reasoning = ""; + if (reasoningEffort != null && !reasoningEffort.isEmpty()) { + String capitalized = reasoningEffort.substring(0, 1).toUpperCase() + + reasoningEffort.substring(1); + reasoning = " - %s".formatted(escapeHtml(capitalized)); + } + return """ +
%s%s%s
""" + .formatted(escapeHtml(blockId), escapeHtml(modelName), billing, reasoning); + } + + /** Creates an agent message block (e.g., coding agent PR link). */ + public String createAgentMessageHtmlBlock(String blockId, String title, + String description, String prLink) { + String titleBlock = StringUtils.isNotBlank(title) + ? "
%s%s
" + .formatted(SvgIcons.get(SvgIcons.Icon.PULL_REQUEST), escapeHtml(title)) + : ""; + String descBlock = ""; + if (StringUtils.isNotBlank(description)) { + // Match the StyledText AgentMessageWidget, which truncates the description + // to the first 100 characters followed by an ellipsis. + String reducedDescription = description.length() > 100 + ? description.substring(0, 100) + "..." + : description; + descBlock = "

%s

".formatted(escapeHtml(reducedDescription)); + } + String actionsBlock = ""; + if (StringUtils.isNotBlank(prLink)) { + actionsBlock = """ +
\ + \ +
""" + .formatted(escapeHtml(prLink)); + } + return """ +
%s%s%s
""" + .formatted(escapeHtml(blockId), titleBlock, descBlock, actionsBlock); + } + + /** Creates a streaming indicator (animated dots) shown while waiting for copilot response. */ + public String createStreamingIndicatorHtmlBlock(String blockId) { + return """ +
\ + \ +
""".formatted(escapeHtml(blockId)); + } + + /** + * 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) { + // Message + String message = StringUtils.isNotBlank(content.getMessage()) + ? "
%s
".formatted(escapeHtml(content.getMessage())) + : ""; + + // Command panel (extracted from input map) + String commandPanel = ""; + String explanationPanel = ""; + if (input instanceof Map) { + Map inputMap = (Map) input; + Object command = inputMap.get("command"); + if (command != null) { + commandPanel = "
%s
" + .formatted(escapeHtml(command.toString())); + } + Object explanation = inputMap.get("explanation"); + if (explanation != null) { + explanationPanel = "
%s
" + .formatted(escapeHtml(explanation.toString())); + } + } + + // Action buttons: mirror the SWT split-dropdown layout. The primary accept action is + // rendered as a button; any additional accept actions are moved into a caret-triggered + // dropdown attached to it, and the dismiss action stays a separate button beside it. + StringBuilder actionsHtml = new StringBuilder(); + List actions = content.getActions(); + if (actions != null && !actions.isEmpty()) { + int primaryIndex = -1; + int dismissIndex = -1; + List alternativeIndexes = new ArrayList<>(); + for (int i = 0; i < actions.size(); i++) { + ConfirmationAction action = actions.get(i); + if (!action.isAccept()) { + dismissIndex = i; + } else if (action.isPrimary() && primaryIndex < 0) { + primaryIndex = i; + } else { + alternativeIndexes.add(i); + } + } + // Fallback: if no accept action is flagged primary, promote the first accept action so + // the confirmation card always stays actionable. + if (primaryIndex < 0 && !alternativeIndexes.isEmpty()) { + primaryIndex = alternativeIndexes.remove(0); + } + if (primaryIndex >= 0) { + appendConfirmationSplitButton(actionsHtml, actions, primaryIndex, alternativeIndexes); + } + if (dismissIndex >= 0) { + ConfirmationAction dismiss = actions.get(dismissIndex); + actionsHtml.append(""" + """ + .formatted(escapeHtml(dismiss.getLabel()))); + } + } + + return """ +
\ +
%s%s
%s%s%s\ +
%s
""" + .formatted(escapeHtml(blockId), SvgIcons.get(SvgIcons.Icon.TERMINAL), + escapeHtml(content.getTitle()), message, commandPanel, explanationPanel, + actionsHtml.toString()); + } + + /** + * Appends the primary accept action as a button. When alternative accept actions exist, + * the button is wrapped in a split-button group with a caret that opens a dropdown menu + * listing those alternatives; otherwise a plain primary button is emitted. + */ + private void appendConfirmationSplitButton(StringBuilder html, + List actions, int primaryIndex, List alternativeIndexes) { + ConfirmationAction primary = actions.get(primaryIndex); + if (alternativeIndexes.isEmpty()) { + html.append(""" + """ + .formatted(primaryIndex, escapeHtml(primary.getLabel()))); + return; + } + html.append(""" +
\ + \ + \ +
"); + } + + /** Renders Markdown to HTML and injects code block action buttons. */ + public String renderMarkdown(String markdown) { + if (markdown == null || markdown.isEmpty()) { + return ""; + } + String html = htmlRenderer.render(markdownParser.parse(markdown)); + return injectCodeBlockButtons(html); + } + + /** + * Renders Markdown to HTML for an inline context such as a thinking block title inside a + * {@code }. Strips a single wrapping {@code

...

} (which commonmark always emits + * around a lone paragraph) so the result stays inline and does not break the summary layout. + * Does not inject code block action buttons. + */ + public String renderMarkdownInline(String markdown) { + if (markdown == null || markdown.isEmpty()) { + return ""; + } + String html = htmlRenderer.render(markdownParser.parse(markdown)).strip(); + if (html.startsWith("

") && html.endsWith("

")) { + html = html.substring("

".length(), html.length() - "

".length()); + } + return 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() ? "" + : """ + """.formatted(copyIconDataUri); + String insertImg = insertIconDataUri.isEmpty() ? "" + : """ + """.formatted(insertIconDataUri); + String buttons = """ +
\ + \ + \ +
""".formatted(copyImg, insertImg); + return html.replace("", buttons + ""); + } + + /** Escapes special HTML characters in the given text. Returns empty string for null. */ + public static String escapeHtml(String text) { + if (text == null) { + return ""; + } + return text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/IConversationWidget.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/IConversationWidget.java new file mode 100644 index 00000000..acbac96a --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/IConversationWidget.java @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import java.util.concurrent.CompletableFuture; + +import org.eclipse.swt.custom.StyledText; +import org.eclipse.swt.widgets.Control; + +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatProgressValue; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolConfirmationResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.codingagent.CodingAgentMessageRequestParams; +import com.microsoft.copilot.eclipse.core.persistence.AbstractTurnData; +import com.microsoft.copilot.eclipse.core.persistence.ConversationDataFactory; + +/** + * Abstraction for the conversation content area in the chat view. Implementations render chat turns + * either via ({@link StyledTextConversationWidget}) using {@link StyledText} + * or via ({@link BrowserConversationWidget}) using an Eclipse-internal web browser rendering HTML code. + */ +public interface IConversationWidget { + + /** + * Returns the underlying SWT control for layout purposes. + */ + Control getControl(); + + /** + * Returns whether this widget has been disposed. + */ + boolean isDisposed(); + + /** + * Disposes this widget and releases all resources. + */ + void dispose(); + + /** + * Requests a layout pass on the underlying control. + */ + void requestLayout(); + + /** + * Sets the conversation ID for this widget. + */ + void setConversationId(String conversationId); + + /** + * Begins a new user / copilot turn for the given turn ID. + * + * @param turnId the unique ID of the turn + * @param isCopilot true if this is a Copilot turn, false for user turns + * @param isHistory true if the turn is being restored from history + */ + void beginTurn(String turnId, boolean isCopilot, boolean isHistory); + + /** + * Processes a chat progress event (report or end phase). The widget updates the turn content + * accordingly. + */ + void processTurnEvent(ChatProgressValue value); + + /** + * Creates a new user turn entry in the conversation. + * + * @param turnId the turn ID + * @param message the user's message text + */ + void startNewUserTurn(String turnId, String message); + + /** + * Scrolls the conversation content to the bottom. + */ + void scrollToBottom(); + + /** + * Refreshes the internal scroller layout (e.g., after content changes). + */ + void refreshScrollerLayout(); + + /** + * Renders an error message in the conversation area. + */ + void renderErrorMessage(String content); + + /** + * Shows a "compacting" status indicator on the latest Copilot turn. + */ + void showCompactingStatusOnLatestCopilotTurn(); + + /** + * Hides the "compacting" status indicator on the latest Copilot turn. + */ + void hideCompactingStatusOnLatestCopilotTurn(); + + /** + * Returns the active thinking block ID for the given turn, or null if none. + */ + String getActiveThinkingBlockId(String turnId); + + /** + * Restores a single turn from persisted conversation data. Implementations render user turns, + * copilot turns (with thinking blocks, tool calls, errors, agent messages), and model info + * footers. + * + * @param turn the turn data to restore (either {@code UserTurnData} or {@code CopilotTurnData}) + * @param dataFactory factory for converting persisted data to runtime objects + */ + void restoreTurn(AbstractTurnData turn, ConversationDataFactory dataFactory); + + /** + * Renders model info footer below a copilot turn (model name, billing multiplier, reasoning + * effort). + * + * @param turnId the turn ID to attach the footer to + * @param modelName the model name to display + * @param billingMultiplier the billing multiplier (0 means not shown) + * @param reasoningEffort the reasoning effort level (may be null) + */ + void renderModelInfo(String turnId, String modelName, double billingMultiplier, + String reasoningEffort); + + /** + * Renders a coding agent message (e.g., PR link) in the specified turn. + * + * @param params the agent message parameters containing turn ID, title, description, and link + */ + void renderAgentMessage(CodingAgentMessageRequestParams params); + + /** + * Requests tool execution confirmation from the user. Implementations render a confirmation UI + * (inline HTML in browser view, or SWT dialog in SWT view) and return a future that completes + * when the user accepts or dismisses. + * + * @param turnId the turn ID where the confirmation should appear + * @param content confirmation content with title, message, and action buttons + * @param input tool input (may contain "command", "explanation", "action" keys) + * @return future that completes with the user's confirmation result + */ + CompletableFuture requestToolConfirmation( + String turnId, ConfirmationContent content, Object input); + + /** + * Cancels any pending tool confirmation for the given turn. Completes the pending future with + * DISMISS and removes the confirmation UI. + * + * @param turnId the turn ID whose confirmation should be cancelled + */ + void cancelToolConfirmation(String turnId); + + /** + * Returns the selected {@link ConfirmationAction} from the last completed confirmation, or null + * if the user dismissed or no confirmation was shown. Used by the caller to cache decisions. + */ + ConfirmationAction getLastSelectedConfirmationAction(); +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/QuotaActions.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/QuotaActions.java index 5c3a0137..35bc75a3 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/QuotaActions.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/QuotaActions.java @@ -7,6 +7,7 @@ import org.apache.commons.lang3.StringUtils; +import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CheckQuotaResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CopilotPlan; import com.microsoft.copilot.eclipse.ui.UiConstants; import com.microsoft.copilot.eclipse.ui.i18n.Messages; @@ -34,6 +35,51 @@ public record QuotaAction(String label, String tooltip, String url, boolean prim private QuotaActions() { } + /** + * The plan inputs needed to build quota actions, extracted from a {@link CheckQuotaResult}. Shared + * by the browser renderer and the StyledText {@link WarnWidget} path so the {@code CheckQuotaResult} + * decomposition lives in one place. + * + * @param plan the user's Copilot plan, or {@code null} when unknown + * @param overageEnabled {@code true} when additional paid usage is already enabled for the user + * @param canUpgradePlan whether the user can upgrade their plan, or {@code null} when the language + * server did not supply this field + */ + public record QuotaPlanContext(CopilotPlan plan, boolean overageEnabled, Boolean canUpgradePlan) { + + /** Extracts the plan inputs from a language-server {@link CheckQuotaResult}. */ + public static QuotaPlanContext from(CheckQuotaResult quotaStatus) { + boolean overageEnabled = quotaStatus.premiumInteractions() != null + && quotaStatus.premiumInteractions().overagePermitted(); + return new QuotaPlanContext(quotaStatus.copilotPlan(), overageEnabled, + quotaStatus.canUpgradePlan()); + } + } + + /** + * Resolves the {@link QuotaAction}s for a quota-exceeded error given the current quota status. + * Returns an empty list for non-{@code 402} errors, BYOK quota errors, when token-based billing is + * not enabled, or when {@code quotaStatus} is {@code null}. Otherwise delegates to + * {@link #forPlan(CopilotPlan, boolean, Boolean)} with the inputs from + * {@link QuotaPlanContext#from(CheckQuotaResult)}. + * + * @param quotaStatus the current quota status, or {@code null} + * @param code the language-server error code + * @param modelProviderName the BYOK model-provider name, or {@code null} + * @return an immutable, possibly empty list; never {@code null} + */ + public static List forQuotaStatus(CheckQuotaResult quotaStatus, int code, + String modelProviderName) { + if (code != 402 || isByokQuotaExceeded(code, modelProviderName)) { + return List.of(); + } + if (quotaStatus == null || !quotaStatus.tokenBasedBillingEnabled()) { + return List.of(); + } + QuotaPlanContext context = QuotaPlanContext.from(quotaStatus); + return forPlan(context.plan(), context.overageEnabled(), context.canUpgradePlan()); + } + /** * Returns the ordered list of {@link QuotaAction}s appropriate for the supplied plan. * diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/StyledTextConversationWidget.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/StyledTextConversationWidget.java new file mode 100644 index 00000000..394fd4c3 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/StyledTextConversationWidget.java @@ -0,0 +1,307 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import java.util.concurrent.CompletableFuture; + +import org.apache.commons.lang3.StringUtils; +import org.eclipse.swt.SWT; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; + +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentToolCall; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatProgressValue; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolConfirmationResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolConfirmationResult.ToolConfirmationResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.codingagent.CodingAgentMessageRequestParams; +import com.microsoft.copilot.eclipse.core.persistence.AbstractTurnData; +import com.microsoft.copilot.eclipse.core.persistence.ConversationDataFactory; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.AgentMessageData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.EditAgentRoundData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ErrorData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ErrorMessageData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ReplyData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ToolCallData; +import com.microsoft.copilot.eclipse.core.persistence.UserTurnData; +import com.microsoft.copilot.eclipse.ui.UiConstants; +import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; +import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; + +/** + * {@link IConversationWidget} implementation backed by the existing SWT {@link ChatContentViewer} + * using {@link StyledText} to render the content. + * This is a thin adapter that delegates all calls to the underlying viewer. + */ +public class StyledTextConversationWidget implements IConversationWidget { + + private final ChatContentViewer viewer; + + /** + * Creates a new {@link StyledTextConversationWidget} that internally creates a {@link ChatContentViewer}. + * + * @param parent the parent composite + * @param serviceManager the chat service manager + */ + public StyledTextConversationWidget(Composite parent, ChatServiceManager serviceManager) { + this.viewer = new ChatContentViewer(parent, SWT.NONE, serviceManager); + } + + /** + * Returns the underlying {@link ChatContentViewer} for SWT-specific operations that are not part + * of the {@link IConversationWidget} interface (e.g., {@code getTurnWidget()}). + */ + public ChatContentViewer getChatContentViewer() { + return viewer; + } + + @Override + public Control getControl() { + return viewer; + } + + @Override + public boolean isDisposed() { + return viewer.isDisposed(); + } + + @Override + public void dispose() { + viewer.dispose(); + } + + @Override + public void requestLayout() { + viewer.requestLayout(); + } + + @Override + public void setConversationId(String conversationId) { + viewer.setConversationId(conversationId); + } + + @Override + public void beginTurn(String turnId, boolean isCopilot, boolean isHistory) { + viewer.getLatestOrCreateNewTurnWidget(turnId, isCopilot, isHistory); + } + + @Override + public void processTurnEvent(ChatProgressValue value) { + viewer.processTurnEvent(value); + } + + @Override + public void startNewUserTurn(String turnId, String message) { + viewer.startNewTurn(turnId, message); + } + + @Override + public void scrollToBottom() { + viewer.getDisplay().asyncExec(() -> { + if (viewer.isDisposed()) { + return; + } + viewer.refreshLayoutFull(); + viewer.scrollToBottomIfAutoScroll(); + }); + } + + @Override + public void refreshScrollerLayout() { + viewer.refreshLayoutFull(); + } + + @Override + public void renderErrorMessage(String content) { + viewer.renderErrorMessage(content); + } + + @Override + public void showCompactingStatusOnLatestCopilotTurn() { + viewer.showCompactingStatusOnLatestCopilotTurn(); + } + + @Override + public void hideCompactingStatusOnLatestCopilotTurn() { + viewer.hideCompactingStatusOnLatestCopilotTurn(); + } + + @Override + public String getActiveThinkingBlockId(String turnId) { + return viewer.getActiveThinkingBlockId(turnId); + } + + @Override + public void restoreTurn(AbstractTurnData turn, ConversationDataFactory dataFactory) { + if (turn == null) { + return; + } + + // Subagent turns: render inside parent turn's subagent block + if (turn instanceof CopilotTurnData copilotTurn + && StringUtils.isNotBlank(copilotTurn.getParentTurnId())) { + BaseTurnWidget parentWidget = viewer.getTurnWidget(copilotTurn.getParentTurnId()); + if (parentWidget != null) { + String toolCallId = copilotTurn.getSubagentToolCallId(); + if (StringUtils.isNotBlank(toolCallId)) { + parentWidget.restoreSubagentContent(toolCallId, copilotTurn, dataFactory); + } else { + restoreCopilotTurnContent(copilotTurn, parentWidget, dataFactory); + } + } + return; + } + + // User turn + if (turn instanceof UserTurnData userTurn) { + if (userTurn.getMessage() == null + || StringUtils.isNotBlank(userTurn.getMessage().getText())) { + BaseTurnWidget userTurnWidget = + viewer.getLatestOrCreateNewTurnWidget(turn.getTurnId(), false, true); + userTurnWidget.appendMessage(userTurn.getMessage().getText()); + userTurnWidget.flushMessageBuffer(); + } + return; + } + + // Copilot turn + if (turn instanceof CopilotTurnData copilotTurn) { + BaseTurnWidget copilotTurnWidget = + viewer.getLatestOrCreateNewTurnWidget(turn.getTurnId(), true, true); + restoreCopilotTurnContent(copilotTurn, copilotTurnWidget, dataFactory); + copilotTurnWidget.flushMessageBuffer(); + + // Restore model info footer + ReplyData replyData = copilotTurn.getReply(); + if (replyData != null && StringUtils.isNotBlank(replyData.getModelName())) { + renderModelInfo(turn.getTurnId(), replyData.getModelName(), + replyData.getBillingMultiplier(), replyData.getReasoningEffort()); + } + } + } + + @Override + public void renderModelInfo(String turnId, String modelName, double billingMultiplier, + String reasoningEffort) { + if (viewer.isDisposed()) { + return; + } + BaseTurnWidget turnWidget = viewer.getTurnWidget(turnId); + if (turnWidget instanceof CopilotTurnWidget copilotWidget) { + copilotWidget.renderModelInfo(modelName, billingMultiplier, reasoningEffort); + SwtUtils.invokeOnDisplayThreadAsync(viewer::refreshLayoutFull, viewer); + } + } + + @Override + public void renderAgentMessage(CodingAgentMessageRequestParams params) { + if (viewer.isDisposed() || params == null) { + return; + } + SwtUtils.invokeOnDisplayThread(() -> { + BaseTurnWidget turnWidget = viewer.getTurnWidget(params.getTurnId()); + if (turnWidget != null && !turnWidget.isDisposed()) { + turnWidget.createAgentMessageWidget(params); + } + }, viewer); + } + + @Override + public CompletableFuture requestToolConfirmation( + String turnId, ConfirmationContent content, Object input) { + BaseTurnWidget turnWidget = viewer.getTurnWidget(turnId); + if (turnWidget == null) { + return CompletableFuture.completedFuture( + new LanguageModelToolConfirmationResult(ToolConfirmationResult.DISMISS)); + } + BaseTurnWidget activeTurnWidget = turnWidget.getActiveTurnWidget(); + CompletableFuture future = + activeTurnWidget.requestToolExecutionConfirmation(content, input); + viewer.refreshLayoutFull(); + return future; + } + + @Override + public void cancelToolConfirmation(String turnId) { + BaseTurnWidget turnWidget = viewer.getTurnWidget(turnId); + if (turnWidget != null) { + turnWidget.getActiveTurnWidget().cancelToolConfirmation(); + } + } + + @Override + public ConfirmationAction getLastSelectedConfirmationAction() { + // In the SWT implementation, the selected action is retrieved from the dialog directly + // by AgentToolService. Return null here; SWT path uses dialog.getSelectedAction(). + return null; + } + + private void restoreCopilotTurnContent(CopilotTurnData copilotTurn, BaseTurnWidget turnWidget, + ConversationDataFactory dataFactory) { + ReplyData replyData = copilotTurn.getReply(); + if (replyData == null) { + return; + } + + ThinkingTurnWidget thinkingWidget = + turnWidget instanceof ThinkingTurnWidget ? (ThinkingTurnWidget) turnWidget : null; + + if (StringUtils.isNotBlank(replyData.getText())) { + turnWidget.appendMessage(replyData.getText()); + } + + if (replyData.getEditAgentRounds() != null && !replyData.getEditAgentRounds().isEmpty()) { + for (EditAgentRoundData round : replyData.getEditAgentRounds()) { + if (thinkingWidget != null && round.getThinkingBlock() != null) { + thinkingWidget.restoreThinkingBlock(round.getThinkingBlock()); + } + if (round.getReply() != null && !round.getReply().isEmpty()) { + turnWidget.appendMessage(round.getReply()); + } + if (round.getToolCalls() != null && !round.getToolCalls().isEmpty()) { + for (ToolCallData toolCallData : round.getToolCalls()) { + AgentToolCall agentToolCall = + dataFactory.convertToolCallDataToAgentToolCall(toolCallData); + turnWidget.appendToolCallStatus(agentToolCall); + } + } + } + } + + // Flush buffered text before creating error/agent widgets so that reply text + // always appears above them in the layout. + turnWidget.flushMessageBuffer(); + + if (replyData.getErrorMessages() != null && !replyData.getErrorMessages().isEmpty()) { + for (ErrorMessageData errorMessageData : replyData.getErrorMessages()) { + ErrorData errorData = errorMessageData.getError(); + SwtUtils.invokeOnDisplayThread(() -> { + String errorMessage = errorData != null + ? errorData.getMessage() : Messages.chat_warnWidget_defaultErrorMsg; + int errorCode = errorData != null ? errorData.getCode() : 0; + String modelProviderName = errorData != null ? errorData.getModelProviderName() : null; + turnWidget.createWarnDialog(errorMessage, errorCode, modelProviderName); + }, viewer); + } + } + + if (replyData.getAgentMessages() != null && !replyData.getAgentMessages().isEmpty()) { + for (AgentMessageData agentMessageData : replyData.getAgentMessages()) { + if (StringUtils.equals(agentMessageData.getAgentSlug(), + UiConstants.GITHUB_COPILOT_CODING_AGENT_SLUG)) { + SwtUtils.invokeOnDisplayThread(() -> { + CodingAgentMessageRequestParams params = new CodingAgentMessageRequestParams(); + params.setTitle(agentMessageData.getTitle()); + params.setDescription(agentMessageData.getDescription()); + params.setPrLink(agentMessageData.getPrLink()); + params.setTurnId(copilotTurn.getTurnId()); + turnWidget.createAgentMessageWidget(params); + }, viewer); + } + } + } + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/SvgIcons.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/SvgIcons.java new file mode 100644 index 00000000..9820f1ab --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/SvgIcons.java @@ -0,0 +1,235 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.osgi.framework.Bundle; + +import com.microsoft.copilot.eclipse.core.utils.BundleUtils; +import com.microsoft.copilot.eclipse.ui.CopilotUi; + +/** + * Registry of inline SVG icons used by the browser-based chat renderer. + * + *

Each icon lives as a standalone {@code .svg} file under {@code resources/html/icons/} + * containing only the icon geometry (path/line elements and a {@code viewBox}). At load time + * this registry injects the presentation attributes the chat DOM requires (CSS {@code class}, + * sizing, {@code currentColor} theming, inline {@code style}) onto the root {@code } + * element. Because those attributes are enforced by code rather than stored in the file, an + * icon file can be replaced (e.g. with a fresh export) without risk of losing the CSS class or + * theming the chat view depends on. + * + *

The resulting markup is inlined directly into the browser DOM (not referenced via an + * {@code } element), which is required for {@code currentColor} to adapt to the + * light/dark theme. + */ +public final class SvgIcons { + + /** The available chat icons, each mapped to its file name and enforced root attributes. */ + public enum Icon { + /** Lightbulb icon for sealed (completed) thinking blocks. */ + THINKING_BULB("thinking-bulb.svg", attrs( + "class", "thinking-icon", + "width", "14", + "height", "14", + "fill", "none", + "stroke", "currentColor", + "stroke-width", "1.3", + "stroke-linecap", "round", + "stroke-linejoin", "round")), + /** Pull request icon for coding-agent message blocks. */ + PULL_REQUEST("pull-request.svg", attrs( + "width", "14", + "height", "14", + "fill", "currentColor", + "style", "vertical-align: middle; margin-right: 4px;")), + /** Terminal/command icon for tool confirmation blocks. */ + TERMINAL("terminal.svg", attrs( + "width", "14", + "height", "14", + "fill", "currentColor", + "style", "vertical-align: middle; margin-right: 4px;")), + /** Warning triangle icon for quota warning and error blocks. */ + WARNING("warning.svg", attrs( + "width", "14", + "height", "14", + "fill", "currentColor", + "style", "vertical-align: middle; margin-right: 4px; flex-shrink: 0;")); + + private final String fileName; + private final Map enforcedAttributes; + + Icon(String fileName, Map enforcedAttributes) { + this.fileName = fileName; + this.enforcedAttributes = enforcedAttributes; + } + } + + private static final String ICON_RESOURCE_DIR = "resources/html/icons/"; + + private static final Pattern ATTRIBUTE_PATTERN = + Pattern.compile("([\\w:-]+)\\s*=\\s*\"([^\"]*)\""); + + private static volatile Map cache; + + private SvgIcons() { + } + + /** + * Returns the ready-to-inline SVG markup for the given icon, with all required presentation + * attributes applied. Icons are loaded and processed once, then cached. + * + * @param icon the icon to render + * @return the inline SVG markup, or an empty string if the icon file cannot be read + */ + public static String get(Icon icon) { + Map local = cache; + if (local == null) { + synchronized (SvgIcons.class) { + local = cache; + if (local == null) { + local = loadAll(CopilotUi.getPlugin().getBundle()); + cache = local; + } + } + } + return local.getOrDefault(icon, ""); + } + + private static Map loadAll(Bundle bundle) { + Map map = new EnumMap<>(Icon.class); + for (Icon icon : Icon.values()) { + String raw = BundleUtils.readResourceAsString(bundle, ICON_RESOURCE_DIR + icon.fileName); + map.put(icon, raw == null ? "" : applyRootAttributes(raw, icon.enforcedAttributes)); + } + return map; + } + + /** + * Applies (adds or overrides) the given attributes on the root {@code } element of the + * supplied markup, dropping any {@code xmlns} declarations so the result is lean inline HTML. + * Attributes present in the source but not listed are preserved (notably {@code viewBox}). + * + *

Attributes present in the source but not listed are preserved (notably {@code viewBox}). The + * {@code style} attribute is treated specially: rather than replacing it wholesale, its CSS + * sub-properties are merged so enforced declarations are added or overridden while unrelated + * existing declarations are kept. + * + *

Visible for unit testing. + * + * @param svg the raw SVG markup + * @param enforcedAttributes the attributes to enforce on the root element + * @return the SVG markup with the enforced attributes applied, or the input unchanged if it + * does not contain a recognizable root {@code } element + */ + public static String applyRootAttributes(String svg, Map enforcedAttributes) { + if (svg == null) { + return ""; + } + String trimmed = stripXmlProlog(svg.trim()); + int start = trimmed.indexOf("', start); + if (tagEnd < 0) { + return trimmed; + } + String openTag = trimmed.substring(start, tagEnd); // "' + + Map attributes = parseAttributes(openTag); + attributes.keySet().removeIf(name -> name.equals("xmlns") || name.startsWith("xmlns:")); + for (Map.Entry enforced : enforcedAttributes.entrySet()) { + String name = enforced.getKey(); + String value = enforced.getValue(); + if ("style".equals(name) && attributes.containsKey("style")) { + value = mergeStyle(attributes.get("style"), value); + } + attributes.put(name, value); + } + + StringBuilder rebuilt = new StringBuilder(" entry : attributes.entrySet()) { + rebuilt.append(' ').append(entry.getKey()) + .append("=\"").append(entry.getValue()).append('"'); + } + String rest = trimmed.substring(tagEnd + 1); // inner content plus "" + rebuilt.append('>').append(rest); + return rebuilt.toString(); + } + + private static Map parseAttributes(String openTag) { + Map attributes = new LinkedHashMap<>(); + Matcher matcher = ATTRIBUTE_PATTERN.matcher(openTag); + while (matcher.find()) { + attributes.put(matcher.group(1), matcher.group(2)); + } + return attributes; + } + + /** + * Merges the {@code enforced} CSS declarations into the {@code existing} ones. Declarations from + * {@code existing} are kept unless {@code enforced} overrides them by property name; declarations + * present only in {@code enforced} are appended. This ensures enforcing a {@code style} attribute + * never drops unrelated sub-properties already declared on the element. + * + * @param existing the current value of the {@code style} attribute + * @param enforced the {@code style} declarations to add or override + * @return the merged {@code style} value + */ + private static String mergeStyle(String existing, String enforced) { + Map declarations = parseStyle(existing); + declarations.putAll(parseStyle(enforced)); + StringBuilder merged = new StringBuilder(); + for (Map.Entry entry : declarations.entrySet()) { + if (merged.length() > 0) { + merged.append(' '); + } + merged.append(entry.getKey()).append(": ").append(entry.getValue()).append(';'); + } + return merged.toString(); + } + + private static Map parseStyle(String style) { + Map declarations = new LinkedHashMap<>(); + if (style == null) { + return declarations; + } + for (String declaration : style.split(";")) { + int colon = declaration.indexOf(':'); + if (colon < 0) { + continue; + } + String property = declaration.substring(0, colon).trim(); + String value = declaration.substring(colon + 1).trim(); + if (!property.isEmpty()) { + declarations.put(property, value); + } + } + return declarations; + } + + private static String stripXmlProlog(String svg) { + if (svg.startsWith(""); + if (end >= 0) { + return svg.substring(end + 2).trim(); + } + } + return svg; + } + + private static Map attrs(String... pairs) { + Map map = new LinkedHashMap<>(); + for (int i = 0; i + 1 < pairs.length; i += 2) { + map.put(pairs[i], pairs[i + 1]); + } + return map; + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingBlock.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingBlock.java index e273986f..22cf827b 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingBlock.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingBlock.java @@ -8,7 +8,6 @@ import java.util.Objects; import java.util.UUID; import java.util.regex.Matcher; -import java.util.regex.Pattern; import org.apache.commons.lang3.StringUtils; import org.eclipse.e4.ui.services.IStylingEngine; @@ -38,8 +37,6 @@ */ public class ThinkingBlock extends Composite { private static final String SECONDARY_TEXT_CSS_CLASS = "text-secondary"; - private static final Pattern TITLE_PATTERN = - Pattern.compile("(?:^|\\n)\\*\\*([^*\\r\\n]+?)\\*\\*(?=\\r?\\n|$)"); private static final int STREAMING_MAX_HEIGHT = 180; @@ -357,7 +354,7 @@ private static List parseSections(String raw) { if (raw == null || raw.isEmpty()) { return result; } - Matcher matcher = TITLE_PATTERN.matcher(raw); + Matcher matcher = ThinkingTitles.TITLE_PATTERN.matcher(raw); String currentTitle = null; int cursor = 0; while (matcher.find()) { diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingTitles.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingTitles.java new file mode 100644 index 00000000..1707d5f3 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingTitles.java @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.GenerateThinkingTitleParams; +import com.microsoft.copilot.eclipse.core.persistence.ConversationPersistenceManager; + +/** + * Shared helpers for the "thinking" title feature used by both the browser renderer + * ({@link BrowserConversationWidget}) and the StyledText renderer ({@link ThinkingBlock} / + * {@link ThinkingTurnWidget}). Centralizes the standalone-bold-line title pattern, title + * extraction, the {@link GenerateThinkingTitleParams} construction rule, and title persistence so + * the two renderers cannot drift. + */ +public final class ThinkingTitles { + + /** + * Matches a standalone {@code **Title**} line in thinking text: a bold span that occupies its own + * line (start-of-text or after a newline, terminated by a newline or end-of-text). Group 1 is the + * title text. + */ + public static final Pattern TITLE_PATTERN = + Pattern.compile("(?:^|\\n)\\*\\*([^*\\r\\n]+?)\\*\\*(?=\\r?\\n|$)"); + + private ThinkingTitles() { + } + + /** + * Extracts the non-blank {@code **Title**} strings from {@code text} in document order. + * + * @param text the thinking text to scan; may be {@code null} + * @return the trimmed, non-empty titles; never {@code null} + */ + public static String[] extractTitles(String text) { + List titles = new ArrayList<>(); + if (text != null) { + Matcher matcher = TITLE_PATTERN.matcher(text); + while (matcher.find()) { + String title = matcher.group(1).trim(); + if (!title.isEmpty()) { + titles.add(title); + } + } + } + return titles.toArray(String[]::new); + } + + /** + * Builds the {@link GenerateThinkingTitleParams} for a title-generation request. The server + * schema rejects null entries inside {@code extractedTitles}, so exactly one field is populated: + * the extracted titles when present, otherwise the raw content. + * + * @param content the accumulated thinking content + * @param titles the titles extracted from {@code content}; may be {@code null} or empty + * @return params carrying either the titles or the content, never both + */ + public static GenerateThinkingTitleParams buildTitleParams(String content, String[] titles) { + boolean hasTitles = titles != null && titles.length > 0; + return new GenerateThinkingTitleParams(hasTitles ? null : content, hasTitles ? titles : null); + } + + /** + * Persists a generated thinking-block {@code title}. No-op when {@code persistenceManager} or + * {@code conversationId} is {@code null}. + * + * @param persistenceManager the persistence manager, or {@code null} + * @param conversationId the conversation id, or {@code null} + * @param turnId the turn id + * @param thinkingBlockId the thinking block id + * @param title the generated title + */ + public static void persistTitle(ConversationPersistenceManager persistenceManager, + String conversationId, String turnId, String thinkingBlockId, String title) { + if (persistenceManager == null || conversationId == null) { + return; + } + persistenceManager.updateThinkingBlockTitle(conversationId, turnId, thinkingBlockId, title); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingTurnWidget.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingTurnWidget.java index 87505e23..3ace6d19 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingTurnWidget.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingTurnWidget.java @@ -115,10 +115,7 @@ public void sealThinking() { return; } String[] titles = target.getExtractedTitles(); - // Server schema rejects null entries inside extractedTitles, so we send one of the two fields, never both. - boolean hasTitles = titles.length > 0; - GenerateThinkingTitleParams params = new GenerateThinkingTitleParams(hasTitles ? null : content, - hasTitles ? titles : null); + GenerateThinkingTitleParams params = ThinkingTitles.buildTitleParams(content, titles); String thinkingBlockId = target.getThinkingId(); ls.generateThinkingTitle(params) .thenAccept(resp -> SwtUtils.invokeOnDisplayThread(() -> { @@ -182,11 +179,11 @@ private void cancelThinkingBlock(String cancelTurnId, String thinkingBlockId) { } private void persistThinkingTitle(String conversationId, String persistTurnId, String thinkingBlockId, String title) { - if (conversationId == null || serviceManager == null) { + if (serviceManager == null) { return; } - serviceManager.getPersistenceManager() - .updateThinkingBlockTitle(conversationId, persistTurnId, thinkingBlockId, title); + ThinkingTitles.persistTitle(serviceManager.getPersistenceManager(), conversationId, persistTurnId, + thinkingBlockId, title); } /** diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/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..06e8fba0 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,108 @@ 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 or when the IDE is + * started without theming support — there is no reliable theme id to inspect, so the method falls + * back to sampling the actual widget background color 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; + String activeThemeId = getActiveThemeIdIfThemeSupportIsAvailable(); + if (activeThemeId != null) { + return activeThemeId.toLowerCase().contains("dark"); } - return false; + // No e4 CSS theme id available (theming unavailable/disabled): the persisted themeid preference + // is unreliable here because it can still hold a "...dark" value from a previous themed session + // while the IDE actually renders in its native light appearance. Sample the real background + // color instead so the chat renderer matches what the surrounding UI truly looks like. + return isBackgroundDark(); + } + + /** + * Reflectively resolves the id of the active e4 CSS theme, if e4 CSS theming support is available. + * + *

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, no active theme, or a linkage/reflection error) is treated as "cannot determine". + * + * @return the active theme id, or {@code null} when e4 CSS theming is unavailable so the caller + * can fall back to another detection strategy + */ + private static String getActiveThemeIdIfThemeSupportIsAvailable() { + Bundle themeBundle = Platform.getBundle("org.eclipse.e4.ui.css.swt.theme"); + if (themeBundle == null) { + return null; + } + try { + Class themeEngineClass = themeBundle.loadClass("org.eclipse.e4.ui.css.swt.theme.IThemeEngine"); + Object themeEngine = PlatformUI.getWorkbench().getService(themeEngineClass); + if (themeEngine == null) { + return null; + } + Object activeTheme = themeEngineClass.getMethod("getActiveTheme").invoke(themeEngine); + if (activeTheme == null) { + return null; + } + Class themeClass = themeBundle.loadClass("org.eclipse.e4.ui.css.swt.theme.ITheme"); + Object themeId = themeClass.getMethod("getId").invoke(activeTheme); + return themeId == null ? null : themeId.toString(); + } catch (ReflectiveOperationException | RuntimeException | LinkageError e) { + return null; + } + } + + /** + * Fallback theme detection that samples the actual widget background color and decides whether it + * is dark. This needs no theming bundle and reflects the real appearance of the surrounding UI, + * which is exactly what the chat renderer must blend into. + * + *

{@link Display#getSystemColor(int)} must run on the UI thread, so this prefers + * {@link Display#getCurrent()} (non-null only on a UI thread) and otherwise marshals the read onto + * the UI thread via {@link Display#getDefault()}. Any inability to resolve a color (no/disposed + * display) degrades to light. + * + * @return true if the widget background is dark, false otherwise (including when undetermined) + */ + private static boolean isBackgroundDark() { + Display current = Display.getCurrent(); + if (current != null && !current.isDisposed()) { + return isDark(current.getSystemColor(SWT.COLOR_WIDGET_BACKGROUND).getRGB()); + } + Display display = Display.getDefault(); + if (display == null || display.isDisposed()) { + return false; + } + RGB[] holder = new RGB[1]; + display.syncExec(() -> { + if (!display.isDisposed()) { + holder[0] = display.getSystemColor(SWT.COLOR_WIDGET_BACKGROUND).getRGB(); + } + }); + return holder[0] != null && isDark(holder[0]); + } + + /** + * Decides whether a color is dark based on its perceived brightness (luma). + * + *

Uses the ITU-R BT.601 luma coefficients (0.299 R, 0.587 G, 0.114 B). They are weighted by + * the eye's relative sensitivity to each primary (most to green, least to blue) and sum to 1.0, + * so the result stays in the 0–255 range; values below the mid-point (128) are treated as dark. + * See ITU-R Recommendation BT.601. + * + *

Package/public visibility is intentional so the pure luma decision can be unit-tested without + * an SWT {@link Display}. + * + * @param rgb the color to evaluate + * @return true if the color is dark, false otherwise + */ + public static boolean isDark(RGB rgb) { + double luminance = 0.299 * rgb.red + 0.587 * rgb.green + 0.114 * rgb.blue; + return luminance < 128; } /** diff --git a/launch/Build and test Copilot for Eclipse.launch b/launch/Build and test Copilot for Eclipse.launch new file mode 100644 index 00000000..837045cf --- /dev/null +++ b/launch/Build and test Copilot for Eclipse.launch @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/launch/Run Checkstyle in Copilot for Eclipse.launch b/launch/Run Checkstyle in Copilot for Eclipse.launch new file mode 100644 index 00000000..30546b16 --- /dev/null +++ b/launch/Run Checkstyle in Copilot for Eclipse.launch @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + +