From 618003d6cfeb1867c921c6bf199e44eae957be0d Mon Sep 17 00:00:00 2001 From: Sheng Chen Date: Wed, 29 Jul 2026 15:57:09 +0800 Subject: [PATCH] fix: preserve chat cancel state during active turns Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fbabf492-eab9-4d8c-bb95-87b1df6ff332 --- ACTION_BAR_LAYOUT_OPTIMIZATION.md | 40 ++++++ CONTEXT.md | 8 ++ .../test-plans/subagent/subagent.md | 41 ++++++ .../chat/ActionBarActionButtonStateTest.java | 131 ++++++++++++++++++ .../copilot/eclipse/ui/chat/ActionBar.java | 128 ++++++++++------- .../copilot/eclipse/ui/chat/ChatView.java | 25 ++-- 6 files changed, 310 insertions(+), 63 deletions(-) create mode 100644 ACTION_BAR_LAYOUT_OPTIMIZATION.md create mode 100644 com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ActionBarActionButtonStateTest.java diff --git a/ACTION_BAR_LAYOUT_OPTIMIZATION.md b/ACTION_BAR_LAYOUT_OPTIMIZATION.md new file mode 100644 index 000000000..2cfb869b1 --- /dev/null +++ b/ACTION_BAR_LAYOUT_OPTIMIZATION.md @@ -0,0 +1,40 @@ +# Action Bar Layout Optimization + +## Context + +`ActionBar.updateButtonsLayout()` currently disposes and recreates the Send or +Cancel button whenever the chat view or feature-flag-driven button topology is +refreshed. The state refactor intentionally keeps this layout behavior and +restores the current `ActionButtonState` after each rebuild. + +Layout optimization is deferred so state correctness and widget lifecycle +changes can be reviewed independently. + +## Current Costs and Risks + +- Recreating controls churns selection listeners and accessibility metadata. +- Send button images are disposed and reloaded with the control. +- Rebuilds can cause unnecessary layout work and visible flicker. +- Future code can accidentally initialize a recreated control instead of + rendering the current state. +- Asynchronous feature flag notifications can queue redundant rebuilds. + +## Preferred Follow-up + +Keep both action controls stable after construction: + +1. Create the primary Send or Cancel button once. +2. Create the optional coding-agent button once. +3. Toggle visibility and `GridData.exclude` when preview availability changes. +4. Adjust the parent column count without disposing either control. +5. Render the existing `ActionButtonState` after topology changes. +6. Coalesce redundant asynchronous refresh requests when practical. + +## Validation + +- Switch between Ask and Agent while a turn is running. +- Deliver repeated feature flag notifications while a turn is running. +- Toggle preview availability in both idle and running states. +- Confirm keyboard focus, accessibility names, and tooltips remain correct. +- Confirm images and listeners are disposed exactly once with the ActionBar. +- Compare layout and repaint frequency before and after the optimization. diff --git a/CONTEXT.md b/CONTEXT.md index bb6b45cb7..b66e51b70 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -11,6 +11,14 @@ A delegated agent that the main agent spawns to carry out a scoped sub-task on its behalf. The main agent invokes it through the `run_subagent` tool and receives the sub-agent's result back as part of its own turn. +### Top-level turn +A user-initiated unit of chat work that owns the chat-wide cancellation and +completion lifecycle. + +### Sub-agent turn +A delegated unit of chat work nested within a top-level turn. Its completion +returns control to the parent and does not complete the top-level turn. + ### Sub-agent policy Organization-level governance that decides whether sub-agents are permitted for a user. It is authoritative and is **enforced by the language server**, not by diff --git a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/subagent/subagent.md b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/subagent/subagent.md index 49b667abc..cc81c11b4 100644 --- a/com.microsoft.copilot.eclipse.swtbot.test/test-plans/subagent/subagent.md +++ b/com.microsoft.copilot.eclipse.swtbot.test/test-plans/subagent/subagent.md @@ -267,3 +267,44 @@ Not exercised: `setMinHeight()` was never recomputed after the dialog disposal. - Verify the Allow path is unaffected: clicking **Allow** on a separate invocation should still lay out correctly (no regression). + +--- + +## 7. Top-level turn lifecycle + +### TC-008: Cancel remains available after a subagent turn completes + +**Type:** `Regression` +**Priority:** `P0` + +#### Preconditions +- The Chat view is open in Agent mode. +- A new conversation (fresh session). +- Subagents are available for the signed-in account. + +#### Steps +1. Send a prompt that requires both subagent work and a main-agent follow-up, + for example: `Use a subagent to inspect this project for TODO comments. + After the subagent completes, continue in the main agent and summarize the + findings.` +2. Wait for the `SubagentMessageBlock` to appear and verify that the action + button shows **Cancel**. +3. Wait until the subagent card reports completion. +4. While the main agent continues working or streams its summary, verify that + the action button still shows **Cancel**. +5. Wait for the main agent's top-level turn to complete. + +#### Expected Result +- Completing the subagent turn does not change the action button to **Send**. +- **Cancel** remains available while the main agent continues the top-level + turn. +- The button changes to **Send** only after the main agent's top-level turn + completes. + +#### Key Screenshots +- [ ] **Subagent completed** - completed subagent card with Cancel still shown. +- [ ] **Top-level turn completed** - main-agent summary complete and Send shown. + +#### Notes on failure modes +- Send appears while the main agent is still working - a subagent progress + `end` was incorrectly treated as completion of the top-level turn. diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ActionBarActionButtonStateTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ActionBarActionButtonStateTest.java new file mode 100644 index 000000000..5cd71b52b --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ActionBarActionButtonStateTest.java @@ -0,0 +1,131 @@ +// 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.lang.reflect.Method; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class ActionBarActionButtonStateTest { + private static final String SEND_ENABLED = "SEND_ENABLED"; + private static final String SEND_DISABLED = "SEND_DISABLED"; + private static final String CANCEL_ENABLED = "CANCEL_ENABLED"; + private static final String TURN_STARTED = "TURN_STARTED"; + private static final String TURN_FINISHED = "TURN_FINISHED"; + private static final String INPUT_CHANGED = "INPUT_CHANGED"; + private static final Class STATE_TYPE = loadStateType(); + private static final Class EVENT_TYPE = loadEventType(); + + @ParameterizedTest + @ValueSource(strings = { SEND_ENABLED, SEND_DISABLED, CANCEL_ENABLED }) + void testOnTurnStarted_anyState_returnsCancelEnabled(String stateName) { + assertEquals(CANCEL_ENABLED, stateName(nextState(stateName, TURN_STARTED, false))); + } + + @ParameterizedTest + @ValueSource(strings = { SEND_ENABLED, SEND_DISABLED, CANCEL_ENABLED }) + void testOnTurnFinished_withInput_returnsSendEnabled(String stateName) { + assertEquals(SEND_ENABLED, stateName(nextState(stateName, TURN_FINISHED, true))); + } + + @ParameterizedTest + @ValueSource(strings = { SEND_ENABLED, SEND_DISABLED, CANCEL_ENABLED }) + void testOnTurnFinished_withoutInput_returnsSendDisabled(String stateName) { + assertEquals(SEND_DISABLED, stateName(nextState(stateName, TURN_FINISHED, false))); + } + + @Test + void testOnInputChanged_runningTurn_keepsCancelEnabled() { + assertEquals(CANCEL_ENABLED, stateName(nextState(CANCEL_ENABLED, INPUT_CHANGED, true))); + assertEquals(CANCEL_ENABLED, stateName(nextState(CANCEL_ENABLED, INPUT_CHANGED, false))); + } + + @Test + void testOnInputChanged_idleTurn_reflectsInputPresence() { + assertEquals(SEND_ENABLED, stateName(nextState(SEND_DISABLED, INPUT_CHANGED, true))); + assertEquals(SEND_DISABLED, stateName(nextState(SEND_ENABLED, INPUT_CHANGED, false))); + } + + @Test + void testRepeatedTransitions_sameInput_areIdempotent() { + Object startedTwice = nextState(nextState(CANCEL_ENABLED, TURN_STARTED, false), TURN_STARTED, false); + Object enabledTwice = nextState(nextState(SEND_ENABLED, TURN_FINISHED, true), TURN_FINISHED, true); + Object disabledTwice = nextState(nextState(SEND_DISABLED, TURN_FINISHED, false), TURN_FINISHED, false); + + assertEquals(CANCEL_ENABLED, stateName(startedTwice)); + assertEquals(SEND_ENABLED, stateName(enabledTwice)); + assertEquals(SEND_DISABLED, stateName(disabledTwice)); + } + + @Test + void testIsTurnRunning_onlyCancelEnabled_returnsTrue() { + assertTrue(invokeBoolean(state(CANCEL_ENABLED), "isTurnRunning")); + assertFalse(invokeBoolean(state(SEND_ENABLED), "isTurnRunning")); + assertFalse(invokeBoolean(state(SEND_DISABLED), "isTurnRunning")); + } + + private static Class loadStateType() { + return loadNestedType("ActionButtonState"); + } + + private static Class loadEventType() { + return loadNestedType("ActionButtonEvent"); + } + + private static Class loadNestedType(String simpleName) { + try { + return Class.forName(ActionBar.class.getName() + "$" + simpleName); + } catch (ClassNotFoundException e) { + throw new AssertionError(simpleName + " is missing", e); + } + } + + private static Object enumConstant(Class enumType, String name) { + for (Object value : enumType.getEnumConstants()) { + if (name.equals(stateName(value))) { + return value; + } + } + throw new AssertionError("Unknown " + enumType.getSimpleName() + ": " + name); + } + + private static String stateName(Object state) { + return ((Enum) state).name(); + } + + private static Object nextState(String stateName, String eventName, boolean hasInput) { + return nextState(enumConstant(STATE_TYPE, stateName), eventName, hasInput); + } + + private static Object nextState(Object state, String eventName, boolean hasInput) { + try { + Method method = ActionBar.class.getDeclaredMethod("nextActionButtonState", STATE_TYPE, EVENT_TYPE, + boolean.class); + method.setAccessible(true); + return method.invoke(null, state, enumConstant(EVENT_TYPE, eventName), hasInput); + } catch (ReflectiveOperationException e) { + throw new AssertionError("Failed to invoke ActionBar.nextActionButtonState", e); + } + } + + private static boolean invokeBoolean(Object state, String methodName) { + try { + Method method = STATE_TYPE.getDeclaredMethod(methodName); + method.setAccessible(true); + return (Boolean) method.invoke(state); + } catch (ReflectiveOperationException e) { + throw new AssertionError("Failed to invoke ActionButtonState." + methodName, e); + } + } + + private static Object state(String name) { + return enumConstant(STATE_TYPE, name); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ActionBar.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ActionBar.java index 2c98738f6..bdaf67270 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ActionBar.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ActionBar.java @@ -104,7 +104,7 @@ public class ActionBar extends Composite implements NewConversationListener { private ContentAssistant ca; private Image sendImage; private Image sendDisabledImage; - private boolean isSendButton = true; + private volatile ActionButtonState actionButtonState = ActionButtonState.SEND_DISABLED; private LinkedHashSet messageListeners = new LinkedHashSet<>(); private Button mcpToolButton; private Image mcpToolImage; @@ -127,10 +127,6 @@ public class ActionBar extends Composite implements NewConversationListener { EventHandler featureFlagsChangedEventHandler; EventHandler updateMcpToolButtonAndPlaceHolderHandler; - private static enum SendOrCancelButtonStates { - SEND_ENABLED, SEND_DISABLED, CANCEL_ENABLED; - } - /** * Creates a new InputArea. */ @@ -145,7 +141,7 @@ public ActionBar(Composite parent, int style, ChatServiceManager chatServiceMana this.setData(CssConstants.CSS_ID_KEY, "chat-action-bar-wrapper"); this.chatServiceManager = chatServiceManager; this.updateSendButtonToCancelButtonHandler = event -> { - updateButtonState(SendOrCancelButtonStates.CANCEL_ENABLED); + markTurnStarted(); }; this.eventBroker = PlatformUI.getWorkbench().getService(IEventBroker.class); this.eventBroker.subscribe(CopilotEventConstants.TOPIC_CHAT_ON_SEND, updateSendButtonToCancelButtonHandler); @@ -233,18 +229,11 @@ public ActionBar(Composite parent, int style, ChatServiceManager chatServiceMana tv.addTextListener(new ITextListener() { @Override public void textChanged(TextEvent event) { - if (!isSendButton) { - return; - } - if (tv.getDocument().get().equals(StringUtils.EMPTY)) { - updateButtonState(SendOrCancelButtonStates.SEND_DISABLED); - } else { - updateButtonState(SendOrCancelButtonStates.SEND_ENABLED); - } + transitionActionButtonState(ActionButtonEvent.INPUT_CHANGED); } }); tv.setSendMessageHandler((message) -> { - if (isSendButton) { + if (!isTurnRunning()) { handleSendMessage(); } }); @@ -446,9 +435,6 @@ public void updateButtonsLayout() { this.sendToJobButton = UiUtils.createIconButton(this.bottomRightButtonsComposite, SWT.PUSH | SWT.FLAT); - boolean hasText = !StringUtils.isBlank(this.inputTextViewer.getContent()); - this.sendToJobButton.setEnabled(hasText); - this.sendToJobButton.setImage(hasText ? sendToJobImage : sendToJobDisabledImage); this.sendToJobButton.setToolTipText(Messages.chat_actionBar_sendToJobButton_Tooltip); GridData sendToJobGd = new GridData(SWT.LEFT, SWT.CENTER, false, false); sendToJobGd.widthHint = sendToJobImage.getImageData().width + 2 * UiConstants.BTN_PADDING; @@ -468,10 +454,6 @@ public void widgetSelected(SelectionEvent e) { this.sendImage = UiUtils.buildImageFromPngPath("/icons/chat/send.png"); this.sendDisabledImage = UiUtils.buildImageFromPngPath("/icons/chat/send_disabled.png"); this.btnMsgToggle = UiUtils.createIconButton(bottomRightButtonsComposite, SWT.PUSH | SWT.FLAT); - boolean isEnabled = !StringUtils.isBlank(this.inputTextViewer.getContent()); - this.btnMsgToggle.setEnabled(isEnabled); - this.btnMsgToggle.setImage(isEnabled ? this.sendImage : this.sendDisabledImage); - this.btnMsgToggle.setToolTipText(Messages.chat_actionBar_sendButton_Tooltip); GridData sendGd = new GridData(SWT.RIGHT, SWT.CENTER, false, false); sendGd.widthHint = this.sendImage.getImageData().width + 2 * UiConstants.BTN_PADDING; sendGd.heightHint = this.sendImage.getImageData().height + 2 * UiConstants.BTN_PADDING; @@ -479,10 +461,10 @@ public void widgetSelected(SelectionEvent e) { this.btnMsgToggle.addSelectionListener(new SelectionAdapter() { @Override public void widgetSelected(org.eclipse.swt.events.SelectionEvent e) { - if (isSendButton) { - handleSendMessage(); - } else { + if (isTurnRunning()) { handleCancelMessage(); + } else { + handleSendMessage(); } } }); @@ -497,6 +479,7 @@ public void widgetSelected(org.eclipse.swt.events.SelectionEvent e) { AccessibilityUtils.addAccessibilityNameForUiComponent(this.btnMsgToggle, Messages.chat_actionBar_sendButton_Tooltip); } + renderCurrentState(); // Refresh the layout this.bottomRightButtonsComposite.requestLayout(); } @@ -731,20 +714,34 @@ private boolean isJavaDebuggerToolEnabledForCurrentMode() { @Override public void onNewConversation() { - resetSendButton(); + markTurnFinished(); disposeStaticBanner(); } /** - * Handles the cancel message event. + * Marks a top-level chat turn as started. */ - public void resetSendButton() { - if (this.inputTextViewer.getContent().isEmpty()) { - updateButtonState(SendOrCancelButtonStates.SEND_DISABLED); - } else { - updateButtonState(SendOrCancelButtonStates.SEND_ENABLED); + public void markTurnStarted() { + transitionActionButtonState(ActionButtonEvent.TURN_STARTED); + } + + /** + * Marks a top-level chat turn as finished. + */ + public void markTurnFinished() { + transitionActionButtonState(ActionButtonEvent.TURN_FINISHED); + if (!isDisposed()) { + this.chatServiceManager.getFileToolService().setWorkingSetBarButtonStatus(true); } - this.chatServiceManager.getFileToolService().setWorkingSetBarButtonStatus(true); + } + + /** + * Returns whether a top-level chat turn is running. + * + * @return {@code true} while a top-level turn is running + */ + public boolean isTurnRunning() { + return actionButtonState.isTurnRunning(); } /** @@ -780,7 +777,7 @@ public void setInputTextViewerContent(String content) { * Handles the send message event. */ public void handleSendMessage() { - updateButtonState(SendOrCancelButtonStates.CANCEL_ENABLED); + markTurnStarted(); String message = this.inputTextViewer.getContent(); String workDoneToken = UUID.randomUUID().toString(); this.inputTextViewer.setContent(StringUtils.EMPTY); @@ -813,18 +810,13 @@ private void handleSendToJob() { // Only proceed if a project path was selected (dialog was not cancelled) if (selectedProjectPath != null) { - updateButtonState(SendOrCancelButtonStates.CANCEL_ENABLED); + markTurnStarted(); String message = this.inputTextViewer.getContent(); String workDoneToken = UUID.randomUUID().toString(); this.inputTextViewer.setContent(StringUtils.EMPTY); notifySendWithSlug(workDoneToken, message, UiConstants.GITHUB_COPILOT_CODING_AGENT_SLUG, selectedProjectPath); } else { - // Dialog was cancelled, reset button state based on current input content - if (this.inputTextViewer.getContent().isEmpty()) { - updateButtonState(SendOrCancelButtonStates.SEND_DISABLED); - } else { - updateButtonState(SendOrCancelButtonStates.SEND_ENABLED); - } + transitionActionButtonState(ActionButtonEvent.INPUT_CHANGED); } } } @@ -832,7 +824,7 @@ private void handleSendToJob() { } private void handleCancelMessage() { - resetSendButton(); + markTurnFinished(); notifyCancel(); IEventBroker eventBroker = PlatformUI.getWorkbench().getService(IEventBroker.class); eventBroker.post(CopilotEventConstants.TOPIC_CHAT_MESSAGE_CANCELLED, null); @@ -862,28 +854,42 @@ public void unregisterMessageListener(MessageListener listener) { this.messageListeners.remove(listener); } - /** - * Returns the current action bar conversation state. Return true if the conversation is stand by or cancelled, false - * otherwise - */ - public boolean isSendButton() { - return isSendButton; + private void transitionActionButtonState(ActionButtonEvent event) { + if (isDisposed()) { + return; + } + SwtUtils.invokeOnDisplayThread(() -> { + if (isDisposed()) { + return; + } + boolean hasInput = inputTextViewer != null && StringUtils.isNotBlank(inputTextViewer.getContent()); + actionButtonState = nextActionButtonState(actionButtonState, event, hasInput); + renderCurrentState(); + }, this); } - private void updateButtonState(SendOrCancelButtonStates state) { - switch (state) { + private static ActionButtonState nextActionButtonState(ActionButtonState currentState, ActionButtonEvent event, + boolean hasInput) { + return switch (event) { + case TURN_STARTED -> ActionButtonState.CANCEL_ENABLED; + case TURN_FINISHED -> hasInput ? ActionButtonState.SEND_ENABLED : ActionButtonState.SEND_DISABLED; + case INPUT_CHANGED -> currentState.isTurnRunning() + ? currentState + : hasInput ? ActionButtonState.SEND_ENABLED : ActionButtonState.SEND_DISABLED; + }; + } + + private void renderCurrentState() { + switch (actionButtonState) { case SEND_ENABLED: - isSendButton = true; updateSendOrCancelMsgBtn(true, sendImage, Messages.chat_actionBar_sendButton_Tooltip); updateSendToJobBtn(true); break; case SEND_DISABLED: - isSendButton = true; updateSendOrCancelMsgBtn(false, sendDisabledImage, Messages.chat_actionBar_sendButton_Tooltip); updateSendToJobBtn(false); break; case CANCEL_ENABLED: - isSendButton = false; Image cancelImage = PlatformUI.getWorkbench().getSharedImages().getImage(ISharedImages.IMG_ELCL_STOP); updateSendOrCancelMsgBtn(true, cancelImage, Messages.chat_actionBar_cancelButton_Tooltip); updateSendToJobBtn(false); @@ -1131,4 +1137,20 @@ private void openMcpPreferences() { dialog.open(); } + + private enum ActionButtonState { + SEND_ENABLED, + SEND_DISABLED, + CANCEL_ENABLED; + + boolean isTurnRunning() { + return this == CANCEL_ENABLED; + } + } + + private enum ActionButtonEvent { + TURN_STARTED, + TURN_FINISHED, + INPUT_CHANGED + } } 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 30ceac7ef..aada075c9 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 @@ -854,7 +854,7 @@ private void clearChatView(Composite composite) { */ @Override public void onChatProgress(ChatProgressValue value) { - if (this.actionBar.isSendButton()) { + if (!this.actionBar.isTurnRunning()) { return; } switch (value.getKind()) { @@ -982,9 +982,12 @@ public void onChatProgress(ChatProgressValue value) { return; } + boolean isTopLevelTurn = StringUtils.isBlank(value.getParentTurnId()); if (this.chatContentViewer != null) { this.chatContentViewer.processTurnEvent(value); - this.actionBar.resetSendButton(); + } + if (isTopLevelTurn) { + this.actionBar.markTurnFinished(); this.topBanner.updateTitle(value.getSuggestedTitle()); } String endThinkingBlockId = this.chatContentViewer != null @@ -995,7 +998,7 @@ public void onChatProgress(ChatProgressValue value) { persistenceManager.persistConversationProgress(this.conversationId, value, endThinkingBlockId); // Persist todo list at end phase - TodoListService todoListService = chatServiceManager.getTodoListService(); + TodoListService todoListService = isTopLevelTurn ? chatServiceManager.getTodoListService() : null; if (todoListService != null) { List todos = todoListService.getTodoList(); if (todos != null && !todos.isEmpty()) { @@ -1005,11 +1008,13 @@ public void onChatProgress(ChatProgressValue value) { } // Show handoff container when turn finishes - Display.getDefault().asyncExec(() -> { - if (handoffContainer != null && !handoffContainer.isDisposed()) { - handoffContainer.show(); - } - }); + if (isTopLevelTurn) { + Display.getDefault().asyncExec(() -> { + if (handoffContainer != null && !handoffContainer.isDisposed()) { + handoffContainer.show(); + } + }); + } break; default: break; @@ -1275,7 +1280,7 @@ private void displayErrorAndResetSendButton(String workDoneToken, String message String content = String.format(Messages.chat_chatContentView_errorTemplate, message, workDoneToken); SwtUtils.invokeOnDisplayThread(() -> { chatContentViewer.renderErrorMessage(content); - actionBar.resetSendButton(); + actionBar.markTurnFinished(); }, parent); } @@ -1416,7 +1421,7 @@ public void onCancel() { // Reset send button in case the conversation was cancelled while in-progress if (this.actionBar != null && !this.actionBar.isDisposed()) { - this.actionBar.resetSendButton(); + this.actionBar.markTurnFinished(); } if (this.chatContentViewer != null && !this.chatContentViewer.isDisposed()) { this.chatContentViewer.hideCompactingStatusOnLatestCopilotTurn();