From d0b1bbf62a077562c29d5e299b4088680c2d1853 Mon Sep 17 00:00:00 2001 From: Dietrich Travkin Date: Mon, 29 Jun 2026 17:52:05 +0200 Subject: [PATCH 1/9] Fix #64: Add browser-based chat view widget for rendering conversations - Introduce SWT-browser-based widget for rendering conversations in copilot chat view - Introduce common interface for the existing StyledText-based conversation rendering and the new browser-based conversation rendering - Introduce commonmark Markdown parser and HTML renderer with extensions to properly render GitHub-Flavoured Markdown tables (and task item lists), i.e. fix issue #64 - Introduce preference for switching between StyledText-based SWT rendering and browser-based HTML rendering Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- base.target | 13 + .../copilot/eclipse/core/Constants.java | 1 + .../eclipse/core/utils/BundleUtils.java | 83 ++ .../META-INF/MANIFEST.MF | 8 +- .../build.properties | 3 +- .../resources/html/chat-view.html | 247 ++++ .../resources/html/css/chat-browser-base.css | 427 +++++++ .../resources/html/css/chat-browser-dark.css | 47 + .../resources/html/css/chat-browser-light.css | 47 + .../resources/html/icons/pull-request.svg | 1 + .../resources/html/icons/terminal.svg | 1 + .../resources/html/icons/thinking-bulb.svg | 1 + .../resources/html/icons/warning.svg | 1 + .../eclipse/ui/chat/BaseTurnWidget.java | 11 +- .../chat/BrowserConversationJavaJsBridge.java | 344 ++++++ .../ui/chat/BrowserConversationWidget.java | 1089 +++++++++++++++++ .../eclipse/ui/chat/ChatContentViewer.java | 59 +- .../eclipse/ui/chat/ChatErrorMessages.java | 54 + .../copilot/eclipse/ui/chat/ChatView.java | 364 +++--- .../ui/chat/ConversationHtmlBlockFactory.java | 557 +++++++++ .../eclipse/ui/chat/IConversationWidget.java | 159 +++ .../copilot/eclipse/ui/chat/QuotaActions.java | 46 + .../ui/chat/StyledTextConversationWidget.java | 307 +++++ .../copilot/eclipse/ui/chat/SvgIcons.java | 235 ++++ .../eclipse/ui/chat/ThinkingBlock.java | 5 +- .../eclipse/ui/chat/ThinkingTitles.java | 85 ++ .../eclipse/ui/chat/ThinkingTurnWidget.java | 11 +- .../ui/chat/services/AgentToolService.java | 48 +- .../ui/preferences/ChatPreferencesPage.java | 11 + .../CopilotPreferenceInitializer.java | 1 + .../eclipse/ui/preferences/Messages.java | 2 + .../ui/preferences/messages.properties | 2 + 32 files changed, 3979 insertions(+), 291 deletions(-) create mode 100644 com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/utils/BundleUtils.java create mode 100644 com.microsoft.copilot.eclipse.ui/resources/html/chat-view.html create mode 100644 com.microsoft.copilot.eclipse.ui/resources/html/css/chat-browser-base.css create mode 100644 com.microsoft.copilot.eclipse.ui/resources/html/css/chat-browser-dark.css create mode 100644 com.microsoft.copilot.eclipse.ui/resources/html/css/chat-browser-light.css create mode 100644 com.microsoft.copilot.eclipse.ui/resources/html/icons/pull-request.svg create mode 100644 com.microsoft.copilot.eclipse.ui/resources/html/icons/terminal.svg create mode 100644 com.microsoft.copilot.eclipse.ui/resources/html/icons/thinking-bulb.svg create mode 100644 com.microsoft.copilot.eclipse.ui/resources/html/icons/warning.svg create mode 100644 com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationJavaJsBridge.java create mode 100644 com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidget.java create mode 100644 com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatErrorMessages.java create mode 100644 com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ConversationHtmlBlockFactory.java create mode 100644 com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/IConversationWidget.java create mode 100644 com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/StyledTextConversationWidget.java create mode 100644 com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/SvgIcons.java create mode 100644 com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ThinkingTitles.java diff --git a/base.target b/base.target index a6fc6705..689e43e8 100644 --- a/base.target +++ b/base.target @@ -15,6 +15,19 @@ + + + + + + + + + + + + + diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java index 176bc245..e613cbcc 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/Constants.java @@ -53,6 +53,7 @@ private Constants() { public static final String AUTO_BREAKPOINT_RESPONSE = "autoBreakpointResponse"; public static final String GITHUB_JOBS_VIEW_ID = "com.microsoft.copilot.eclipse.ui.jobs.JobsView"; public static final String SUPPRESS_TERMINAL_DEPENDENCY_DIALOG = "suppressTerminalDependencyDialog"; + public static final String USE_BROWSER_BASED_CHAT_RENDERER = "useBrowserBasedChatRenderer"; // Auto-Approve settings public static final String AUTO_APPROVE_TERMINAL_RULES = "autoApproveTerminalRules"; diff --git a/com.microsoft.copilot.eclipse.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.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/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. From 426f54d5b83952638065769071eb8049899825fc Mon Sep 17 00:00:00 2001 From: Dietrich Travkin Date: Mon, 29 Jun 2026 15:57:35 +0200 Subject: [PATCH 2/9] Fix UserPreferenceService.unbindChatView() thread safety Wrap the chatViewSideEffect disposal in ensureRealm() to guarantee it runs on the correct data binding Realm thread, preventing potential InvalidThreadAccessException when unbinding from a non-UI thread. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ui/chat/services/UserPreferenceService.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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; + } + }); } /** From 2ce636c56148dbb33ae7cde62ff64c106ada6b0c Mon Sep 17 00:00:00 2001 From: Dietrich Travkin Date: Tue, 7 Jul 2026 17:02:01 +0200 Subject: [PATCH 3/9] Fix dark theme detection using IThemeEngine API Replace preference-based theme detection with the proper IThemeEngine service API. The previous approach read the theme ID from preferences which could be stale or unavailable. The new approach queries the active theme directly via IThemeEngine.getActiveTheme(). We're using reflection in order not to introduce a dependency to the SWT themes bundle and to avoid discouraged access warnings. Read actual widget background color as fallback in case theme support is not available. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../copilot/eclipse/ui/utils/UiUtils.java | 107 ++++++++++++++++-- 1 file changed, 100 insertions(+), 7 deletions(-) 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; } /** From f498efdbdd9ac24dc201f6b15caeca508f0a0549 Mon Sep 17 00:00:00 2001 From: Dietrich Travkin Date: Mon, 22 Jun 2026 09:51:02 +0200 Subject: [PATCH 4/9] Fix some compile warnings --- com.microsoft.copilot.eclipse.terminal.api/build.properties | 1 - .../.settings/org.eclipse.jdt.core.prefs | 2 +- com.microsoft.copilot.eclipse.ui.jobs/build.properties | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) 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,\ From 998ff4f2c2c5ca978ff30201c6afb1c2bc0a1358 Mon Sep 17 00:00:00 2001 From: Dietrich Travkin Date: Thu, 2 Jul 2026 10:56:11 +0200 Subject: [PATCH 5/9] Add unit tests Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../eclipse/core/utils/BundleUtilsTests.java | 98 ++ .../BrowserConversationJavaJsBridgeTests.java | 107 +++ ...BrowserConversationWidgetBehaviorTest.java | 649 ++++++++++++++ .../chat/BrowserConversationWidgetTest.java | 60 ++ .../ui/chat/ChatErrorMessagesTest.java | 56 ++ .../ConversationHtmlBlockFactoryTest.java | 845 ++++++++++++++++++ .../copilot/eclipse/ui/chat/SvgIconsTest.java | 140 +++ 7 files changed, 1955 insertions(+) create mode 100644 com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/utils/BundleUtilsTests.java create mode 100644 com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationJavaJsBridgeTests.java create mode 100644 com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidgetBehaviorTest.java create mode 100644 com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidgetTest.java create mode 100644 com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ChatErrorMessagesTest.java create mode 100644 com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ConversationHtmlBlockFactoryTest.java create mode 100644 com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/SvgIconsTest.java 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.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/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; + } +} From 2ac7455b646719db0483de7db201273cc96ea884 Mon Sep 17 00:00:00 2001 From: Dietrich Travkin Date: Tue, 7 Jul 2026 13:14:12 +0200 Subject: [PATCH 6/9] Add UiUtilsThemeTest for reflection-based dark theme detection - Cover UiUtils.isDarkTheme() with mocked PlatformUI/Platform statics: WITH e4 CSS theming and WITHOUT theming - theming-unavailable detection samples the widget-background luminance. Add isDark(RGB) BT.601 luma tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../eclipse/ui/utils/UiUtilsThemeTest.java | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/UiUtilsThemeTest.java 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]); + } +} From b881534f6d42e81b3991394ae49ca6e5f94257bc Mon Sep 17 00:00:00 2001 From: Dietrich Travkin Date: Wed, 8 Jul 2026 10:50:47 +0200 Subject: [PATCH 7/9] Add side-by-side comparison via new Copilot menu entry - Add 'Compare Chat View Rendering Alternatives' Copilot menu entry - Opens both rendering alternatives in one dialog, side by side, for visual comparison Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../META-INF/MANIFEST.MF | 3 +- .../plugin.xml | 25 + ...versationRendererVisualDemoSideBySide.java | 606 ++++++++++++++++++ 3 files changed, 633 insertions(+), 1 deletion(-) create mode 100644 com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ConversationRendererVisualDemoSideBySide.java 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/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); + } + } +} From b7e142a7690bf15d6704e447d9ebb2420c5f0861 Mon Sep 17 00:00:00 2001 From: Dietrich Travkin Date: Tue, 7 Jul 2026 12:15:15 +0200 Subject: [PATCH 8/9] Add Maven launch configs --- .../Build and test Copilot for Eclipse.launch | 21 +++++++++++++++++++ ...n Checkstyle in Copilot for Eclipse.launch | 21 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 launch/Build and test Copilot for Eclipse.launch create mode 100644 launch/Run Checkstyle in Copilot for Eclipse.launch 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 @@ + + + + + + + + + + + + + + + + + + + + + From ccc0b1cd4a708a7753eb5fe102316772badaf54d Mon Sep 17 00:00:00 2001 From: Dietrich Travkin Date: Fri, 3 Jul 2026 17:26:53 +0200 Subject: [PATCH 9/9] Probe both chat renderers via -Dprobe.renderer launch property Both the browser-based (HTML) and the seasoned StyledText renderers ship behind the useBrowserBasedChatRenderer preference, so both need probe coverage. Select the renderer per launch instead of hardcoding it. - ProbeRunner: read -Dprobe.renderer (browser | styledtext) and set USE_BROWSER_BASED_CHAT_RENDERER accordingly. Default styledtext matches the production default, so plain runs still exercise the SWT widget tree. - chat-send-receive-001.json: restored to the strong StyledText widget-tree assertions (user-turn, copilot-turn, model-info-label) for the default renderer. - chat-send-receive-browser-001.json (new): browser-renderer variant that asserts the Browser widget exists, then uses sleep + screenshot because the HTML DOM is opaque to SWTBot widget locators. - REFERENCE.md: document the -Dprobe.renderer property and the two-probe workflow for covering both renderers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/skills/ui-action/REFERENCE.md | 23 ++++++++++ .../chat-send-receive-browser-001.json | 44 +++++++++++++++++++ .../swtbot/test/probe/ProbeRunner.java | 8 ++++ 3 files changed, 75 insertions(+) create mode 100644 com.microsoft.copilot.eclipse.swtbot.test/probe-scripts/chat-send-receive-browser-001.json 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/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);