Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,9 @@ CompletableFuture.runAsync(() -> {

**When** adding user-facing text → externalize in `Messages.properties` using Eclipse NLS pattern

**When** naming focusable controls for accessibility → use non-empty names that are unique among sibling controls;
distinguish repeated controls with stable context or an ordinal

**When** implementing context menus → prefer simple, predictable visibility logic

**When** opening preference dialogs → show only relevant pages
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// 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.lang.reflect.Field;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;

import org.eclipse.swt.SWT;
import org.eclipse.swt.accessibility.Accessible;
import org.eclipse.swt.accessibility.AccessibleEvent;
import org.eclipse.swt.accessibility.AccessibleListener;
import org.eclipse.swt.custom.StyledText;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;

import com.microsoft.copilot.eclipse.ui.utils.SwtUtils;

/**
* Verifies accessible names exposed by turn content controls.
*/
@ExtendWith(MockitoExtension.class)
class BaseTurnWidgetAccessibilityTest extends BaseTurnWidgetTestSupport {

@Test
void appendMessage_multipleMarkupBlocks_haveUniqueAccessibleNames() {
AtomicReference<List<String>> accessibleNamesRef = new AtomicReference<>();
SwtUtils.invokeOnDisplayThread(() -> {
CopilotTurnWidget widget = new CopilotTurnWidget(shell, SWT.NONE, mockChatServiceManager, TURN_ID);

widget.appendMessage("First explanation\n```java\nint value = 1;\n```\nSecond explanation\n");

accessibleNamesRef.set(Arrays.stream(widget.getChildren())
.filter(StyledText.class::isInstance)
.map(StyledText.class::cast)
.map(BaseTurnWidgetAccessibilityTest::getAccessibleName)
.toList());
});
assertEquals(List.of("GitHub Copilot message 1", "GitHub Copilot message 2"), accessibleNamesRef.get());
}

@SuppressWarnings("unchecked")
private static String getAccessibleName(StyledText text) {
Accessible accessible = text.getAccessible();
try {
Field listenersField = Accessible.class.getDeclaredField("accessibleListeners");
listenersField.setAccessible(true);
List<AccessibleListener> listeners = (List<AccessibleListener>) listenersField.get(accessible);
AccessibleEvent event = new AccessibleEvent(accessible);
for (AccessibleListener listener : listeners) {
listener.getName(event);
}
return event.result;
} catch (ReflectiveOperationException e) {
throw new RuntimeException(e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,14 @@
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 static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;

import java.lang.reflect.Field;

import org.eclipse.swt.SWT;
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;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;

import com.microsoft.copilot.eclipse.ui.CopilotUi;
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;
import com.microsoft.copilot.eclipse.ui.utils.SwtUtils;

/**
Expand All @@ -36,48 +23,7 @@
* blocks where fence detection requires a complete line.
*/
@ExtendWith(MockitoExtension.class)
class BaseTurnWidgetPartialRenderTest {

private static final String TURN_ID = "turn-1";

private Shell shell;
private MockedStatic<CopilotUi> copilotUiMock;
private CopilotUi mockPlugin;

@Mock
private ChatServiceManager mockChatServiceManager;
@Mock
private AvatarService mockAvatarService;
@Mock
private ChatFontService mockChatFontService;

@BeforeEach
void setUp() {
lenient().when(mockChatServiceManager.getAvatarService()).thenReturn(mockAvatarService);
lenient().when(mockChatServiceManager.getChatFontService()).thenReturn(mockChatFontService);
lenient().when(mockAvatarService.getAvatarForCopilot()).thenReturn(null);

SwtUtils.invokeOnDisplayThread(() -> {
shell = new Shell(Display.getDefault());
copilotUiMock = mockStatic(CopilotUi.class);
mockPlugin = mock(CopilotUi.class);
copilotUiMock.when(CopilotUi::getPlugin).thenReturn(mockPlugin);
lenient().when(mockPlugin.getChatServiceManager()).thenReturn(mockChatServiceManager);
});
}

@AfterEach
void tearDown() {
SwtUtils.invokeOnDisplayThread(() -> {
if (copilotUiMock != null) {
copilotUiMock.close();
copilotUiMock = null;
}
if (shell != null && !shell.isDisposed()) {
shell.dispose();
}
});
}
class BaseTurnWidgetPartialRenderTest extends BaseTurnWidgetTestSupport {

@Test
void appendMessage_partialLineWithoutNewline_rendersImmediately() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

package com.microsoft.copilot.eclipse.ui.chat;

import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;

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.mockito.Mock;
import org.mockito.MockedStatic;

import com.microsoft.copilot.eclipse.ui.CopilotUi;
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;
import com.microsoft.copilot.eclipse.ui.utils.SwtUtils;

/**
* Shared SWT and service fixture for turn widget tests.
*/
abstract class BaseTurnWidgetTestSupport {

protected static final String TURN_ID = "turn-1";

protected Shell shell;

@Mock
protected ChatServiceManager mockChatServiceManager;
@Mock
private AvatarService mockAvatarService;
@Mock
private ChatFontService mockChatFontService;

private MockedStatic<CopilotUi> copilotUiMock;

@BeforeEach
void setUpBaseTurnWidget() {
lenient().when(mockChatServiceManager.getAvatarService()).thenReturn(mockAvatarService);
lenient().when(mockChatServiceManager.getChatFontService()).thenReturn(mockChatFontService);
lenient().when(mockAvatarService.getAvatarForCopilot()).thenReturn(null);

SwtUtils.invokeOnDisplayThread(() -> {
shell = new Shell(Display.getDefault());
copilotUiMock = mockStatic(CopilotUi.class);
CopilotUi mockPlugin = mock(CopilotUi.class);
copilotUiMock.when(CopilotUi::getPlugin).thenReturn(mockPlugin);
lenient().when(mockPlugin.getChatServiceManager()).thenReturn(mockChatServiceManager);
});
}

@AfterEach
void tearDownBaseTurnWidget() {
SwtUtils.invokeOnDisplayThread(() -> {
if (copilotUiMock != null) {
copilotUiMock.close();
copilotUiMock = null;
}
if (shell != null && !shell.isDisposed()) {
shell.dispose();
}
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,8 @@ public void updateButtonsLayout() {
this.sendToJobButton.setEnabled(hasText);
this.sendToJobButton.setImage(hasText ? sendToJobImage : sendToJobDisabledImage);
this.sendToJobButton.setToolTipText(Messages.chat_actionBar_sendToJobButton_Tooltip);
AccessibilityUtils.addAccessibilityNameForUiComponent(this.sendToJobButton,
Messages.chat_actionBar_sendToJobButton_Tooltip);
GridData sendToJobGd = new GridData(SWT.LEFT, SWT.CENTER, false, false);
sendToJobGd.widthHint = sendToJobImage.getImageData().width + 2 * UiConstants.BTN_PADDING;
sendToJobGd.heightHint = sendToJobImage.getImageData().height + 2 * UiConstants.BTN_PADDING;
Expand Down Expand Up @@ -550,6 +552,8 @@ private void setUpMcpToolButtonInControlBar(Composite parent) {
this.mcpToolButton = UiUtils.createIconButton(parent, SWT.PUSH | SWT.FLAT);
this.chatServiceManager.getMcpConfigService().bindWithMcpToolButton(mcpToolButton, mcpToolImage,
mcpToolDisabledImage, mcpToolDetectedImage);
AccessibilityUtils.addAccessibilityNameForUiComponent(this.mcpToolButton,
Messages.chat_actionBar_toolButton_accessibilityName);

GridData mcpToolGd = new GridData(SWT.LEFT, SWT.CENTER, false, false);
mcpToolGd.widthHint = mcpToolImage.getImageData().width + 2 * UiConstants.BTN_PADDING;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import com.microsoft.copilot.eclipse.core.Constants;
import com.microsoft.copilot.eclipse.core.lsp.protocol.codingagent.CodingAgentMessageRequestParams;
import com.microsoft.copilot.eclipse.ui.swt.CssConstants;
import com.microsoft.copilot.eclipse.ui.utils.AccessibilityUtils;
import com.microsoft.copilot.eclipse.ui.utils.UiUtils;

/**
Expand Down Expand Up @@ -70,6 +71,8 @@ private void buildMessageLabelWithIcon(CodingAgentMessageRequestParams params) {
StyledText titleText = titleLabel.getTextWidget();
titleText.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
titleText.setEditable(false);
AccessibilityUtils.addAccessibilityNameForUiComponent(titleText,
Messages.agentMessageWidget_titleAccessibilityName);
titleLabel.setMarkup("**" + params.getTitle() + "**");
}

Expand All @@ -83,6 +86,8 @@ private void buildMessageLabelWithIcon(CodingAgentMessageRequestParams params) {
StyledText descText = descLabel.getTextWidget();
descText.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
descText.setEditable(false);
AccessibilityUtils.addAccessibilityNameForUiComponent(descText,
Messages.agentMessageWidget_messageAccessibilityName);

String reducedDescription = StringUtils.isBlank(params.getDescription()) ? ""
: params.getDescription().substring(0, 100) + "...";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ public void setText(String text) {
styledText.setEditable(false);
styledText.setData(CssConstants.CSS_CLASS_NAME_KEY, "text-secondary");
AccessibilityUtils.addFocusBorderToComposite(styledText);
AccessibilityUtils.addAccessibilityNameForUiComponent(styledText,
Messages.agentStatusLabel_accessibilityName);
}
textLabel.setMarkup(text);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ public abstract class BaseTurnWidget extends Composite {
protected int codeBlockIndex;
protected boolean inSubagentBlock;
protected String overrideRoleName;
private int textBlockIndex;

// Resource
protected Image icon = null;
Expand Down Expand Up @@ -106,6 +107,7 @@ protected BaseTurnWidget(Composite parent, int style, ChatServiceManager service
this.isCopilot = isCopilot;
this.turnId = turnId;
this.codeBlockIndex = 1;
this.textBlockIndex = 1;
this.statusLabels = new HashMap<>();
this.subagentBlocks = new HashMap<>();
// editor group
Expand Down Expand Up @@ -577,6 +579,13 @@ private void createCodeBlock(String language) {
*/
protected abstract void createTextBlock();

/**
* Returns the next unique accessible name for a text block in this turn.
*/
protected String nextTextBlockAccessibilityName(String pattern) {
return NLS.bind(pattern, textBlockIndex++);
}

/**
* Create an optional footer component. Subclasses can override this to define footer structure.
* The footer is placed at the bottom of the turn widget.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ protected void createTextBlock() {
styledText.setEditable(false);

AccessibilityUtils.addFocusBorderToComposite(styledText);
AccessibilityUtils.addAccessibilityNameForUiComponent(styledText,
nextTextBlockAccessibilityName(Messages.chat_copilotTurnWidget_message_accessibilityName));
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ public final class Messages extends NLS {
public static String agentMessageWidget_openInBrowserTooltip;
public static String agentMessageWidget_openJobListButton;
public static String agentMessageWidget_openJobListTooltip;
public static String agentMessageWidget_titleAccessibilityName;
public static String agentMessageWidget_messageAccessibilityName;
public static String subagentTurnWidget_messageAccessibilityName;
public static String agentStatusLabel_accessibilityName;
public static String sourceViewerComposite_codeBlockAccessibilityName;
public static String sourceViewerComposite_copyButtonAccessibilityName;
public static String sourceViewerComposite_insertButtonAccessibilityName;
public static String handoffContainer_proceedFrom;
public static String fileChangeSummary_filesChanged;
public static String fileChangeSummary_fileChanged;
Expand All @@ -40,6 +47,8 @@ public final class Messages extends NLS {
public static String thinking_title;
public static String thinking_expandTooltip;
public static String thinking_collapseTooltip;
public static String thinkingSection_headingAccessibilityName;
public static String thinkingSection_detailsAccessibilityName;

// Confirmation dialog action labels
public static String confirmation_action_allowOnce;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ public void mouseExit(MouseEvent e) {
};
serviceManager.getChatFontService().registerCallback(fontChangeCallback);
AccessibilityUtils.addFocusBorderToComposite(styledText);
AccessibilityUtils.addAccessibilityNameForUiComponent(styledText,
Messages.sourceViewerComposite_codeBlockAccessibilityName);

return viewer;
}
Expand All @@ -172,7 +174,8 @@ private Composite createActionsComposite() {
result.setLayout(layout);

this.copyIcon = PlatformUI.getWorkbench().getSharedImages().getImage(ISharedImages.IMG_TOOL_COPY);
Button copyButton = createActionButton(result, SWT.PUSH | SWT.FLAT, copyIcon, "Copy", "Copy to clipboard");
Button copyButton = createActionButton(result, SWT.PUSH | SWT.FLAT, copyIcon, "Copy", "Copy to clipboard",
Messages.sourceViewerComposite_copyButtonAccessibilityName);
copyButton.addListener(SWT.Selection, e -> {
String content = this.sourceViewer.getDocument().get();
if (StringUtils.isNotEmpty(content)) {
Expand All @@ -187,17 +190,19 @@ private Composite createActionsComposite() {
Image pasteIcon = PlatformUI.getWorkbench().getSharedImages().getImage(ISharedImages.IMG_TOOL_PASTE);
Image insertButtonIcon = this.insertIcon != null ? this.insertIcon : pasteIcon;
Button insertButton = createActionButton(result, SWT.PUSH | SWT.FLAT, insertButtonIcon, "Insert",
"Insert into editor");
"Insert into editor", Messages.sourceViewerComposite_insertButtonAccessibilityName);
insertButton.addListener(SWT.Selection, this::insert);

result.setVisible(false);
result.pack();
return result;
}

private Button createActionButton(Composite parent, int style, Image image, String text, String tooltip) {
private Button createActionButton(Composite parent, int style, Image image, String text, String tooltip,
String accessibilityName) {
Button result = new Button(parent, style);
result.setToolTipText(tooltip);
AccessibilityUtils.addAccessibilityNameForUiComponent(result, accessibilityName);
result.setVisible(true);
if (image == null) {
result.setText(text);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentToolCall;
import com.microsoft.copilot.eclipse.ui.chat.services.AvatarService;
import com.microsoft.copilot.eclipse.ui.chat.services.ChatServiceManager;
import com.microsoft.copilot.eclipse.ui.utils.AccessibilityUtils;

/**
* A turn widget for displaying subagent messages within a SubagentMessageBlock.
Expand Down Expand Up @@ -69,5 +70,7 @@ protected void createTextBlock() {
StyledText styledText = this.currentTextBlock.getTextWidget();
styledText.setLayoutData(new GridData(SWT.LEFT, SWT.FILL, true, false));
styledText.setEditable(false);
AccessibilityUtils.addAccessibilityNameForUiComponent(styledText,
nextTextBlockAccessibilityName(Messages.subagentTurnWidget_messageAccessibilityName));
}
}
Loading
Loading