From b13d84013436460fd2a57d0d61aa4ed680f68f91 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 + .../META-INF/MANIFEST.MF | 8 +- .../build.properties | 3 +- .../resources/html/chat-view.html | 115 ++ .../resources/html/css/chat-browser-base.css | 372 ++++++ .../resources/html/css/chat-browser-dark.css | 47 + .../resources/html/css/chat-browser-light.css | 47 + .../ui/chat/BrowserConversationWidget.java | 1144 +++++++++++++++++ .../eclipse/ui/chat/ChatContentViewer.java | 59 +- .../eclipse/ui/chat/ChatErrorMessages.java | 54 + .../copilot/eclipse/ui/chat/ChatView.java | 278 ++-- .../ui/chat/ConversationHtmlBlockFactory.java | 541 ++++++++ .../eclipse/ui/chat/IConversationWidget.java | 159 +++ .../ui/chat/StyledTextConversationWidget.java | 307 +++++ .../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 + 20 files changed, 2938 insertions(+), 274 deletions(-) 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/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 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.ui/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF index dc86d49a..62cf0972 100644 --- a/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF @@ -32,7 +32,7 @@ Require-Bundle: com.microsoft.copilot.eclipse.core;bundle-version="0.20.0", org.eclipse.ui.editors;bundle-version="3.17.100", org.eclipse.ui;bundle-version="3.205.0", org.eclipse.ui.navigator;bundle-version="3.12.200", - org.eclipse.jface.text;bundle-version="3.24.200", + org.eclipse.jface.text;bundle-version="3.24.200", org.eclipse.core.runtime;bundle-version="[3.30.0,4.0.0)", org.eclipse.core.expressions, org.eclipse.jdt.annotation;resolution:=optional, @@ -68,4 +68,8 @@ Require-Bundle: com.microsoft.copilot.eclipse.core;bundle-version="0.20.0", org.eclipse.ui.editors, org.eclipse.debug.core;resolution:=optional, org.eclipse.jdt.core;resolution:=optional, - org.eclipse.jdt.debug;resolution:=optional + org.eclipse.jdt.debug;resolution:=optional, + org.commonmark;bundle-version="0.24.0", + org.commonmark-gfm-tables;bundle-version="0.24.0", + org.commonmark-gfm-strikethrough;bundle-version="0.24.0", + org.commonmark-task-list-items;bundle-version="0.24.0" diff --git a/com.microsoft.copilot.eclipse.ui/build.properties b/com.microsoft.copilot.eclipse.ui/build.properties index 5ba08abe..0bb08961 100644 --- a/com.microsoft.copilot.eclipse.ui/build.properties +++ b/com.microsoft.copilot.eclipse.ui/build.properties @@ -8,4 +8,5 @@ bin.includes = META-INF/,\ css/,\ intro/,\ terminal-bundles/*.jar,\ - contexts.xml + contexts.xml,\ + resources/ diff --git a/com.microsoft.copilot.eclipse.ui/resources/html/chat-view.html b/com.microsoft.copilot.eclipse.ui/resources/html/chat-view.html new file mode 100644 index 00000000..ef93a458 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/resources/html/chat-view.html @@ -0,0 +1,115 @@ + + + + + + + + + Copilot Chat View + + + + + + + + +
+ + + + diff --git a/com.microsoft.copilot.eclipse.ui/resources/html/css/chat-browser-base.css b/com.microsoft.copilot.eclipse.ui/resources/html/css/chat-browser-base.css new file mode 100644 index 00000000..146f8a72 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/resources/html/css/chat-browser-base.css @@ -0,0 +1,372 @@ +/* Copyright (c) Microsoft Corporation. */ +/* Licensed under the MIT license. */ + +/* + * Base structural CSS for the browser-based chat view. + * Colors are applied via body.dark / body.light classes defined in the theme CSS files. + */ + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --font-mono: 'Cascadia Code', 'Fira Code', Consolas, monospace; + font-size: 13px; + padding: 8px; + line-height: 1.5; +} + +.chat-container { display: flex; flex-direction: column; gap: 12px; } + +.turn { padding: 8px 12px; border-radius: 6px; word-wrap: break-word; } + +.turn-user { box-shadow: inset 3px 0 0 var(--accent); } + +.turn-content { line-height: 1.5; } + +.tool-calls { margin-bottom: 6px; } + +.tool-call { font-size: 12px; padding: 2px 0; display: flex; align-items: flex-start; gap: 4px; } + +.tool-call-name { font-family: var(--font-mono); } + +.tool-call-progress { font-style: italic; } + +.tool-call-icon { display: inline-flex; align-items: center; font-weight: bold; } +.tool-call-icon.tc-success { color: #28a745 !important; } +.tool-call-icon.tc-error { color: #d73a49 !important; } +.tool-call-icon.tc-cancelled { color: var(--muted); } +.tool-call-icon.tc-running { + display: inline-flex; + align-items: center; +} +.tool-call-icon.tc-running .thinking-spinner { + width: 12px; + height: 12px; + border: 2px solid var(--muted); + border-top-color: transparent; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +/* Shared box styling for message cards (agent messages, tool confirmations, subagent calls). + Individual card classes add only their own element-specific rules. */ +.message-card { + margin: 8px 0; + padding: 8px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-secondary); + font-size: 12px; +} + +/* Nested subagent turn (mirrors SWT SubagentMessageBlock). Border/accent use themed variables. */ +.subagent-content { display: flex; flex-direction: column; } + +.subagent-avatar { + width: 14px; + height: 14px; + border-radius: 50%; + margin-right: 6px; +} + +.thinking-block { margin: 4px 0; font-size: 12px; } + +.thinking-block details { margin: 0; } + +.thinking-block summary { + cursor: pointer; + font-style: italic; + list-style: none; + display: flex; + align-items: center; +} + +.thinking-block summary .thinking-chevron { + font-style: normal; + margin-right: 4px; +} + +.thinking-block summary .thinking-chevron::after { + content: '\25B8'; +} + +.thinking-block details[open] > summary .thinking-chevron::after { + content: '\25BE'; +} + +.thinking-block summary .thinking-icon { + margin-right: 4px; + flex-shrink: 0; +} + +.thinking-block summary .thinking-spinner { + display: inline-block; + width: 12px; + height: 12px; + border: 2px solid currentColor; + border-top-color: transparent; + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin-right: 4px; + flex-shrink: 0; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.thinking-block .thinking-body { + margin: 4px 0; + padding: 4px 0 4px 10px; + border-left: 2px solid currentColor; + white-space: pre-wrap; + font-size: 11px; + max-height: 180px; + overflow-y: auto; + opacity: 0.85; +} + +.turn-content p { margin: 4px 0; } + +.turn-content a { text-decoration: none; } + +.turn-content a:hover { text-decoration: underline; } + +.turn-content code { + font-family: var(--font-mono); + font-size: 12px; + padding: 1px 4px; + border-radius: 3px; +} + +.turn-content pre { + position: relative; + margin: 8px 0; + padding: 8px; + border-radius: 4px; + overflow-x: auto; +} + +.turn-content pre code { background: none; padding: 0; } + +.turn-content table { border-collapse: collapse; margin: 8px 0; width: auto; } + +.turn-content th, +.turn-content td { padding: 4px 8px; text-align: left; } + +.turn-content th { font-weight: 600; } + +.turn-content ul, +.turn-content ol { margin: 4px 0 4px 20px; } + +.turn-content li { margin: 2px 0; } + +.turn-content blockquote { padding-left: 10px; margin: 6px 0; } + +.turn-content hr { border: none; margin: 8px 0; } + +/* Turn header with avatar and name */ +.turn-header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 6px; +} + +.turn-avatar { + width: 24px; + height: 24px; + border-radius: 50%; +} + +.turn-name { + font-weight: 600; + font-size: 12px; +} + +/* Code block action buttons */ +.code-actions { + position: absolute; + top: 4px; + right: 4px; + display: flex; + gap: 4px; + opacity: 0; + transition: opacity 0.15s; +} + +pre:hover .code-actions { opacity: 1; } + +.code-action-btn { + font-size: 11px; + padding: 3px 5px; + border: none; + border-radius: 3px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +/* Model info footer */ +.model-info { + margin-top: 8px; + font-size: 11px; + font-style: italic; + text-align: right; +} + +/* Error messages */ +.error-message { + padding: 6px 8px; + margin: 6px 0; + border-radius: 4px; + font-size: 12px; +} + +/* Warning messages with icon + optional action buttons */ +.warning-message { + padding: 8px; + margin: 6px 0; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 12px; +} + +.warning-content { + display: flex; + align-items: flex-start; + gap: 4px; +} + +.warning-content span { + flex: 1; + line-height: 1.4; +} + +.warning-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; + margin-left: 18px; +} + +/* Agent messages (PR links) */ +/* Box styling (margin, padding, border, radius, background, font-size) comes from + the shared .message-card class; keep only element-specific rules here. */ + +.block-title { + display: flex; + align-items: center; + font-weight: 600; + margin-bottom: 6px; +} + +.agent-message-desc { + margin: 4px 0 8px 0; + color: var(--muted); +} + +.agent-message-actions { + display: flex; + gap: 8px; + margin-top: 6px; +} + +.agent-message a { word-break: break-all; } + +/* Compacting status */ +.compacting-status { + margin-top: 8px; + font-size: 12px; + font-style: italic; + animation: blink 1s step-end infinite; +} + +/* Streaming indicator (animated dots) */ +.streaming-indicator { + display: flex; + gap: 4px; + padding: 8px 0; +} + +.streaming-indicator .dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + opacity: 0.4; + animation: dot-pulse 1.4s ease-in-out infinite; +} + +.streaming-indicator .dot:nth-child(2) { animation-delay: 0.2s; } + +.streaming-indicator .dot:nth-child(3) { animation-delay: 0.4s; } + +@keyframes dot-pulse { + 0%, 80%, 100% { opacity: 0.4; transform: scale(1); } + 40% { opacity: 1; transform: scale(1.2); } +} + +/* ═══════════════════ Tool Confirmation Block ═══════════════════ */ +/* Shared box styling comes from .message-card; keep only element-specific rules. */ +.confirmation-block { + min-width: fit-content; +} + +.confirmation-message { + margin-bottom: 8px; + color: var(--muted); +} + +.confirmation-command { + font-family: var(--font-mono); + font-size: 12px; + color: var(--code-color); + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 4px; + padding: 8px; + margin-bottom: 8px; + white-space: pre-wrap; + word-break: break-all; +} + +.confirmation-explanation { + margin-bottom: 8px; + font-size: 12px; + color: var(--muted); +} + +.confirmation-actions { + display: flex; + flex-wrap: nowrap; + gap: 8px; + margin-top: 8px; +} + +.btn-confirm { + padding: 3px 8px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg-secondary); + color: var(--fg-primary); + cursor: pointer; + font-size: 13px; +} + +.btn-confirm:hover { + background: var(--bg-hover); +} + +.btn-confirm.btn-primary { + background: var(--accent); + color: var(--fg-on-accent); + border-color: var(--accent); +} + +.btn-confirm.btn-primary:hover { + opacity: 0.9; +} diff --git a/com.microsoft.copilot.eclipse.ui/resources/html/css/chat-browser-dark.css b/com.microsoft.copilot.eclipse.ui/resources/html/css/chat-browser-dark.css new file mode 100644 index 00000000..6d149f7e --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/resources/html/css/chat-browser-dark.css @@ -0,0 +1,47 @@ +/* Copyright (c) Microsoft Corporation. */ +/* Licensed under the MIT license. */ + +/* + * Dark theme colors for the browser-based chat view. + * Values are synchronized with css/dark.css - update together. + */ + +body.dark { + background: #1e1f22; + color: #dee1e5; + --accent: #0078d4; + --muted: #a4a4a4; + --bg-primary: #1e1f22; + --bg-secondary: #2a2a2a; + --bg-hover: #3d3d3d; + --fg-primary: #dee1e5; + --fg-on-accent: #ffffff; + --border: #3d3d3d; + --code-color: #e0c576; +} + +body.dark .turn-user { background: #26282b; } +body.dark .turn-copilot { background: #1e1f22; } +body.dark .turn-error { background: #3d2020; border-left: 3px solid #f44747; } +body.dark .turn-name { color: #dee1e5; } +body.dark .tool-call { color: #a4a4a4; } +body.dark .tool-call-name { color: #0078d4; } +body.dark .thinking-block summary { color: #a4a4a4; } +body.dark .thinking-block .thinking-body { color: #a4a4a4; border-left-color: #505050; } +body.dark .turn-content a { color: #0078d4; } +body.dark .turn-content code { background: #2a2a2a; color: var(--code-color); } +body.dark .turn-content pre { background: #2a2a2a; } +body.dark .turn-content pre code { color: var(--code-color); } +body.dark .turn-content th, +body.dark .turn-content td { border: 1px solid #3d3d3d; } +body.dark .turn-content th { background: #2a2d2e; } +body.dark .turn-content tr:nth-child(even) td { background: #252729; } +body.dark .turn-content blockquote { border-left: 3px solid #E0C576; color: #E0C576; } +body.dark .turn-content hr { border-top: 1px solid #3d3d3d; } +body.dark .code-action-btn { background: #3d3d3d; color: #dee1e5; } +body.dark .code-action-btn:hover { background: #505050; } +body.dark .model-info { color: #808080; } +body.dark .error-message { background: #3d2020; color: #f44747; } +body.dark .warning-message { background: #3d3520; border-color: #e6a817; color: #f5c518; } +body.dark .agent-message { } +body.dark .compacting-status { color: #808080; } diff --git a/com.microsoft.copilot.eclipse.ui/resources/html/css/chat-browser-light.css b/com.microsoft.copilot.eclipse.ui/resources/html/css/chat-browser-light.css new file mode 100644 index 00000000..1faf39b7 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/resources/html/css/chat-browser-light.css @@ -0,0 +1,47 @@ +/* Copyright (c) Microsoft Corporation. */ +/* Licensed under the MIT license. */ + +/* + * Light theme colors for the browser-based chat view. + * Values are synchronized with css/light.css - update together. + */ + +body.light { + background: #f8f8f8; + color: #000000; + --accent: #0078d4; + --muted: #808080; + --bg-primary: #f8f8f8; + --bg-secondary: #f0f0f0; + --bg-hover: #e0e0e0; + --fg-primary: #000000; + --fg-on-accent: #ffffff; + --border: #d0d0d0; + --code-color: rgb(38, 86, 145); +} + +body.light .turn-user { background: #efefee; } +body.light .turn-copilot { background: #f8f8f8; } +body.light .turn-error { background: #fde7e7; border-left: 3px solid #d32f2f; } +body.light .turn-name { color: #000000; } +body.light .tool-call { color: #808080; } +body.light .tool-call-name { color: #0078d4; } +body.light .thinking-block summary { color: #808080; } +body.light .thinking-block .thinking-body { color: #808080; border-left-color: #c0c0c0; } +body.light .turn-content a { color: #0078d4; } +body.light .turn-content code { background: #f0f0f0; color: var(--code-color); } +body.light .turn-content pre { background: #f0f0f0; } +body.light .turn-content pre code { color: var(--code-color); } +body.light .turn-content th, +body.light .turn-content td { border: 1px solid #d0d0d0; } +body.light .turn-content th { background: #e8e8e8; } +body.light .turn-content tr:nth-child(even) td { background: #f4f4f4; } +body.light .turn-content blockquote { border-left: 3px solid rgb(38, 86, 145); color: rgb(38, 86, 145); } +body.light .turn-content hr { border-top: 1px solid #d0d0d0; } +body.light .code-action-btn { background: #e0e0e0; color: #333333; } +body.light .code-action-btn:hover { background: #d0d0d0; } +body.light .model-info { color: #808080; } +body.light .error-message { background: #fde7e7; color: #d32f2f; } +body.light .warning-message { background: #fef9e7; border-color: #e6a817; color: #795600; } +body.light .agent-message { } +body.light .compacting-status { color: #808080; } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidget.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidget.java new file mode 100644 index 00000000..2af634c9 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidget.java @@ -0,0 +1,1144 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.concurrent.CompletableFuture; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.commons.lang3.StringUtils; +import org.eclipse.core.runtime.FileLocator; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.ITextSelection; +import org.eclipse.swt.SWT; +import org.eclipse.swt.browser.Browser; +import org.eclipse.swt.browser.BrowserFunction; +import org.eclipse.swt.browser.ProgressAdapter; +import org.eclipse.swt.browser.ProgressEvent; +import org.eclipse.swt.dnd.Clipboard; +import org.eclipse.swt.dnd.TextTransfer; +import org.eclipse.swt.dnd.Transfer; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Display; +import org.eclipse.ui.IEditorPart; +import org.eclipse.ui.PlatformUI; +import org.eclipse.ui.texteditor.ITextEditor; +import org.osgi.framework.Bundle; + +import com.microsoft.copilot.eclipse.core.Constants; +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentRound; +import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentToolCall; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatProgressValue; +import com.microsoft.copilot.eclipse.core.lsp.protocol.GenerateThinkingTitleParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolConfirmationResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolConfirmationResult.ToolConfirmationResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.Thinking; +import com.microsoft.copilot.eclipse.core.lsp.protocol.codingagent.CodingAgentMessageRequestParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CheckQuotaResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CopilotPlan; +import com.microsoft.copilot.eclipse.core.persistence.AbstractTurnData; +import com.microsoft.copilot.eclipse.core.persistence.ConversationDataFactory; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.AgentMessageData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.EditAgentRoundData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ErrorData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ErrorMessageData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ReplyData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ThinkingBlockData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ToolCallData; +import com.microsoft.copilot.eclipse.core.persistence.UserTurnData; +import com.microsoft.copilot.eclipse.ui.CopilotUi; +import com.microsoft.copilot.eclipse.ui.UiConstants; +import com.microsoft.copilot.eclipse.ui.chat.QuotaActions.QuotaAction; +import com.microsoft.copilot.eclipse.ui.utils.UiUtils; + +/** + * {@link IConversationWidget} implementation that renders copilot chat turns in an + * Eclipse-internal web {@link Browser} widget using an HTML/JavaScript frontend. + * + *

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

HTML block ID scheme: turn containers use the server-assigned turn ID. + * Child blocks within a turn use {@code turnId-N} where N is a sequential counter. + * The CSS class on each child block identifies its type + * (thinking-block, tool-call, response, etc.). + */ +public class BrowserConversationWidget implements IConversationWidget { + + private static final String COPILOT_AVATAR_PATH = + "/icons/chat/chat_message_copilot_avatar.png"; + private static final String USER_AVATAR_PATH = "/icons/chat/chat_message_user_avatar.png"; + private static final String COPILOT_DISPLAY_NAME = "GitHub Copilot"; + private static final String GITHUB_AVATAR_URL = + "https://avatars.githubusercontent.com/%s?s=24&v=4"; + + private final Browser browser; + private final ConversationHtmlBlockFactory htmlFactory; + + // Turn streaming state (per-turn, keyed by turnId). + private final Map turnStates = new HashMap<>(); + private String currentTurnId; + private String lastCopilotTurnId; + private String conversationId; + + // Subagent nesting (single level, mirrors SWT BaseTurnWidget). Set while the parent's + // run_subagent tool call is running; consumed by the subagent's beginTurn. + private String activeSubagentContentAreaId; + // Maps a run_subagent tool call id to its nested content-area id (used on restore). + private final Map subagentContentAreaByToolCallId = new HashMap<>(); + + // Tool confirmation state + private CompletableFuture pendingConfirmationFuture; + private String pendingConfirmationTurnId; + private List pendingConfirmationActions; + private ConfirmationAction lastSelectedAction; + + private boolean pageLoaded; + private final Queue pendingScripts = new LinkedList<>(); + private String copilotAvatarDataUri; + private String userAvatarDataUri; + private String userName; + + /** Types of child blocks within a copilot turn. */ + private enum ChildBlockType { + THINKING, TOOL_CALL, RESPONSE + } + + /** + * Per-turn streaming state, keyed by turnId. Keeping the block counter and current-block pointers + * per turn means a parent turn that resumes after a nested subagent keeps its own counter, so no + * duplicate DOM ids are produced. {@code contentAreaId} is where this turn's child blocks are + * inserted: the turn's own content area, or — for a subagent turn — the nested subagent content + * area. + */ + private static final class TurnStreamState { + private final String contentAreaId; + private int childBlockCounter; + private ChildBlockType currentBlockType; + private String currentChildBlockId; + private String currentToolCallId; + private String currentThinkingBlockId; + private StringBuilder currentThinkingText; + private StringBuilder currentReplyText; + private boolean streamingIndicatorVisible; + + TurnStreamState(String contentAreaId) { + this.contentAreaId = contentAreaId; + } + + void resetTransient() { + currentBlockType = null; + currentChildBlockId = null; + currentToolCallId = null; + currentThinkingBlockId = null; + currentThinkingText = null; + currentReplyText = null; + } + } + + /** + * Creates a new browser-based conversation widget. + * + * @param parent the parent composite + */ + public BrowserConversationWidget(Composite parent) { + browser = new Browser(parent, SWT.NONE); + browser.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); + htmlFactory = new ConversationHtmlBlockFactory(); + + // Load avatar icons as base64 data URIs + Bundle bundle = CopilotUi.getPlugin().getBundle(); + this.copilotAvatarDataUri = loadIconAsDataUri(bundle, COPILOT_AVATAR_PATH); + this.userAvatarDataUri = loadIconAsDataUri(bundle, USER_AVATAR_PATH); + + // Load code block action icons from Eclipse platform bundles + Bundle uiBundle = org.eclipse.core.runtime.Platform.getBundle("org.eclipse.ui"); + String copyUri = loadIconAsDataUri(uiBundle, "icons/full/etool16/copy_edit.png"); + Bundle textEditorBundle = org.eclipse.core.runtime.Platform.getBundle( + UiConstants.WORKBENCH_TEXTEDITOR); + String insertUri = loadIconAsDataUri(textEditorBundle, UiConstants.INSERT_ICON); + htmlFactory.setCodeBlockIcons(copyUri, insertUri); + + registerBrowserFunctions(); + + browser.addProgressListener(new ProgressAdapter() { + @Override + public void completed(ProgressEvent event) { + pageLoaded = true; + String theme = UiUtils.isDarkTheme() ? "dark" : "light"; + browser.execute("window.setTheme('" + theme + "')"); + while (!pendingScripts.isEmpty()) { + browser.execute(pendingScripts.poll()); + } + } + }); + + try { + URL fileUrl = bundle.getEntry("resources/html/chat-view.html"); + URL resolvedUrl = FileLocator.toFileURL(fileUrl); + browser.setUrl(resolvedUrl.toExternalForm()); + } catch (Exception e) { + CopilotCore.LOGGER.error("Failed to load chat-view.html in browser widget", e); + } + } + + @Override + public Control getControl() { + return browser; + } + + @Override + public boolean isDisposed() { + return browser.isDisposed(); + } + + @Override + public void dispose() { + if (!browser.isDisposed()) { + browser.dispose(); + } + } + + @Override + public void requestLayout() { + browser.requestLayout(); + } + + @Override + public void setConversationId(String conversationId) { + this.conversationId = conversationId; + } + + @Override + public void beginTurn(String turnId, boolean isCopilot, boolean isHistory) { + // Subagent begin: nest inside the parent's active subagent block instead of creating a + // top-level turn container. Its child blocks are routed to the nested content area. + if (isCopilot && activeSubagentContentAreaId != null) { + currentTurnId = turnId; + turnStates.put(turnId, new TurnStreamState(activeSubagentContentAreaId)); + return; + } + currentTurnId = turnId; + if (isCopilot) { + lastCopilotTurnId = turnId; + } + turnStates.put(turnId, + new TurnStreamState(ConversationHtmlBlockFactory.contentBlockId(turnId, isCopilot))); + String turnHtml = htmlFactory.createTurnContainerHtmlBlock( + turnId, isCopilot, + isCopilot ? copilotAvatarDataUri : getUserAvatarUri(), + isCopilot ? COPILOT_DISPLAY_NAME : getUserDisplayName()); + insertHtmlBlockChild("chat-container", turnHtml); + if (isCopilot && !isHistory) { + showStreamingIndicator(turnId); + } + } + + @Override + public void processTurnEvent(ChatProgressValue value) { + String turnId = value.getTurnId(); + + switch (value.getKind()) { + case report -> { + currentTurnId = turnId; + ensureTurnState(turnId, value); + removeStreamingIndicator(turnId); + processThinking(turnId, value); + processAgentRounds(turnId, value); + processReply(turnId, extractReplyChunk(value)); + } + case end -> { + removeStreamingIndicator(turnId); + finalizeTurn(turnId); + } + default -> { + // begin is handled by beginTurn(), called separately by ChatView + } + } + + // Handle error messages (can arrive on any event kind, including report and end) + String errorMessage = ChatErrorMessages.resolveDisplayMessage(value); + if (StringUtils.isNotEmpty(errorMessage)) { + processErrorMessage(turnId, errorMessage, value.getCode(), value.getErrorModelProviderName()); + } + } + + @Override + public void startNewUserTurn(String turnId, String message) { + String turnHtml = htmlFactory.createTurnContainerHtmlBlock( + turnId, false, getUserAvatarUri(), getUserDisplayName()); + insertHtmlBlockChild("chat-container", turnHtml); + String contentId = ConversationHtmlBlockFactory.contentBlockId(turnId, false); + String blockId = contentId + "-0"; + String blockHtml = htmlFactory.createUserRequestHtmlBlock(blockId, message); + insertHtmlBlockChild(contentId, blockHtml); + } + + @Override + public void scrollToBottom() { + executeScript("window.scrollTo(0, document.body.scrollHeight)"); + } + + @Override + public void refreshScrollerLayout() { + // Browser handles its own layout; no action needed + } + + @Override + public void renderErrorMessage(String content) { + String errorId = "error-" + System.currentTimeMillis(); + String renderedContent = htmlFactory.renderMarkdown(content); + String errorHtml = htmlFactory.createErrorTurnHtmlBlock(errorId, renderedContent); + insertHtmlBlockChild("chat-container", errorHtml); + // Scroll the newly inserted error banner into view so the user notices it, mirroring the + // SWT renderer's showControl-based scroll for error banners. + scrollToBottom(); + } + + @Override + public void showCompactingStatusOnLatestCopilotTurn() { + String turnId = lastCopilotTurnId != null ? lastCopilotTurnId : currentTurnId; + if (turnId != null) { + String blockId = ConversationHtmlBlockFactory.compactingBlockId(turnId); + String html = htmlFactory.createCompactingStatusHtmlBlock(blockId); + insertHtmlBlockChild(ConversationHtmlBlockFactory.contentBlockId(turnId, true), html); + } + } + + @Override + public void hideCompactingStatusOnLatestCopilotTurn() { + String turnId = lastCopilotTurnId != null ? lastCopilotTurnId : currentTurnId; + if (turnId != null) { + removeHtmlBlock(ConversationHtmlBlockFactory.compactingBlockId(turnId)); + } + } + + @Override + public String getActiveThinkingBlockId(String turnId) { + TurnStreamState state = turnId != null ? turnStates.get(turnId) : null; + return state != null ? state.currentThinkingBlockId : null; + } + + @Override + public void restoreTurn(AbstractTurnData turn, ConversationDataFactory dataFactory) { + if (turn == null) { + return; + } + // Subagent turn: render nested inside the parent turn's subagent block. + if (turn instanceof CopilotTurnData copilotTurn + && StringUtils.isNotBlank(copilotTurn.getParentTurnId())) { + restoreSubagentTurn(copilotTurn); + return; + } + if (turn instanceof UserTurnData userTurn) { + if (userTurn.getMessage() != null + && StringUtils.isNotBlank(userTurn.getMessage().getText())) { + startNewUserTurn(turn.getTurnId(), userTurn.getMessage().getText()); + } + return; + } + if (turn instanceof CopilotTurnData copilotTurn) { + String turnId = turn.getTurnId(); + lastCopilotTurnId = turnId; + String turnHtml = htmlFactory.createTurnContainerHtmlBlock( + turnId, true, copilotAvatarDataUri, COPILOT_DISPLAY_NAME); + insertHtmlBlockChild("chat-container", turnHtml); + restoreCopilotTurnContent(turnId, + ConversationHtmlBlockFactory.contentBlockId(turnId, true), copilotTurn); + ReplyData replyData = copilotTurn.getReply(); + if (replyData != null && StringUtils.isNotBlank(replyData.getModelName())) { + renderModelInfo(turnId, replyData.getModelName(), + replyData.getBillingMultiplier(), replyData.getReasoningEffort()); + } + } + } + + /** + * Restores a subagent turn into the nested subagent block that was opened while restoring the + * parent turn's {@code run_subagent} tool call. Falls back to the parent's content area when the + * subagent tool-call id is missing (mirrors the SWT blank-toolCallId path). + */ + private void restoreSubagentTurn(CopilotTurnData copilotTurn) { + String toolCallId = copilotTurn.getSubagentToolCallId(); + String contentAreaId = StringUtils.isNotBlank(toolCallId) + ? subagentContentAreaByToolCallId.get(toolCallId) : null; + if (contentAreaId == null) { + contentAreaId = + ConversationHtmlBlockFactory.contentBlockId(copilotTurn.getParentTurnId(), true); + } + restoreCopilotTurnContent(copilotTurn.getTurnId(), contentAreaId, copilotTurn); + } + + @Override + public void renderModelInfo(String turnId, String modelName, double billingMultiplier, + String reasoningEffort) { + // When token-based billing is enabled, the per-turn billing multiplier is no longer + // a meaningful price signal — hide it, matching StyledText widget behavior. + double effectiveMultiplier = billingMultiplier; + try { + if (CopilotCore.getPlugin().getAuthStatusManager() + .getQuotaStatus().tokenBasedBillingEnabled()) { + effectiveMultiplier = 0; + } + } catch (Exception e) { + // Defensive: if quota status unavailable, fall back to showing multiplier + } + String blockId = ConversationHtmlBlockFactory.modelInfoBlockId(turnId); + String html = htmlFactory.createModelInfoHtmlBlock( + blockId, modelName, effectiveMultiplier, reasoningEffort); + insertHtmlBlockChild(ConversationHtmlBlockFactory.copilotTurnContainerId(turnId), html); + } + + @Override + public void renderAgentMessage(CodingAgentMessageRequestParams params) { + if (params == null) { + return; + } + String turnId = params.getTurnId(); + if (StringUtils.isBlank(turnId)) { + return; + } + String blockId = ConversationHtmlBlockFactory.agentMessageBlockId(turnId); + String html = htmlFactory.createAgentMessageHtmlBlock( + blockId, params.getTitle(), params.getDescription(), params.getPrLink()); + insertHtmlBlockChild(ConversationHtmlBlockFactory.copilotTurnContainerId(turnId), html); + } + + @Override + public CompletableFuture requestToolConfirmation( + String turnId, ConfirmationContent content, Object input) { + // Cancel any existing pending confirmation + cancelToolConfirmation(turnId); + + CompletableFuture future = new CompletableFuture<>(); + pendingConfirmationFuture = future; + pendingConfirmationTurnId = turnId; + pendingConfirmationActions = content.getActions(); + lastSelectedAction = null; + + String blockId = ConversationHtmlBlockFactory.confirmationBlockId(turnId); + String html = htmlFactory.createConfirmationHtmlBlock(blockId, content, input); + // Insert before the model-info block so confirmation appears above it + String modelInfoId = ConversationHtmlBlockFactory.modelInfoBlockId(turnId); + insertHtmlBlockBefore( + ConversationHtmlBlockFactory.copilotTurnContainerId(turnId), html, modelInfoId); + scrollToBottom(); + + return future; + } + + @Override + public void cancelToolConfirmation(String turnId) { + if (pendingConfirmationFuture != null && !pendingConfirmationFuture.isDone()) { + pendingConfirmationFuture.complete( + new LanguageModelToolConfirmationResult(ToolConfirmationResult.DISMISS)); + } + pendingConfirmationFuture = null; + pendingConfirmationActions = null; + pendingConfirmationTurnId = null; + removeConfirmationBlock(turnId); + } + + @Override + public ConfirmationAction getLastSelectedConfirmationAction() { + return lastSelectedAction; + } + + private void resolveConfirmation(int actionIndex, boolean accepted) { + if (pendingConfirmationFuture == null || pendingConfirmationFuture.isDone()) { + return; + } + if (accepted && pendingConfirmationActions != null + && actionIndex >= 0 && actionIndex < pendingConfirmationActions.size()) { + lastSelectedAction = pendingConfirmationActions.get(actionIndex); + } + ToolConfirmationResult result = accepted + ? ToolConfirmationResult.ACCEPT : ToolConfirmationResult.DISMISS; + pendingConfirmationFuture.complete(new LanguageModelToolConfirmationResult(result)); + pendingConfirmationFuture = null; + pendingConfirmationActions = null; + if (pendingConfirmationTurnId != null) { + removeConfirmationBlock(pendingConfirmationTurnId); + pendingConfirmationTurnId = null; + } + } + + private void removeConfirmationBlock(String turnId) { + if (turnId == null) { + return; + } + String blockId = ConversationHtmlBlockFactory.confirmationBlockId(turnId); + executeScript("window.removeBlock && window.removeBlock('" + escapeForJs(blockId) + "')"); + } + + /** + * Processes a thinking chunk: starts a new thinking block or updates the current one. + * Seals the thinking block if renderable output follows in the same event. + */ + private void processThinking(String turnId, ChatProgressValue value) { + Thinking thinking = value.getThinking(); + boolean hasThinking = thinking != null && thinking.text() != null + && !thinking.text().isBlank(); + + TurnStreamState state = stateFor(turnId); + if (hasThinking) { + if (state.currentBlockType != ChildBlockType.THINKING) { + state.currentThinkingText = new StringBuilder(thinking.text()); + state.currentBlockType = ChildBlockType.THINKING; + state.currentChildBlockId = ConversationHtmlBlockFactory.copilotChildBlockId( + turnId, state.childBlockCounter++); + state.currentThinkingBlockId = state.currentChildBlockId; + String blockHtml = htmlFactory.createThinkingHtmlBlock( + state.currentChildBlockId, thinking.text()); + insertHtmlBlockChild(state.contentAreaId, blockHtml); + } else { + state.currentThinkingText.append(thinking.text()); + updateThinkingBodyText(state.currentChildBlockId, state.currentThinkingText.toString()); + } + } + + // Seal thinking if renderable output follows in the same event + if (hasRenderableOutput(value) && state.currentBlockType == ChildBlockType.THINKING) { + sealCurrentThinkingBlock(turnId); + } + } + + /** + * Seals the current thinking block: collapses it, removes the spinner, and requests a title + * from the language server. Mirrors the behavior of {@code ThinkingTurnWidget.sealThinking()}. + * Uses targeted DOM manipulation instead of replacing the entire block. + */ + private void sealCurrentThinkingBlock(String turnId) { + TurnStreamState state = stateFor(turnId); + if (state.currentThinkingBlockId == null || state.currentThinkingText == null) { + state.currentBlockType = null; + return; + } + String blockId = state.currentThinkingBlockId; + String thinkingContent = state.currentThinkingText.toString(); + state.currentBlockType = null; + + // Collapse details, remove spinner (targeted DOM ops, no full re-render) + collapseThinkingBlock(blockId); + + // Request title from language server (async) + requestThinkingTitle(blockId, thinkingContent, turnId); + + // The block is sealed; it is no longer the turn's active thinking block. + state.currentThinkingBlockId = null; + state.currentThinkingText = null; + } + + /** + * Collapses a thinking block by removing the {@code open} attribute from its + * {@code

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

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

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

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

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

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

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

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

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

This is less accurate than the active-theme query (the persisted value can lag the runtime + * theme, e.g. when following the OS theme), but it needs no theming bundle at all and never + * throws, so it is a safe last resort. + * + * @return true if the persisted theme id denotes a dark theme, false otherwise (including light + * and unknown) + */ + private static boolean persistedThemeIsDark() { + String themeId = Platform.getPreferencesService() + .getString("org.eclipse.e4.ui.css.swt.theme", "themeid", "", null); + return themeId.toLowerCase().contains("dark"); } /** From 72f1744f7489bf9b70be2b2be4740a677305ce3a 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 edabc7989d9aa56de4c99db4f3b5a92f3665a22e 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> Update subagent card factory test for header --- ...BrowserConversationWidgetBehaviorTest.java | 649 +++++++++++++++ ...rowserConversationWidgetEscapingTests.java | 43 + .../chat/BrowserConversationWidgetTest.java | 60 ++ .../ui/chat/ChatErrorMessagesTest.java | 56 ++ .../ConversationHtmlBlockFactoryTest.java | 761 ++++++++++++++++++ 5 files changed, 1569 insertions(+) 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/BrowserConversationWidgetEscapingTests.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 diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidgetBehaviorTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidgetBehaviorTest.java new file mode 100644 index 00000000..f3466db9 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/BrowserConversationWidgetBehaviorTest.java @@ -0,0 +1,649 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import org.eclipse.lsp4j.WorkDoneProgressKind; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Shell; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import com.microsoft.copilot.eclipse.core.AuthStatusManager; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentRound; +import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentToolCall; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatProgressValue; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ConversationError; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolConfirmationResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolConfirmationResult.ToolConfirmationResult; +import com.microsoft.copilot.eclipse.core.lsp.protocol.Thinking; +import com.microsoft.copilot.eclipse.core.persistence.ConversationDataFactory; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.EditAgentRoundData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ErrorData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ErrorMessageData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ReplyData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ThinkingBlockData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ThinkingBlockState; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ToolCallData; +import com.microsoft.copilot.eclipse.core.persistence.UserTurnData; +import com.microsoft.copilot.eclipse.core.persistence.UserTurnData.MessageData; + +/** + * Behavior tests for {@link BrowserConversationWidget} covering turn lifecycle, + * state tracking, tool confirmation logic, and error handling. + * + *

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

"), "Should be open during streaming"); + assertTrue(html.contains("thinking-spinner"), "Should show spinner"); + assertTrue(html.contains("Thinking…"), "Should have 'Thinking...' summary"); + assertTrue(html.contains("Analyzing code..."), "Should include thinking text"); + assertTrue(html.contains("class=\"thinking-block\"")); + } + + @Test + void sealedThinkingBlock_isClosedWithCustomTitle() { + String html = factory.createSealedThinkingHtmlBlock( + "think-2", "Done.", "Analysis Complete"); + + assertTrue(html.contains("
"), "Should have
"); + assertFalse(html.contains("
"), "Should NOT be open"); + assertFalse(html.contains("thinking-spinner"), "Should NOT show spinner"); + assertTrue(html.contains("Analysis Complete"), "Should show custom title"); + assertTrue(html.contains("Done."), "Should have content"); + } + + @Test + void sealedThinkingBlock_usesDefaultTitle_whenBlank() { + String html = factory.createSealedThinkingHtmlBlock("think-3", "text", null); + assertTrue(html.contains("Thinking…"), + "Should fall back to default title when null"); + + html = factory.createSealedThinkingHtmlBlock("think-4", "text", ""); + assertTrue(html.contains("Thinking…"), + "Should fall back to default title when empty"); + } + + @Test + void restoredThinkingBlock_delegatesToSealed() { + ThinkingBlockData data = new ThinkingBlockData("tb-1", "Analyzing..."); + data.setTitle("Code Analysis"); + + String html = factory.createRestoredThinkingHtmlBlock("think-5", data); + + assertTrue(html.contains("Code Analysis")); + assertTrue(html.contains("
")); + assertFalse(html.contains("
")); + assertTrue(html.contains("Analyzing...")); + } + + @Test + void escapesHtmlInThinkingContent() { + String html = factory.createThinkingHtmlBlock("think-6", "if (a < b) { }"); + assertTrue(html.contains("a < b"), "Should escape HTML in thinking text"); + } + } + + @Nested + class TurnContainerAndOtherBlockTests { + + @Test + void createTurnContainerHtmlBlock_copilotTurn() { + String html = factory.createTurnContainerHtmlBlock( + "turn-123", true, "data:image/png;base64,ABC", "GitHub Copilot"); + + assertTrue(html.contains("id=\"turn-123-copilot\"")); + assertTrue(html.contains("turn-copilot")); + assertTrue(html.contains("turn-avatar")); + assertTrue(html.contains("GitHub Copilot")); + assertTrue(html.contains("id=\"turn-123-copilot-content\"")); + } + + @Test + void createTurnContainerHtmlBlock_userTurn() { + String html = factory.createTurnContainerHtmlBlock( + "turn-456", false, null, "TestUser"); + + assertTrue(html.contains("id=\"turn-456-user\"")); + assertTrue(html.contains("turn-user")); + assertTrue(html.contains("TestUser")); + assertTrue(html.contains("id=\"turn-456-user-content\"")); + assertFalse(html.contains("turn-avatar"), + "Should omit avatar img when dataUri is null"); + } + + @Test + void sameTurnId_producesDifferentContainerIds() { + String user = factory.createTurnContainerHtmlBlock("t1", false, null, "U"); + String copilot = factory.createTurnContainerHtmlBlock("t1", true, null, "C"); + + assertTrue(user.contains("id=\"t1-user\"")); + assertTrue(copilot.contains("id=\"t1-copilot\"")); + assertFalse(user.contains("-copilot")); + assertFalse(copilot.contains("-user\"")); + } + + @Test + void createCopilotReplyHtmlBlock_rendersMarkdown() { + String html = factory.createCopilotReplyHtmlBlock("b1", "**hello**"); + + assertTrue(html.contains("id=\"b1\"")); + assertTrue(html.contains("class=\"response\"")); + assertTrue(html.contains("hello")); + } + + @Test + void createUserRequestHtmlBlock_rendersMarkdown() { + String html = factory.createUserRequestHtmlBlock("b2", "How do I **sort**?"); + + assertTrue(html.contains("id=\"b2\"")); + assertTrue(html.contains("class=\"user-request\"")); + assertTrue(html.contains("sort")); + } + + @Test + void createErrorMessageHtmlBlock() { + String html = factory.createErrorMessageHtmlBlock("e1", "Rate limit exceeded"); + + assertTrue(html.contains("id=\"e1\"")); + assertTrue(html.contains("class=\"warning-message\"")); + assertTrue(html.contains("Rate limit exceeded")); + } + + @Test + void createWarningMessageHtmlBlockWithActions() { + List actions = List.of( + new QuotaActions.QuotaAction("Upgrade Plan", "Upgrade tooltip", + "https://example.com/upgrade", true), + new QuotaActions.QuotaAction("Enable Usage", "Usage tooltip", + "https://example.com/usage", false)); + + String html = factory.createWarningMessageHtmlBlock("w1", "Quota exceeded", actions); + + assertTrue(html.contains("id=\"w1\"")); + assertTrue(html.contains("class=\"warning-message\"")); + assertTrue(html.contains("Quota exceeded")); + assertTrue(html.contains("class=\"warning-actions\"")); + assertTrue(html.contains("btn-confirm btn-primary")); + assertTrue(html.contains("Upgrade Plan")); + assertTrue(html.contains("Enable Usage")); + assertTrue(html.contains("copilotAction('openLink','")); + } + + @Test + void createWarningMessageHtmlBlockWithoutActions() { + String html = factory.createWarningMessageHtmlBlock("w2", "Generic error", List.of()); + + assertTrue(html.contains("id=\"w2\"")); + assertTrue(html.contains("class=\"warning-message\"")); + assertTrue(html.contains("Generic error")); + assertFalse(html.contains("warning-actions")); + } + + @Test + void createStreamingIndicatorHtmlBlock() { + String html = factory.createStreamingIndicatorHtmlBlock("s1"); + + assertTrue(html.contains("id=\"s1\"")); + assertTrue(html.contains("class=\"streaming-indicator\"")); + assertTrue(html.contains("class=\"dot\"")); + } + + @Test + void createCompactingStatusHtmlBlock() { + String html = factory.createCompactingStatusHtmlBlock("c1"); + + assertTrue(html.contains("id=\"c1\"")); + assertTrue(html.contains("class=\"compacting-status\"")); + assertTrue(html.contains("Compacting")); + } + + @Test + void createAgentMessageHtmlBlock() { + String html = factory.createAgentMessageHtmlBlock( + "msg-1", "PR Created", "Description", "https://github.com/pr/1"); + + assertTrue(html.contains("id=\"msg-1\"")); + assertTrue(html.contains("class=\"message-card agent-message\"")); + assertTrue(html.contains("PR Created")); + assertTrue(html.contains("Description")); + assertTrue(html.contains("copilotAction('openLink','https://github.com/pr/1')")); + } + } + + @Nested + class BlockIdTests { + + @Test + void userAndCopilotContainerIds_doNotCollide() { + assertEquals("t1-user", ConversationHtmlBlockFactory.userTurnContainerId("t1")); + assertEquals("t1-copilot", + ConversationHtmlBlockFactory.copilotTurnContainerId("t1")); + } + + @Test + void contentBlockId_scopedByRole() { + assertEquals("t1-user-content", + ConversationHtmlBlockFactory.contentBlockId("t1", false)); + assertEquals("t1-copilot-content", + ConversationHtmlBlockFactory.contentBlockId("t1", true)); + } + + @Test + void otherIdFormats() { + assertEquals("t1-5", ConversationHtmlBlockFactory.copilotChildBlockId("t1", 5)); + assertEquals("t1-tc-3", ConversationHtmlBlockFactory.toolCallBlockId("t1", 3)); + assertEquals("t1-compacting", ConversationHtmlBlockFactory.compactingBlockId("t1")); + assertEquals("t1-model-info", ConversationHtmlBlockFactory.modelInfoBlockId("t1")); + assertEquals("t1-streaming", + ConversationHtmlBlockFactory.streamingIndicatorId("t1")); + assertEquals("t1-confirm", + ConversationHtmlBlockFactory.confirmationBlockId("t1")); + } + } + + // Creates an AgentToolCall using Gson (class has no setters). + private static AgentToolCall agentToolCall(String name, String status, + String progressMessage) { + String json = GSON.toJson(Map.of( + "name", name, + "status", status, + "progressMessage", progressMessage != null ? progressMessage : "")); + AgentToolCall tc = GSON.fromJson(json, AgentToolCall.class); + // Gson sets empty string; clear to null if that's what we want + if (progressMessage == null) { + // Use Gson again with explicit null + String jsonNull = "{\"name\":\"" + name + "\",\"status\":\"" + status + "\"}"; + tc = GSON.fromJson(jsonNull, AgentToolCall.class); + } + return tc; + } + + @Nested + class SubagentBlockTests { + + @Test + void subagentBlockId_combinesParentTurnAndToolCall() { + assertEquals("turn-1-subagent-tc-9", + ConversationHtmlBlockFactory.subagentBlockId("turn-1", "tc-9")); + } + + @Test + void subagentContentAreaId_suffixesBlockIdWithContent() { + assertEquals("turn-1-subagent-tc-9-content", + ConversationHtmlBlockFactory.subagentContentAreaId("turn-1", "tc-9")); + } + + @Test + void createSubagentBlockHtmlBlock_nestsContentAreaInsideBorderedBlock() { + String blockId = ConversationHtmlBlockFactory.subagentBlockId("turn-1", "tc-9"); + String contentAreaId = ConversationHtmlBlockFactory.subagentContentAreaId("turn-1", "tc-9"); + String html = factory.createSubagentBlockHtmlBlock( + blockId, contentAreaId, "data:image/png;base64,AAAA", "Investigated the failing test"); + + assertTrue(html.contains("id=\"turn-1-subagent-tc-9\""), "Outer block carries its id"); + assertTrue(html.contains("class=\"message-card subagent-message-block\""), + "Shares SWT subagent CSS class for parity"); + assertTrue(html.contains("id=\"turn-1-subagent-tc-9-content\""), + "Inner content area carries its id"); + assertTrue(html.contains("class=\"subagent-content\""), "Inner content area is styled"); + assertTrue(html.contains("class=\"block-title\""), "Card has a header"); + assertTrue(html.contains("class=\"subagent-avatar\""), "Header shows the copilot avatar"); + assertTrue(html.contains("Investigated the failing test"), "Header shows the title"); + // Content area must be nested inside the outer block. + assertTrue(html.indexOf(blockId) < html.indexOf(contentAreaId), + "Content area is nested inside the outer block"); + } + + @Test + void createSubagentBlockHtmlBlock_fallsBackToDefaultTitle() { + String blockId = ConversationHtmlBlockFactory.subagentBlockId("turn-1", "tc-9"); + String contentAreaId = ConversationHtmlBlockFactory.subagentContentAreaId("turn-1", "tc-9"); + String html = factory.createSubagentBlockHtmlBlock(blockId, contentAreaId, null, null); + + assertTrue(html.contains("Subagent"), "Falls back to a default title"); + assertFalse(html.contains("class=\"subagent-avatar\""), "No avatar img when uri is blank"); + } + } +} From 0a2fa46c3b43cf4cc08e6e17de8270735ec2c5bc 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 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover UiUtils.isDarkTheme() with mocked PlatformUI/Platform statics: WITH e4 CSS theming (dark id, light id, mixed-case id, and a null active theme falling back to the preference) and WITHOUT theming (theme bundle absent, theme service null, and a reflective loadClass failure) — all of which must degrade to the persisted themeid preference and finally to light without throwing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../eclipse/ui/utils/UiUtilsThemeTest.java | 178 ++++++++++++++++++ 1 file changed, 178 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..88e94a83 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/UiUtilsThemeTest.java @@ -0,0 +1,178 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.utils; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import org.eclipse.core.runtime.Platform; +import org.eclipse.core.runtime.preferences.IPreferencesService; +import org.eclipse.e4.ui.css.swt.theme.ITheme; +import org.eclipse.e4.ui.css.swt.theme.IThemeEngine; +import org.eclipse.ui.IWorkbench; +import org.eclipse.ui.PlatformUI; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.osgi.framework.Bundle; + +/** + * Unit tests for {@link UiUtils#isDarkTheme()}. + * + *

The detection resolves the active e4 CSS theme reflectively through the theme bundle's own + * class loader, so no compile-time reference to the friend-restricted + * {@code org.eclipse.e4.ui.css.swt.theme} package is required in production. These tests drive that + * logic entirely through mocked {@link PlatformUI} and {@link Platform} statics, covering both the + * case where e4 CSS theming is available and the graceful fallbacks when it is not. + */ +class UiUtilsThemeTest { + + private static final String THEME_BUNDLE = "org.eclipse.e4.ui.css.swt.theme"; + private static final String DARK_THEME_ID = "org.eclipse.e4.ui.css.theme.e4_dark"; + private static final String LIGHT_THEME_ID = "org.eclipse.e4.ui.css.theme.e4_default"; + + // --------------------------------------------------------------------------- + // Theming available: active theme resolved via reflection. + // --------------------------------------------------------------------------- + + @Test + void isDarkTheme_activeThemeIsDark_returnsTrue() throws Exception { + try (MockedStatic platformUi = mockStatic(PlatformUI.class); + MockedStatic platform = mockStatic(Platform.class)) { + IWorkbench workbench = givenThemingAvailable(platformUi, platform); + givenActiveTheme(workbench, DARK_THEME_ID); + + assertTrue(UiUtils.isDarkTheme()); + } + } + + @Test + void isDarkTheme_activeThemeIsLight_returnsFalse() throws Exception { + try (MockedStatic platformUi = mockStatic(PlatformUI.class); + MockedStatic platform = mockStatic(Platform.class)) { + IWorkbench workbench = givenThemingAvailable(platformUi, platform); + givenActiveTheme(workbench, LIGHT_THEME_ID); + + assertFalse(UiUtils.isDarkTheme()); + } + } + + @Test + void isDarkTheme_activeThemeIdMixedCase_returnsTrue() throws Exception { + try (MockedStatic platformUi = mockStatic(PlatformUI.class); + MockedStatic platform = mockStatic(Platform.class)) { + IWorkbench workbench = givenThemingAvailable(platformUi, platform); + givenActiveTheme(workbench, "com.example.Solarized.DARK.Theme"); + + assertTrue(UiUtils.isDarkTheme()); + } + } + + @Test + void isDarkTheme_activeThemeNull_fallsBackToPreference() throws Exception { + try (MockedStatic platformUi = mockStatic(PlatformUI.class); + MockedStatic platform = mockStatic(Platform.class)) { + IWorkbench workbench = givenThemingAvailable(platformUi, platform); + givenActiveTheme(workbench, null); + givenPersistedThemeId(platform, DARK_THEME_ID); + + assertTrue(UiUtils.isDarkTheme()); + } + } + + // --------------------------------------------------------------------------- + // Theming unavailable: graceful fallback to the persisted preference. + // --------------------------------------------------------------------------- + + @Test + void isDarkTheme_themeBundleAbsent_fallsBackToPreferenceDark() { + try (MockedStatic platformUi = mockStatic(PlatformUI.class); + MockedStatic platform = mockStatic(Platform.class)) { + platform.when(() -> Platform.getBundle(THEME_BUNDLE)).thenReturn(null); + givenPersistedThemeId(platform, DARK_THEME_ID); + + assertTrue(UiUtils.isDarkTheme()); + } + } + + @Test + void isDarkTheme_themeBundleAbsent_fallsBackToPreferenceLight() { + try (MockedStatic platformUi = mockStatic(PlatformUI.class); + MockedStatic platform = mockStatic(Platform.class)) { + platform.when(() -> Platform.getBundle(THEME_BUNDLE)).thenReturn(null); + givenPersistedThemeId(platform, ""); + + assertFalse(UiUtils.isDarkTheme()); + } + } + + @Test + void isDarkTheme_themeServiceNull_fallsBackToPreference() throws Exception { + try (MockedStatic platformUi = mockStatic(PlatformUI.class); + MockedStatic platform = mockStatic(Platform.class)) { + IWorkbench workbench = givenThemingAvailable(platformUi, platform); + doReturn(null).when(workbench).getService(IThemeEngine.class); + givenPersistedThemeId(platform, DARK_THEME_ID); + + assertTrue(UiUtils.isDarkTheme()); + } + } + + @Test + void isDarkTheme_reflectionFailure_fallsBackToPreferenceWithoutThrowing() throws Exception { + try (MockedStatic platformUi = mockStatic(PlatformUI.class); + MockedStatic platform = mockStatic(Platform.class)) { + IWorkbench workbench = mock(IWorkbench.class); + platformUi.when(PlatformUI::getWorkbench).thenReturn(workbench); + + Bundle themeBundle = mock(Bundle.class); + doThrow(new ClassNotFoundException()).when(themeBundle) + .loadClass("org.eclipse.e4.ui.css.swt.theme.IThemeEngine"); + platform.when(() -> Platform.getBundle(THEME_BUNDLE)).thenReturn(themeBundle); + givenPersistedThemeId(platform, LIGHT_THEME_ID); + + assertFalse(UiUtils.isDarkTheme()); + } + } + + // --------------------------------------------------------------------------- + // Helpers. + // --------------------------------------------------------------------------- + + private static IWorkbench givenThemingAvailable(MockedStatic platformUi, + MockedStatic platform) throws ClassNotFoundException { + IWorkbench workbench = mock(IWorkbench.class); + platformUi.when(PlatformUI::getWorkbench).thenReturn(workbench); + + Bundle themeBundle = mock(Bundle.class); + doReturn(IThemeEngine.class).when(themeBundle) + .loadClass("org.eclipse.e4.ui.css.swt.theme.IThemeEngine"); + doReturn(ITheme.class).when(themeBundle) + .loadClass("org.eclipse.e4.ui.css.swt.theme.ITheme"); + platform.when(() -> Platform.getBundle(THEME_BUNDLE)).thenReturn(themeBundle); + return workbench; + } + + private static void givenActiveTheme(IWorkbench workbench, String themeId) { + IThemeEngine themeEngine = mock(IThemeEngine.class); + doReturn(themeEngine).when(workbench).getService(IThemeEngine.class); + + ITheme activeTheme = null; + if (themeId != null) { + activeTheme = mock(ITheme.class); + when(activeTheme.getId()).thenReturn(themeId); + } + when(themeEngine.getActiveTheme()).thenReturn(activeTheme); + } + + private static void givenPersistedThemeId(MockedStatic platform, String themeId) { + IPreferencesService preferences = mock(IPreferencesService.class); + platform.when(Platform::getPreferencesService).thenReturn(preferences); + when(preferences.getString(THEME_BUNDLE, "themeid", "", null)).thenReturn(themeId); + } +} From ea4400f960a89267ae6e92328ab33a171967f648 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 | 559 ++++++++++++++++++ 3 files changed, 586 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..3843247e --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ConversationRendererVisualDemoSideBySide.java @@ -0,0 +1,559 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import java.util.List; +import java.util.Map; + +import org.eclipse.core.commands.AbstractHandler; +import org.eclipse.core.commands.ExecutionEvent; +import org.eclipse.core.commands.ExecutionException; +import org.eclipse.e4.ui.css.swt.theme.IThemeEngine; +import org.eclipse.lsp4j.WorkDoneProgressKind; +import org.eclipse.swt.SWT; +import org.eclipse.swt.browser.Browser; +import org.eclipse.swt.custom.SashForm; +import org.eclipse.swt.layout.FillLayout; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Shell; +import org.mockito.Mockito; +import org.eclipse.ui.PlatformUI; + +import com.microsoft.copilot.eclipse.core.AuthStatusManager; +import com.microsoft.copilot.eclipse.core.CopilotCore; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationAction; +import com.microsoft.copilot.eclipse.core.chat.ConfirmationContent; +import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatProgressValue; +import com.microsoft.copilot.eclipse.core.lsp.protocol.Thinking; +import com.microsoft.copilot.eclipse.core.persistence.AbstractTurnData; +import com.microsoft.copilot.eclipse.core.persistence.ConversationDataFactory; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.AgentMessageData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.EditAgentRoundData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ErrorData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ErrorMessageData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ReplyData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ThinkingBlockData; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ThinkingBlockState; +import com.microsoft.copilot.eclipse.core.persistence.CopilotTurnData.ToolCallData; +import com.microsoft.copilot.eclipse.core.persistence.UserTurnData; +import com.microsoft.copilot.eclipse.core.persistence.UserTurnData.MessageData; +import com.microsoft.copilot.eclipse.ui.chat.services.AvatarService; +import com.microsoft.copilot.eclipse.ui.chat.services.ChatFontService; +import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager; + +/** + * Side-by-side visual comparison of {@link BrowserConversationWidget} (left) and + * {@link StyledTextConversationWidget} (right) rendering identical conversation content. + * + *

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

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