diff --git a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClientTests.java b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClientTests.java index 0ebad63b..52e9f3c7 100644 --- a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClientTests.java +++ b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClientTests.java @@ -131,4 +131,4 @@ void testOnDidChangeFeatureFlagsWithEmptyFeatureFlags() { verify(mockFeatureFlags).setByokEnabled(true); } } -} \ No newline at end of file +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/events/CopilotEventConstants.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/events/CopilotEventConstants.java index e61052cb..1e34f256 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/events/CopilotEventConstants.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/events/CopilotEventConstants.java @@ -30,6 +30,11 @@ public class CopilotEventConstants { */ private static final String TOPIC_MCP = TOPIC_BASE + "MCP/"; + /** + * Topic for quota events. + */ + private static final String TOPIC_QUOTA = TOPIC_BASE + "QUOTA/"; + /** * Topic for Next Edit Suggestion (NES) events. */ @@ -161,6 +166,11 @@ public class CopilotEventConstants { */ public static final String TOPIC_RATE_LIMIT_WARNING = TOPIC_CHAT + "RATE_LIMIT_WARNING"; + /** + * Event when a quota warning notification is received from the language server. + */ + public static final String TOPIC_QUOTA_WARNING = TOPIC_QUOTA + "WARNING"; + /** * Event when custom prompts, skills, agents, or instructions change on the language server. Clients should re-fetch * conversation templates on receipt. diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClient.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClient.java index df5ebb4a..c3897ad9 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClient.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageClient.java @@ -64,6 +64,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.codingagent.CodingAgentMessageRequestParams; import com.microsoft.copilot.eclipse.core.lsp.protocol.codingagent.CodingAgentMessageResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.policy.DidChangePolicyParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.QuotaWarningParams; import com.microsoft.copilot.eclipse.core.utils.FileUtils; import com.microsoft.copilot.eclipse.core.utils.PlatformUtils; @@ -353,6 +354,16 @@ public CompletableFuture onCodingAgentMessage(CodingAg return CompletableFuture.completedFuture(result); } + /** + * Notify when a quota warning is received from the language server. + */ + @JsonNotification("copilot/quotaWarning") + public void onQuotaWarning(QuotaWarningParams params) { + if (eventBroker != null) { + eventBroker.post(CopilotEventConstants.TOPIC_QUOTA_WARNING, params); + } + } + /** * Reads the contents and stats of a file given its URI. */ diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ChatProgressValue.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ChatProgressValue.java index aa95925a..3929af0b 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ChatProgressValue.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ChatProgressValue.java @@ -101,6 +101,15 @@ public String getErrorReason() { return error != null ? error.getReason() : null; } + /** + * Returns the BYOK model-provider name from the error payload, when present. + * + * @return the name of the BYOK model provider that produced the error, or {@code null} for built-in models. + */ + public String getErrorModelProviderName() { + return error != null ? error.getModelProviderName() : null; + } + public List getAgentRounds() { return editAgentRounds; } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationError.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationError.java index d4c883bc..a8b52a77 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationError.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ConversationError.java @@ -19,6 +19,7 @@ public class ConversationError { private String reason; private boolean responseIsIncomplete; private boolean responseIsFiltered; + private String modelProviderName; public void setMessage(String message) { this.message = message; @@ -40,6 +41,10 @@ public void setResponseIsFiltered(boolean responseIsFiltered) { this.responseIsFiltered = responseIsFiltered; } + public void setModelProviderName(String modelProviderName) { + this.modelProviderName = modelProviderName; + } + public String getMessage() { return message; } @@ -60,9 +65,17 @@ public boolean getResponseIsFiltered() { return responseIsFiltered; } + /** + * The name of the model provider that produced the error, when the failing request was routed to a custom + * Bring-Your-Own-Key (BYOK) model. {@code null} or blank for built-in Copilot models. + */ + public String getModelProviderName() { + return modelProviderName; + } + @Override public int hashCode() { - return Objects.hash(message, code, reason, responseIsIncomplete, responseIsFiltered); + return Objects.hash(message, code, reason, responseIsIncomplete, responseIsFiltered, modelProviderName); } @Override @@ -75,7 +88,8 @@ public boolean equals(Object o) { } ConversationError that = (ConversationError) o; return Objects.equals(message, that.message) && code == that.code && Objects.equals(reason, that.reason) - && responseIsIncomplete == that.responseIsIncomplete && responseIsFiltered == that.responseIsFiltered; + && responseIsIncomplete == that.responseIsIncomplete && responseIsFiltered == that.responseIsFiltered + && Objects.equals(modelProviderName, that.modelProviderName); } @Override @@ -86,6 +100,7 @@ public String toString() { builder.append("reason", reason); builder.append("responseIsIncomplete", responseIsIncomplete); builder.append("responseIsFiltered", responseIsFiltered); + builder.append("modelProviderName", modelProviderName); return builder.toString(); } } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaWarningParams.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaWarningParams.java new file mode 100644 index 00000000..8462a934 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/quota/QuotaWarningParams.java @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.lsp.protocol.quota; + +import com.google.gson.annotations.SerializedName; + +/** + * Parameters for the {@code copilot/quotaWarning} notification, sent by the language server when + * the user crosses a quota usage threshold or starts consuming overages. + * + * @param title warning title (e.g. "Copilot Quota Usage Alert") + * @param message human-readable warning message + * @param severity severity level, either {@code "warning"} or {@code "info"} + * @param chat current chat quota snapshot, when available + * @param completions current completions quota snapshot, when available + * @param premiumInteractions current premium interactions quota snapshot, when available + * @param copilotPlan the user's Copilot plan + */ +public record QuotaWarningParams(String title, String message, String severity, QuotaSnapshotParams chat, + QuotaSnapshotParams completions, + @SerializedName("premium_interactions") QuotaSnapshotParams premiumInteractions, CopilotPlan copilotPlan) { +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/ConversationDataFactory.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/ConversationDataFactory.java index f7ea370e..63dfceb0 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/ConversationDataFactory.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/ConversationDataFactory.java @@ -179,6 +179,7 @@ private void applyConversationError(ReplyData reply, ConversationError error) { ErrorData errorData = new ErrorData(); errorData.setMessage(error.getMessage()); errorData.setCode(error.getCode()); + errorData.setModelProviderName(error.getModelProviderName()); ErrorMessageData em = new ErrorMessageData(); em.setError(errorData); reply.getErrorMessages().add(em); diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/CopilotTurnData.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/CopilotTurnData.java index d614bf87..5df84f97 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/CopilotTurnData.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/persistence/CopilotTurnData.java @@ -440,6 +440,7 @@ public String toString() { public static class ErrorData { private String message; private int code; + private String modelProviderName; private Map data; public String getMessage() { @@ -458,6 +459,18 @@ public void setCode(int code) { this.code = code; } + /** + * The BYOK model provider responsible for the error, or {@code null} when the failing model was a + * built-in Copilot model. + */ + public String getModelProviderName() { + return modelProviderName; + } + + public void setModelProviderName(String modelProviderName) { + this.modelProviderName = modelProviderName; + } + public Map getData() { return data; } @@ -468,7 +481,7 @@ public void setData(Map data) { @Override public int hashCode() { - return Objects.hash(code, data, message); + return Objects.hash(code, data, message, modelProviderName); } @Override @@ -483,7 +496,8 @@ public boolean equals(Object obj) { return false; } ErrorData other = (ErrorData) obj; - return code == other.code && Objects.equals(data, other.data) && Objects.equals(message, other.message); + return code == other.code && Objects.equals(data, other.data) && Objects.equals(message, other.message) + && Objects.equals(modelProviderName, other.modelProviderName); } @Override @@ -491,6 +505,7 @@ public String toString() { ToStringBuilder builder = new ToStringBuilder(this); builder.append("message", message); builder.append("code", code); + builder.append("modelProviderName", modelProviderName); builder.append("data", data); return builder.toString(); } diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/i18n/MessagesTest.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/i18n/MessagesTest.java index 850a457c..0ac97045 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/i18n/MessagesTest.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/i18n/MessagesTest.java @@ -15,4 +15,4 @@ void testMessagesInitialization() { assertNotNull(Messages.menu_signToGitHub); assertNotNull(Messages.menu_signOutOfGitHub); } -} \ No newline at end of file +} diff --git a/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF b/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF index a6ae5073..0778e096 100644 --- a/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF +++ b/com.microsoft.copilot.eclipse.ui/META-INF/MANIFEST.MF @@ -31,7 +31,7 @@ Require-Bundle: com.microsoft.copilot.eclipse.core;bundle-version="0.15.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, 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 4e2d8bac..b2be770f 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 @@ -65,6 +65,7 @@ import com.microsoft.copilot.eclipse.core.chat.CustomChatModeManager; import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants; import com.microsoft.copilot.eclipse.core.lsp.protocol.ChatMode; +import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CopilotPlan; import com.microsoft.copilot.eclipse.core.utils.ChatMessageUtils; import com.microsoft.copilot.eclipse.core.utils.FileUtils; import com.microsoft.copilot.eclipse.core.utils.PlatformUtils; @@ -118,6 +119,7 @@ public class ActionBar extends Composite implements NewConversationListener { private Image autoBreakpointDisabledImage; private ContextSizeDonut contextSizeDonut; private StaticBanner staticBanner; + private Composite inputArea; private ChatServiceManager chatServiceManager; IEventBroker eventBroker; @@ -163,13 +165,28 @@ public ActionBar(Composite parent, int style, ChatServiceManager chatServiceMana }; this.eventBroker.subscribe(CopilotEventConstants.TOPIC_CHAT_DID_CHANGE_FEATURE_FLAGS, featureFlagsChangedEventHandler); - Composite actionBar = new Composite(this, style | SWT.BORDER); + // Transparent wrapper for the optional TodoListBar / WorkingSetBar and the bordered input below. + // StaticBanner is created as a sibling of inputArea so it stays structurally above the whole stack. + this.inputArea = new Composite(this, SWT.NONE); + GridLayout glInputArea = new GridLayout(1, false); + glInputArea.marginWidth = 0; + glInputArea.marginHeight = 0; + glInputArea.marginLeft = 0; + glInputArea.marginRight = 0; + glInputArea.marginTop = 0; + glInputArea.marginBottom = 0; + glInputArea.horizontalSpacing = 0; + glInputArea.verticalSpacing = 0; + this.inputArea.setLayout(glInputArea); + this.inputArea.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + + Composite borderedActionBar = new Composite(this.inputArea, style | SWT.BORDER); GridLayout gl = new GridLayout(1, false); gl.marginHeight = 5; gl.verticalSpacing = 0; - actionBar.setLayout(gl); - actionBar.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); - actionBar.setData(CssConstants.CSS_ID_KEY, "chat-action-bar"); + borderedActionBar.setLayout(gl); + borderedActionBar.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + borderedActionBar.setData(CssConstants.CSS_ID_KEY, "chat-action-bar"); RowLayout rowLayout = new RowLayout(); rowLayout.wrap = true; @@ -185,7 +202,7 @@ public ActionBar(Composite parent, int style, ChatServiceManager chatServiceMana rowLayout.marginTop = 0; rowLayout.marginBottom = 10; rowLayout.center = true; - this.cmpFileRef = new Composite(actionBar, SWT.NONE); + this.cmpFileRef = new Composite(borderedActionBar, SWT.NONE); this.cmpFileRef.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); this.cmpFileRef.setLayout(rowLayout); new AddContextButton(this.cmpFileRef); @@ -197,7 +214,7 @@ public ActionBar(Composite parent, int style, ChatServiceManager chatServiceMana ModelService modelService = chatServiceManager.getModelService(); modelService.bindActionBarForSupportVisionChange(this); - ChatInputTextViewer tv = new ChatInputTextViewer(actionBar, chatServiceManager); + ChatInputTextViewer tv = new ChatInputTextViewer(borderedActionBar, chatServiceManager); tv.setEditable(true); tv.addTextListener(new ITextListener() { @Override @@ -279,7 +296,7 @@ private void updateTableLayout(Table table) { glActionArea.marginLeft = 0; glActionArea.marginTop = 5; glActionArea.marginBottom = -5; - this.cmpActionArea = new Composite(actionBar, SWT.NONE); + this.cmpActionArea = new Composite(borderedActionBar, SWT.NONE); this.cmpActionArea.setLayout(glActionArea); this.cmpActionArea.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); @@ -947,11 +964,35 @@ private List selectFile() { } /** - * Show the static banner above the bordered action bar area. + * Show the rate-limit static banner above the input area, with a single "Get more info" action link. * * @param message the message to display + * @param warning {@code true} for the warning icon; {@code false} for the info icon */ - public void createStaticBanner(String message) { + public void createRateLimitBanner(String message, boolean warning) { + List actions = List.of( + new BannerAction(Messages.chat_rateLimitBanner_getMoreInfo, UiConstants.COPILOT_RATE_LIMIT_INFO_URL)); + showStaticBanner(message, actions, warning); + } + + /** + * Show the quota-warning static banner above the input area. Action links are sourced from + * {@link QuotaActions#forPlan(CopilotPlan, boolean)} so they stay in sync with the inline {@link WarnWidget}. + * + * @param message the message to display + * @param plan the user's Copilot plan, or {@code null} for no action links + * @param overageEnabled whether additional paid usage is already enabled for the user; switches the + * "Enable Additional Usage" label to "Increase Budget" + * @param warning {@code true} for the warning icon; {@code false} for the info icon + */ + public void createQuotaWarningBanner(String message, CopilotPlan plan, boolean overageEnabled, boolean warning) { + List bannerActions = QuotaActions.forPlan(plan, overageEnabled).stream() + .map(action -> new BannerAction(action.label(), action.url())) + .toList(); + showStaticBanner(message, bannerActions, warning); + } + + private void showStaticBanner(String message, List actions, boolean warning) { if (isDisposed()) { return; } @@ -959,16 +1000,24 @@ public void createStaticBanner(String message) { this.staticBanner.dispose(); } - this.staticBanner = new StaticBanner(this, SWT.NONE, message, Messages.chat_rateLimitBanner_getMoreInfo, - UiConstants.COPILOT_RATE_LIMIT_INFO_URL, Messages.chat_rateLimitBanner_closeTooltip); - // Position the banner above the first child (the bordered action bar composite) - if (getChildren().length > 0) { - this.staticBanner.moveAbove(getChildren()[0]); + this.staticBanner = new StaticBanner(this, SWT.NONE, message, actions, + Messages.chat_rateLimitBanner_closeTooltip, warning); + // Keep the banner above the inputArea sibling, the only other child of this composite. + if (this.inputArea != null && !this.inputArea.isDisposed()) { + this.staticBanner.moveAbove(this.inputArea); } this.staticBanner.show(); requestLayout(); } + /** + * Returns the input-area wrapper that owns {@code TodoListBar}, {@code WorkingSetBar}, and the bordered chat input. + * Services creating those top bars should parent them here so the sibling {@code StaticBanner} stays above. + */ + public Composite getInputArea() { + return this.inputArea; + } + /** * Dispose the current static banner, if present. */ diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BannerAction.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BannerAction.java new file mode 100644 index 00000000..06edf184 --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BannerAction.java @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +/** + * A clickable action rendered below the message of a {@link StaticBanner}. Each action is a localized label paired + * with the URL that should be opened when the user activates it. + * + * @param text the visible link label + * @param url the target URL opened on activation + */ +public record BannerAction(String text, String url) { +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java index 2fcb99d0..f10df5e2 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/BaseTurnWidget.java @@ -28,6 +28,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.AgentToolCall; import com.microsoft.copilot.eclipse.core.lsp.protocol.LanguageModelToolConfirmationResult; import com.microsoft.copilot.eclipse.core.lsp.protocol.codingagent.CodingAgentMessageRequestParams; +import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CopilotPlan; 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; @@ -420,7 +421,8 @@ public void restoreSubagentContent(String toolCallId, CopilotTurnData copilotTur CopilotTurnData.ErrorData errorData = errorMessageData.getError(); String errorMessage = errorData != null ? errorData.getMessage() : ""; int errorCode = errorData != null ? errorData.getCode() : 0; - subagentWidget.createWarnDialog(errorMessage, errorCode); + String modelProviderName = errorData != null ? errorData.getModelProviderName() : null; + subagentWidget.createWarnDialog(errorMessage, errorCode, modelProviderName); } } } @@ -564,10 +566,25 @@ protected void createFooter() { } /** - * Create a warning dialog to the turn widget. + * Render a warning dialog under the turn. BYOK 402 (402 + non-blank provider name) shows the BYOK message with no + * actions; plain 402 shows the server message plus plan-driven actions; other codes show the server message only. + * + * @param message the server error message + * @param code the server error code + * @param modelProviderName the BYOK model-provider name, or {@code null} for built-in models */ - protected void createWarnDialog(String message, int code) { - new WarnWidget(this, SWT.BOTTOM, message, code); + protected void createWarnDialog(String message, int code, String modelProviderName) { + boolean byokQuotaExceeded = QuotaActions.isByokQuotaExceeded(code, modelProviderName); + String displayMessage = byokQuotaExceeded ? Messages.chat_warnWidget_byokQuotaUsageMessage : message; + CopilotPlan planForActions = null; + boolean overageEnabled = false; + if (code == 402 && !byokQuotaExceeded) { + var quotaStatus = this.serviceManager.getAuthStatusManager().getQuotaStatus(); + planForActions = quotaStatus.copilotPlan(); + overageEnabled = quotaStatus.premiumInteractions() != null + && quotaStatus.premiumInteractions().overagePermitted(); + } + new WarnWidget(this, SWT.NONE, displayMessage, planForActions, overageEnabled); requestLayout(); } 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 befd23b5..fc503010 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 @@ -8,9 +8,9 @@ import java.util.List; import java.util.Map; 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; import org.eclipse.lsp4j.WorkDoneProgressKind; import org.eclipse.swt.SWT; import org.eclipse.swt.custom.ScrolledComposite; @@ -22,23 +22,18 @@ import org.eclipse.swt.layout.GridLayout; import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.ScrollBar; -import org.eclipse.ui.PlatformUI; import com.microsoft.copilot.eclipse.core.CopilotCore; -import com.microsoft.copilot.eclipse.core.events.CopilotEventConstants; 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.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.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; import com.microsoft.copilot.eclipse.ui.utils.SwtUtils; /** @@ -48,6 +43,13 @@ public class ChatContentViewer extends ScrolledComposite { 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 Composite cmpContent; @@ -223,42 +225,16 @@ public void processTurnEvent(ChatProgressValue value) { } 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; } if (StringUtils.isNotEmpty(errMsg)) { - // TODO: remove this error message replacement if statement when the CLS side warn message is aligned. - if (value.getCode() == 402) { - CopilotPlan userPlan = this.serviceManager.getAuthStatusManager().getQuotaStatus().copilotPlan(); - CopilotModel fallbackModel = this.serviceManager.getModelService().getFallbackModel(); - String fallbackModelName = fallbackModel != null ? fallbackModel.getModelName() - : Messages.chat_noQuotaView_fallbackModel; - - if (MenuUtils.isCfiPlan(userPlan)) { - // Pro, Pro+ and Max message - errMsg = String.format(Messages.chat_noQuotaView_proProplusWarnMsg, fallbackModelName); - } else if (userPlan == CopilotPlan.business || userPlan == CopilotPlan.enterprise) { - // CE and CB message - errMsg = String.format(Messages.chat_noQuotaView_cbCeWarnMsg, fallbackModelName); - } - } - - renderWarnMessageWithUpgradePlanButton(errMsg, value.getCode()); - - if (value.getCode() == 402 - && this.serviceManager.getAuthStatusManager().getQuotaStatus().copilotPlan() != CopilotPlan.free) { - this.serviceManager.getModelService().setFallBackModelAsActiveModel(); - this.serviceManager.getAuthStatusManager().checkQuota(); - - String previousInput = this.serviceManager.getUserPreferenceService().getPreviousInput(StringUtils.EMPTY); - if (StringUtils.isNotEmpty(previousInput)) { - IEventBroker eventBroker = PlatformUI.getWorkbench().getService(IEventBroker.class); - Map properties = Map.of("previousInput", previousInput, "needCreateUserTurn", false); - eventBroker.post(CopilotEventConstants.TOPIC_CHAT_ON_SEND, properties); - } - } + renderWarnMessageWithUpgradePlanButton(errMsg, value.getCode(), value.getErrorModelProviderName()); } }, this); } @@ -332,8 +308,8 @@ public BaseTurnWidget getTurnWidget(String turnId) { return turns.get(turnId); } - private void renderWarnMessageWithUpgradePlanButton(String errorMessage, int code) { - latestTurnWidget.createWarnDialog(errorMessage, code); + private void renderWarnMessageWithUpgradePlanButton(String errorMessage, int code, String modelProviderName) { + latestTurnWidget.createWarnDialog(errorMessage, code, modelProviderName); refreshScrollerLayout(); scrollToLatestUserTurn(); } 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 e825a931..e5a543dd 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 @@ -66,6 +66,7 @@ import com.microsoft.copilot.eclipse.core.lsp.protocol.TodoItem; 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; @@ -142,9 +143,17 @@ public class ChatView extends ViewPart implements ChatProgressListener, MessageL private EventHandler codingAgentMessageHandler; private EventHandler autoBreakpointToggleHandler; private EventHandler rateLimitWarningHandler; + private EventHandler quotaWarningHandler; // Context activation for chat view keyboard shortcuts private static final String CHAT_VIEW_CONTEXT = "com.microsoft.copilot.eclipse.chatViewContext"; + + /** + * Percentage-remaining threshold below which the rate-limit banner switches from the informational + * icon to the warning icon. + */ + private static final double RATE_LIMIT_WARNING_THRESHOLD_PERCENT_REMAINING = 25.0; + private IContextActivation chatViewContextActivation; private IPartListener2 partListener; @@ -359,13 +368,31 @@ public void done(IJobChangeEvent event) { if (data instanceof RateLimitWarningParams params) { SwtUtils.invokeOnDisplayThreadAsync(() -> { if (actionBar != null && !actionBar.isDisposed()) { - actionBar.createStaticBanner(params.message()); + RateLimitWarningParams.RateLimit rateLimit = params.rateLimit(); + boolean warning = rateLimit != null + && rateLimit.percentRemaining() <= RATE_LIMIT_WARNING_THRESHOLD_PERCENT_REMAINING; + actionBar.createRateLimitBanner(params.message(), warning); } }, parent); } }; this.eventBroker.subscribe(CopilotEventConstants.TOPIC_RATE_LIMIT_WARNING, this.rateLimitWarningHandler); + this.quotaWarningHandler = event -> { + Object data = event.getProperty(IEventBroker.DATA); + if (data instanceof QuotaWarningParams params) { + SwtUtils.invokeOnDisplayThreadAsync(() -> { + if (actionBar != null && !actionBar.isDisposed()) { + boolean warning = "warning".equalsIgnoreCase(params.severity()); + boolean overageEnabled = params.premiumInteractions() != null + && params.premiumInteractions().overageEnabled(); + actionBar.createQuotaWarningBanner(params.message(), params.copilotPlan(), overageEnabled, warning); + } + }, parent); + } + }; + this.eventBroker.subscribe(CopilotEventConstants.TOPIC_QUOTA_WARNING, this.quotaWarningHandler); + // Register part listener to activate/deactivate chat view context for keyboard shortcuts registerPartListener(); } @@ -1423,6 +1450,10 @@ public void dispose() { this.eventBroker.unsubscribe(this.rateLimitWarningHandler); rateLimitWarningHandler = null; } + if (quotaWarningHandler != null) { + this.eventBroker.unsubscribe(this.quotaWarningHandler); + quotaWarningHandler = null; + } } if (this.chatServiceManager != null) { @@ -1729,7 +1760,8 @@ private void restoreCopilotTurnContent(CopilotTurnData copilotTurn, BaseTurnWidg SwtUtils.invokeOnDisplayThread(() -> { String errorMessage = errorData != null ? errorData.getMessage() : Messages.chat_warnWidget_defaultErrorMsg; int errorCode = errorData != null ? errorData.getCode() : 0; - turnWidget.createWarnDialog(errorMessage, errorCode); + String modelProviderName = errorData != null ? errorData.getModelProviderName() : null; + turnWidget.createWarnDialog(errorMessage, errorCode, modelProviderName); }, parent); } } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/Messages.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/Messages.java index dc99af43..33977e8e 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/Messages.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/Messages.java @@ -19,6 +19,7 @@ public final class Messages extends NLS { public static String confirmDialog_keepChangesButton; public static String confirmDialog_undoChangesButton; public static String chat_warnWidget_defaultErrorMsg; + public static String chat_warnWidget_byokQuotaUsageMessage; public static String configureModes; public static String agentMessageWidget_openInBrowserButton; public static String agentMessageWidget_openInBrowserTooltip; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/QuotaActions.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/QuotaActions.java new file mode 100644 index 00000000..46943eeb --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/QuotaActions.java @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import java.util.List; + +import org.apache.commons.lang3.StringUtils; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CopilotPlan; +import com.microsoft.copilot.eclipse.ui.UiConstants; +import com.microsoft.copilot.eclipse.ui.i18n.Messages; + +/** + * Plan-driven call-to-action list shown when the user's quota is approaching or exceeded. Shared by the + * {@link StaticBanner}-based quota notification (rendered via {@code ActionBar#createQuotaWarningBanner}) + * and the inline {@link WarnWidget} rendered under a chat turn on a 402 error response, so both surfaces + * stay in sync when a new plan or call-to-action is added. + */ +public final class QuotaActions { + + /** + * Single quota call-to-action. + * + * @param label visible button or link label + * @param tooltip tooltip text used by the inline {@link WarnWidget}; ignored by {@link StaticBanner} + * @param url target URL opened on activation + * @param primary {@code true} when this action should be visually emphasised (e.g. {@code btn-primary} + * styling on a push button); ignored by {@link StaticBanner}, which renders all actions as links + */ + public record QuotaAction(String label, String tooltip, String url, boolean primary) { + } + + private QuotaActions() { + } + + /** + * Returns the ordered list of {@link QuotaAction}s appropriate for the supplied plan. + * + *

Mapping: + *

    + *
  • {@code free} → "Upgrade Plan" (primary)
  • + *
  • {@code individual}, {@code individual_pro} → "Enable Additional Usage" / + * "Increase Budget" (primary) + "Upgrade Plan" (secondary)
  • + *
  • {@code individual_max} → "Enable Additional Usage" / "Increase Budget" (primary)
  • + *
  • {@code business}, {@code enterprise} → empty list
  • + *
  • {@code null} → empty list
  • + *
+ * + *

The "Enable Additional Usage" label is replaced with "Increase Budget" when {@code overageEnabled} + * is {@code true}, matching the IntelliJ quota dialog wording. + * + * @param plan the user's Copilot plan, or {@code null} when unknown + * @param overageEnabled {@code true} when additional paid usage is already enabled for the user + * @return an immutable, possibly empty list; never {@code null} + */ + public static List forPlan(CopilotPlan plan, boolean overageEnabled) { + if (plan == null) { + return List.of(); + } + QuotaAction upgradePrimary = new QuotaAction(Messages.menu_quota_upgradePlan, + Messages.chat_noQuotaView_updatePlanButton_Tooltip, + UiConstants.COPILOT_UPGRADE_PLAN_URL, true); + QuotaAction upgradeSecondary = new QuotaAction(Messages.menu_quota_upgradePlan, + Messages.chat_noQuotaView_updatePlanButton_Tooltip, + UiConstants.COPILOT_UPGRADE_PLAN_URL, false); + String overageLabel = overageEnabled ? Messages.menu_quota_increaseBudget + : Messages.menu_quota_enableAdditionalUsage; + QuotaAction manageOverage = new QuotaAction(overageLabel, + Messages.chat_noQuotaView_enableAdditionalUsageButton_tooltip, + UiConstants.MANAGE_COPILOT_OVERAGE_URL, true); + return switch (plan) { + case free -> List.of(upgradePrimary); + case individual, individual_pro -> List.of(manageOverage, upgradeSecondary); + case individual_max -> List.of(manageOverage); + case business, enterprise -> List.of(); + }; + } + + /** + * Returns {@code true} when an error response represents a Bring-Your-Own-Key (BYOK) quota-exceeded + * condition, i.e. a {@code 402} from the language server that also carries a non-blank model + * provider name. BYOK usage is governed by the customer's provider account rather than the user's + * Copilot plan, so callers should suppress the plan-driven {@link #forPlan(CopilotPlan)} actions + * and substitute the BYOK-specific message in this case. + * + * @param code the error code from the language server + * @param modelProviderName the BYOK model-provider name from the server payload, or {@code null} + * @return {@code true} when the error should be rendered as a BYOK quota notice + */ + public static boolean isByokQuotaExceeded(int code, String modelProviderName) { + return code == 402 && StringUtils.isNotBlank(modelProviderName); + } +} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/StaticBanner.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/StaticBanner.java index e5203cea..ec340b7e 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/StaticBanner.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/StaticBanner.java @@ -3,8 +3,9 @@ package com.microsoft.copilot.eclipse.ui.chat; +import java.util.List; + import org.apache.commons.lang3.StringUtils; -import org.eclipse.osgi.util.NLS; import org.eclipse.swt.SWT; import org.eclipse.swt.events.MouseAdapter; import org.eclipse.swt.events.MouseEvent; @@ -22,31 +23,35 @@ import com.microsoft.copilot.eclipse.ui.utils.UiUtils; /** - * A reusable banner widget that displays an informational message with an inline link and a close button. Shows an info - * icon, the provided message with an appended link, and a dismiss (×) button. + * Reusable, dismissible banner shown above the chat input. Layout is a 3-column grid: severity icon, wrapping message, + * close button; an optional second row of action links is added when {@code actions} is non-empty. + * + *

The banner is constructed hidden and layout-excluded; call {@link #show()} to reveal it. Callers must pass + * already-localized strings. * *

Usage example: * - *

var banner = new StaticBanner(parent, SWT.NONE, "You've used 90% of your rate limit.", "Get more info",
- *     "https://example.com", "Dismiss");
+ * 
+ * var banner = new StaticBanner(parent, SWT.NONE, "You've used 75% of your monthly quota.",
+ *     List.of(new BannerAction("Upgrade Plan", "https://example.com/upgrade")), "Dismiss", true);
  * banner.show();
  * 
*/ public class StaticBanner extends Composite { - private Link messageLink; + private Label messageLabel; /** - * Create a static informational banner. + * Create a hidden static banner; call {@link #show()} to reveal. {@link SWT#BORDER} is always applied. * * @param parent the parent composite - * @param style the SWT style - * @param message the informational message to display - * @param linkText the text for the inline link (e.g. "Get more info") - * @param linkUrl the URL to open when the link is clicked - * @param closeTooltip the tooltip for the close button + * @param style additional SWT style bits + * @param message the message to display ({@code null} treated as empty) + * @param actions optional action links below the message; entries with blank label or URL are skipped + * @param closeTooltip tooltip for the close (×) button + * @param warning {@code true} for the warning icon; {@code false} for the info icon */ - public StaticBanner(Composite parent, int style, String message, String linkText, String linkUrl, - String closeTooltip) { + public StaticBanner(Composite parent, int style, String message, List actions, String closeTooltip, + boolean warning) { super(parent, style | SWT.BORDER); GridLayout layout = new GridLayout(3, false); @@ -56,26 +61,19 @@ public StaticBanner(Composite parent, int style, String message, String linkText setLayout(layout); setLayoutData(new GridData(SWT.FILL, SWT.NONE, true, false)); - // Info icon + // Severity icon Label iconLabel = new Label(this, SWT.NONE); - Image infoImage = PlatformUI.getWorkbench().getSharedImages().getImage(ISharedImages.IMG_OBJS_INFO_TSK); - iconLabel.setImage(infoImage); + String iconKey = warning ? ISharedImages.IMG_OBJS_WARN_TSK : ISharedImages.IMG_OBJS_INFO_TSK; + Image iconImage = PlatformUI.getWorkbench().getSharedImages().getImage(iconKey); + iconLabel.setImage(iconImage); iconLabel.setLayoutData(new GridData(SWT.LEFT, SWT.TOP, false, false)); - // Message + inline link - this.messageLink = new Link(this, SWT.WRAP); - this.messageLink.setText(buildMessageText(message, linkText, linkUrl)); - this.messageLink.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); - this.messageLink.addSelectionListener(new SelectionAdapter() { - @Override - public void widgetSelected(SelectionEvent e) { - if (StringUtils.isNotBlank(linkUrl)) { - UiUtils.openLink(linkUrl); - } - } - }); + // Wrapping message text + this.messageLabel = new Label(this, SWT.WRAP); + this.messageLabel.setText(StringUtils.defaultString(message)); + this.messageLabel.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); - // Close button + // Close (×) button Label closeButton = new Label(this, SWT.NONE); Image closeImage = PlatformUI.getWorkbench().getSharedImages().getImage(ISharedImages.IMG_ELCL_REMOVE); closeButton.setImage(closeImage); @@ -89,13 +87,35 @@ public void mouseUp(MouseEvent e) { } }); + // Optional action-link row, aligned under the message column. + List safeActions = actions == null ? List.of() : actions; + if (!safeActions.isEmpty()) { + // Spacer to align with the icon column above. + new Label(this, SWT.NONE).setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, false, false)); + + GridLayout actionLayout = new GridLayout(safeActions.size(), false); + actionLayout.marginWidth = 0; + actionLayout.marginHeight = 0; + actionLayout.horizontalSpacing = 12; + actionLayout.verticalSpacing = 0; + GridData actionRowData = new GridData(SWT.FILL, SWT.CENTER, true, false); + actionRowData.horizontalSpan = 2; + Composite actionRow = new Composite(this, SWT.NONE); + actionRow.setLayout(actionLayout); + actionRow.setLayoutData(actionRowData); + + for (BannerAction action : safeActions) { + addActionLink(actionRow, action); + } + } + setVisible(false); GridData gd = (GridData) getLayoutData(); gd.exclude = true; } /** - * Show the banner. + * Reveal the banner and re-layout the parent. No-op if disposed. */ public void show() { if (isDisposed()) { @@ -118,13 +138,19 @@ private void disposeBanner() { } } - private static String buildMessageText(String message, String linkText, String linkUrl) { - String safeMessage = escapeForLink(message); - if (StringUtils.isBlank(linkText) || StringUtils.isBlank(linkUrl)) { - return safeMessage; + private static void addActionLink(Composite parent, BannerAction action) { + if (action == null || StringUtils.isBlank(action.text()) || StringUtils.isBlank(action.url())) { + return; } - return NLS.bind(com.microsoft.copilot.eclipse.ui.i18n.Messages.chat_staticBanner_messageWithLink, safeMessage, - escapeForLink(linkText)); + Link link = new Link(parent, SWT.NONE); + link.setText("" + escapeForLink(action.text()) + ""); + link.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, false, false)); + link.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + UiUtils.openLink(action.url()); + } + }); } private static String escapeForLink(String text) { diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/WarnWidget.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/WarnWidget.java index 72955327..8772cc80 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/WarnWidget.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/WarnWidget.java @@ -3,6 +3,8 @@ package com.microsoft.copilot.eclipse.ui.chat; +import java.util.List; + import org.eclipse.swt.SWT; import org.eclipse.swt.custom.StyledText; import org.eclipse.swt.events.SelectionAdapter; @@ -16,12 +18,15 @@ import org.eclipse.ui.ISharedImages; import org.eclipse.ui.PlatformUI; -import com.microsoft.copilot.eclipse.ui.i18n.Messages; +import com.microsoft.copilot.eclipse.core.lsp.protocol.quota.CopilotPlan; +import com.microsoft.copilot.eclipse.ui.chat.QuotaActions.QuotaAction; import com.microsoft.copilot.eclipse.ui.swt.CssConstants; import com.microsoft.copilot.eclipse.ui.utils.UiUtils; /** - * Widget to display a message when the user has no quota. + * Widget that displays a warning message under a chat turn, optionally followed by plan-driven action buttons sourced + * from {@link QuotaActions#forPlan(CopilotPlan, boolean)}. Presentation-only: the caller decides the message and + * whether to pass a plan. */ public class WarnWidget extends Composite { private int buttonLeftMargin; @@ -30,30 +35,31 @@ public class WarnWidget extends Composite { * Create the composite. * * @param parent the parent composite - * @param message the message to display + * @param style the SWT style bits + * @param message the message to display ({@code null} treated as empty) + * @param userPlan the user's Copilot plan to render plan-driven action buttons, or {@code null} for no buttons + * @param overageEnabled whether additional paid usage is already enabled for the user; switches the + * "Enable Additional Usage" label to "Increase Budget" */ - public WarnWidget(Composite parent, int style, String message, int code) { + public WarnWidget(Composite parent, int style, String message, CopilotPlan userPlan, boolean overageEnabled) { super(parent, style | SWT.BORDER); - setLayout(new GridLayout(1, true)); + GridLayout outerLayout = new GridLayout(1, true); + outerLayout.verticalSpacing = 0; + setLayout(outerLayout); setLayoutData(new GridData(SWT.FILL, SWT.NONE, true, false)); buildWarnLabelWithIcon(message); - // Render the button based on the error code. See: - // https://github.com/microsoft/copilot-client/blob/77f8f28e1a1e2efb51b6f92649bd9d085b8b64f5/lib/src/conversation/fetchPostProcessor.ts#L232-L248 - if (code == 402) { - // TODO: This is just a temporary solution. We need compose a dialog to support any warn message once issue - // https://github.com/microsoft/copilot-client/issues/405 is resolved. - if (message.toLowerCase().contains("upgrade to copilot pro (30-day free trial)")) { - buildUpdatePlanButton(); - } + if (userPlan != null) { + buildActionButtons(userPlan, overageEnabled); } parent.layout(); } private void buildWarnLabelWithIcon(String message) { Composite composite = new Composite(this, SWT.NONE); - composite.setLayout(new GridLayout(2, false)); + GridLayout warnLayout = new GridLayout(2, false); + composite.setLayout(warnLayout); composite.setLayoutData(new GridData(SWT.LEFT, SWT.NONE, true, false)); Label iconLabel = new Label(composite, SWT.TOP); @@ -62,7 +68,8 @@ private void buildWarnLabelWithIcon(String message) { GridData iconGd = new GridData(SWT.LEFT, SWT.TOP, false, false); iconGd.verticalIndent = 4; iconLabel.setLayoutData(iconGd); - buttonLeftMargin = warnImage.getBounds().width + iconGd.verticalIndent; + buttonLeftMargin = warnLayout.marginWidth + warnLayout.marginLeft + warnImage.getBounds().width + + warnLayout.horizontalSpacing; ChatMarkupViewer textLabel = new ChatMarkupViewer(composite, SWT.LEFT | SWT.WRAP); StyledText styledText = textLabel.getTextWidget(); @@ -73,22 +80,40 @@ private void buildWarnLabelWithIcon(String message) { requestLayout(); } - private void buildUpdatePlanButton() { - Composite composite = new Composite(this, SWT.NONE); + /** + * Render plan-driven action buttons for a quota-exceeded warning, kept in sync with the quota {@link StaticBanner}. + */ + private void buildActionButtons(CopilotPlan userPlan, boolean overageEnabled) { + List actions = QuotaActions.forPlan(userPlan, overageEnabled); + if (actions.isEmpty()) { + return; + } + RowLayout layout = new RowLayout(SWT.HORIZONTAL); - layout.marginLeft = this.buttonLeftMargin; // Add margin to the left of the buttons to align with the message + layout.marginLeft = this.buttonLeftMargin; // Align with the message text + layout.marginTop = 0; layout.spacing = 10; + + Composite composite = new Composite(this, SWT.NONE); composite.setLayout(layout); - Button updatePlanButton = new Button(composite, SWT.PUSH); - updatePlanButton.setText(Messages.chat_noQuotaView_updatePlanButton); - updatePlanButton.setToolTipText(Messages.chat_noQuotaView_updatePlanButton_Tooltip); - updatePlanButton.addSelectionListener(new SelectionAdapter() { + for (QuotaAction action : actions) { + addButton(composite, action.label(), action.tooltip(), action.url(), action.primary()); + } + } + + private static void addButton(Composite parent, String label, String tooltip, String link, boolean primary) { + Button button = new Button(parent, SWT.PUSH); + button.setText(label); + button.setToolTipText(tooltip); + button.addSelectionListener(new SelectionAdapter() { @Override public void widgetSelected(org.eclipse.swt.events.SelectionEvent event) { - UiUtils.openLink(Messages.chat_noQuotaView_updatePlanLink); + UiUtils.openLink(link); } }); - updatePlanButton.setData(CssConstants.CSS_CLASS_NAME_KEY, "btn-primary"); + if (primary) { + button.setData(CssConstants.CSS_CLASS_NAME_KEY, "btn-primary"); + } } } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/messages.properties b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/messages.properties index ef944033..31ad77bb 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/messages.properties +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/messages.properties @@ -1,5 +1,6 @@ chat_chatContentView_errorTemplate=Sorry, your request failed: %s. Request id: %s. chat_warnWidget_defaultErrorMsg=An error occurred. Please try again. +chat_warnWidget_byokQuotaUsageMessage=You have exceeded your quota for BYOK (Bring Your Own Key) models. chat_toolCall_genericError=Tool call failed. chat_toolCall_errorTemplate={0}: {1} diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/TodoListService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/TodoListService.java index adddc2cd..d8856b2e 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/TodoListService.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/TodoListService.java @@ -69,9 +69,10 @@ public void bindTodoListBar(ChatView chatView) { disposeTodoListBar(); } else { if (this.todoListBar == null || this.todoListBar.isDisposed()) { - this.todoListBar = new TodoListBar(chatView.getActionBar(), SWT.NONE); + this.todoListBar = new TodoListBar(chatView.getActionBar().getInputArea(), SWT.NONE); } - // Always position TodoListBar at the very top + // Always position TodoListBar at the very top of the inputArea (still below the + // StaticBanner, which lives on the outer ActionBar as a sibling of inputArea). this.todoListBar.moveAbove(null); this.todoListBar.buildTodoListBar(todoList); } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolService.java index a5571718..1e57daa9 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolService.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/tools/FileToolService.java @@ -83,9 +83,11 @@ public void bindWorkingSetBar(ChatView chatView) { disposeWorkingSetBar(); } else { if (this.workingSetBar == null || this.workingSetBar.isDisposed()) { - this.workingSetBar = new WorkingSetBar(chatView.getActionBar(), SWT.NONE); + this.workingSetBar = new WorkingSetBar(chatView.getActionBar().getInputArea(), SWT.NONE); } - // Position WorkingSetBar below TodoListBar (if present), otherwise at top + // Position WorkingSetBar below TodoListBar (if present), otherwise at the top of + // inputArea. The StaticBanner sits on the outer ActionBar as a sibling of inputArea, + // so it remains above this bar regardless of this call. positionWorkingSetBar(chatView); this.workingSetBar.buildSummaryBarFor(filesMap); } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/Messages.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/Messages.java index 4a319743..47cf65eb 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/Messages.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/Messages.java @@ -152,15 +152,10 @@ public final class Messages extends NLS { public static String chat_addContext_tooltip; public static String chat_filePicker_title; public static String chat_filePicker_message; - public static String chat_noQuotaView_fallbackModel; - public static String chat_noQuotaView_updatePlanButton; public static String chat_noQuotaView_updatePlanButton_Tooltip; - public static String chat_noQuotaView_updatePlanLink; - public static String chat_noQuotaView_enablePremiumRequestsButton; - public static String chat_noQuotaView_enablePremiumRequestsButton_tooltip; - public static String chat_noQuotaView_enablePremiumRequestsLink; - public static String chat_noQuotaView_proProplusWarnMsg; - public static String chat_noQuotaView_cbCeWarnMsg; + public static String chat_noQuotaView_enableAdditionalUsageButton; + public static String chat_noQuotaView_enableAdditionalUsageButton_tooltip; + public static String chat_noQuotaView_viewYourPlanButton_Tooltip; public static String chat_currentReferencedFile_description; public static String chat_turnWidget_copilot; public static String chat_turnWidget_user; @@ -223,9 +218,9 @@ public final class Messages extends NLS { public static String context_window_messages; public static String context_window_files; public static String context_window_tool_results; - public static String chat_staticBanner_messageWithLink; public static String chat_rateLimitBanner_getMoreInfo; public static String chat_rateLimitBanner_closeTooltip; + public static String chat_quotaBanner_viewYourPlan; static { // initialize resource bundle diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/messages.properties b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/messages.properties index 1464ea28..f628ff9e 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/messages.properties +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/messages.properties @@ -133,15 +133,10 @@ chat_agentModeView_configureMcpSuffix=to configure MCP server chat_agentModeView_attachContextSuffix=to attach context chat_loadingView_title=Loading chat_loadingView_description=Initializing Copilot... -chat_noQuotaView_fallbackModel=fallback model -chat_noQuotaView_updatePlanButton=Upgrade to Copilot Pro -chat_noQuotaView_updatePlanButton_Tooltip=Update your plan to Copilot Pro -chat_noQuotaView_updatePlanLink=https://aka.ms/github-copilot-upgrade-plan -chat_noQuotaView_enablePremiumRequestsButton=Enable additional paid premium requests -chat_noQuotaView_enablePremiumRequestsButton_tooltip=Enable additional paid premium requests -chat_noQuotaView_enablePremiumRequestsLink=https://aka.ms/github-copilot-manage-overage -chat_noQuotaView_proProplusWarnMsg=You have exceeded your premium request allowance. We have automatically switched you to **%s** which is included with your plan. [Enable additional paid premium requests](https://aka.ms/github-copilot-manage-overage) to continue using premium models. -chat_noQuotaView_cbCeWarnMsg=You have exceeded your free request allowance. We have automatically switched you to **%s** which is included with your plan. To enable additional paid premium requests, contact your organization admin. +chat_noQuotaView_updatePlanButton_Tooltip=Upgrade your Copilot plan +chat_noQuotaView_enableAdditionalUsageButton=Enable Additional Usage +chat_noQuotaView_enableAdditionalUsageButton_tooltip=Pay-as-you-go usage of additional AI credits once you run out of your included usage. Set a budget to cap your maximum monthly spend. +chat_noQuotaView_viewYourPlanButton_Tooltip=View your Copilot plan chat_noAuthView_title=No Copilot subscription found chat_noAuthView_description=Request a license from your organization manager or sign up for a 60 day trial chat_noAuthView_checkSubButton=Check subscription plans @@ -219,6 +214,6 @@ context_window_messages=Messages context_window_files=Attached Files context_window_tool_results=Tool Results -chat_staticBanner_messageWithLink={0} {1} chat_rateLimitBanner_getMoreInfo=Get more info chat_rateLimitBanner_closeTooltip=Dismiss +chat_quotaBanner_viewYourPlan=View your plan