Copilot usage-based billing onboarding banner#317233
Draft
eli-w-king wants to merge 4 commits into
Draft
Conversation
Adds a 3-slide carousel banner that appears above the chat input (panel and Agents window) when the user is opted into Copilot usage-based billing. The banner explains the new credit model, links to GitHub docs, and routes the primary CTA to the Copilot status dashboard (panel) or the Copilot settings page (Agents). - New IChatBillingBannerService owns visibility state and exposes a bridge command (_chat.billing.usageBannerSetEnabled) the copilot extension calls when copilotToken.isUsageBasedBilling flips, plus a dev command (github.copilot.dev.showUsageBillingBanner) for testing. - New ChatBillingBannerWidget renders an ASCII-tinted hero canvas, a fading carousel body, square chevron nav, a check icon that dismisses on the last slide, and a primary monaco Button CTA. - Wires into chatInputPart and newChatInput so both surfaces host the banner with proper variant copy. - Adds workbench.action.chat.openStatusDashboard, which clicks the Copilot status bar entry to open the existing ChatStatusDashboard tooltip — used as the panel CTA target. - Card squares its bottom corners and pulls itself under the chat input so the surfaces meet seamlessly.
- Rewrite slide copy against the GitHub docs (credits allowance, agent vs. completion cost, overage budget) and inline the docs link in the slide 3 description. - Replace the per-glyph ASCII scramble with a 160ms opacity cross-fade for slide transitions; the hero canvas stays animated. - Reserve a Dismiss + Copilot Dashboard button row on slide 3, with the next chevron disabled on the last step. Reserve a min-height on the nav row so the card height stays constant across slides. - In chatInputPart, hide the banner via a 'chat-billing-banner-squeezed' class when its natural height would push the input editor below its minimum, so the banner never steals the input in short panels.
Wires the third-slide CTA on the Agents-window variant of the Copilot usage-based billing banner to the in-product account panel instead of an external https://github.com/settings/copilot link, since the Copilot status dashboard isn't reachable from the Agents window. - TitleBarAccountWidget exposes a public openAccountPanel() that mirrors clicking the title-bar avatar, and the module tracks the active instance via a module-local handle so a command can drive it without plumbing a dedicated service. - New non-palette Action2 with id 'workbench.action.agents.openAccountMenu' (exported as SESSIONS_OPEN_ACCOUNT_MENU_COMMAND_ID) calls into that handle. The widget is referenced by command id only — the workbench layer never imports anything from src/vs/sessions. - ChatBillingBannerWidget drops COPILOT_SETTINGS_URL, picks the CTA command id by variant, and now relabels the Agents CTA to '$(account) View Account'. Slide 3 description is also variant-aware: the Agents copy directs users to the title-bar account menu, while the panel copy still points at the Copilot status dashboard. - src/vs/sessions/contrib/chat/browser/chat.contribution.ts eagerly imports chatBillingBannerService.js so its dev-only 'Show Copilot Billing Banner' command and the bridge command are registered in the Agents window before the chat input widget instantiates the banner.
826e74d to
6a6bf4a
Compare
Contributor
Author
|
Responsive: 5.18.UBB.First.Time.mov |
Contributor
There was a problem hiding this comment.
Pull request overview
Adds a Copilot usage-based billing onboarding banner that can be enabled by the Copilot extension and rendered above chat inputs in the workbench chat panel and Agents window.
Changes:
- Introduces a workbench billing banner service, widget, CSS, and status dashboard command.
- Wires the banner into
ChatInputPartand the AgentsNewChatInputWidget. - Adds Copilot extension bridge contribution and Agents account-menu command for the banner CTA.
Show a summary per file
| File | Description |
|---|---|
src/vs/workbench/contrib/chat/browser/widget/input/media/chatBillingBannerWidget.css |
Styles the billing banner card, carousel controls, canvas, and host layout. |
src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts |
Hosts the panel variant and adds short-panel squeeze handling. |
src/vs/workbench/contrib/chat/browser/widget/input/chatBillingBannerWidget.ts |
Implements the carousel banner UI, canvas animation, links, CTA, and dismissal controls. |
src/vs/workbench/contrib/chat/browser/widget/input/chatBillingBannerService.ts |
Adds persisted visibility/completion state and bridge/dev commands. |
src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusEntry.ts |
Adds a command to open the Copilot status dashboard via the status bar entry. |
src/vs/workbench/contrib/chat/browser/chat.contribution.ts |
Eagerly imports the banner service in workbench chat. |
src/vs/sessions/contrib/chat/browser/newChatInput.ts |
Hosts the Agents variant above the new chat input. |
src/vs/sessions/contrib/chat/browser/media/chatInput.css |
Adds Agents-specific banner host layout rules. |
src/vs/sessions/contrib/chat/browser/chat.contribution.ts |
Eagerly imports the banner service for the Agents window. |
src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts |
Adds an Agents account-menu open command and widget entry point. |
extensions/copilot/src/extension/extension/vscode-node/contributions.ts |
Registers the Copilot billing banner bridge contribution. |
extensions/copilot/src/extension/chatBillingBanner/vscode-node/chatBillingBanner.contribution.ts |
Syncs isUsageBasedBilling eligibility to the workbench command bridge. |
Copilot's findings
Comments suppressed due to low confidence (4)
src/vs/workbench/contrib/chat/browser/widget/input/chatBillingBannerWidget.ts:158
- The canvas animation starts an unconditional requestAnimationFrame loop and never checks
IAccessibilityService.isMotionReduced()or reduced-motion changes. VS Code exposesworkbench.reduceMotion/prefers-reduced-motionfor this purpose, so users who request reduced motion will still get a continuously animated banner.
const tick = (ts: number) => {
if (this._stopped) {
return;
}
this._draw(ts);
this._rafHandle = dom.scheduleAtNextAnimationFrame(targetWindow, () => tick(performance.now()));
};
this._rafHandle = dom.scheduleAtNextAnimationFrame(targetWindow, () => tick(performance.now()));
src/vs/workbench/contrib/chat/browser/widget/input/chatBillingBannerWidget.ts:296
- This documentation says the primary CTA marks the banner completed, but
_handleCta()only executes the destination command and does not callmarkCompleted(). Please correct the JSDoc or the CTA behavior so the expected dismissal semantics are clear.
* {@link IChatBillingBannerService.shouldShow} is true; clicking the
* primary CTA on slide 3 marks the banner completed and hides it
* permanently across restarts.
src/vs/workbench/contrib/chat/browser/widget/input/chatBillingBannerWidget.ts:378
- The
has-billing-bannerclass is not applied to the element targeted by the CSS._render()runs in the constructor beforedomNodeis appended, soparentElementcan be null; after append it would still be the banner container, while the CSS expects.interactive-input-part.has-billing-banner. This means the getting-started tip is not hidden while the banner is visible.
this.domNode.parentElement?.classList.add('has-billing-banner');
src/vs/workbench/contrib/chat/browser/widget/input/chatBillingBannerWidget.ts:521
- Each time slide 3 is applied, this click listener is added to the long-lived render disposable store, but
_applyStep()clears and recreates the description without disposing the previous link listener. Repeatedly moving between slides 2 and 3 retains listeners for detached anchors until the whole banner re-renders; use a per-step disposable or dispose before rebuilding the description.
this._renderDisposables.add(dom.addDisposableListener(inlineLink, dom.EventType.CLICK, (e: MouseEvent) => {
e.preventDefault();
this._openerService.open(URI.parse(href));
}));
- Files reviewed: 12/12 changed files
- Comments generated: 11
Comment on lines
+7
to
+8
| .interactive-session .interactive-input-part > .chat-billing-banner-container:empty, | ||
| .new-chat-input-container > .chat-billing-banner-container:empty { |
Comment on lines
+3732
to
+3735
| const bannerNaturalHeight = this.chatBillingBannerContainer.scrollHeight; | ||
| if (bannerNaturalHeight === 0) { | ||
| this.chatBillingBannerContainer.classList.remove('chat-billing-banner-squeezed'); | ||
| return; |
| import { createDecorator } from '../../../../../../platform/instantiation/common/instantiation.js'; | ||
| import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; | ||
|
|
||
| const COMPLETED_STORAGE_KEY = 'chat.usageBillingBanner.completed'; |
Comment on lines
+253
to
+257
| public openAccountPanel(): void { | ||
| if (!this.container) { | ||
| return; | ||
| } | ||
| this.showCombinedPanel(); |
Comment on lines
+450
to
+456
| this._dismissBtn = this._renderDisposables.add(new Button(navRight, { | ||
| ...defaultButtonStyles, | ||
| secondary: true, | ||
| })); | ||
| this._dismissBtn.element.classList.add('chat-billing-banner-cta', 'chat-billing-banner-dismiss'); | ||
| this._dismissBtn.element.style.display = 'none'; | ||
| this._dismissBtn.label = localize('billingBanner.dismiss', "Dismiss"); |
Comment on lines
+510
to
+514
| margin-bottom: -10px; | ||
| } | ||
|
|
||
| .new-chat-input-container > .chat-billing-banner-container:empty { | ||
| display: none; |
| this._register({ dispose: () => editorOverflowWidgetsDomNode.remove() }); | ||
|
|
||
| // Copilot usage-based billing banner — sits above the notification | ||
| // widget and replaces all other above-input UI when visible. |
Comment on lines
+3713
to
+3739
| /** | ||
| * The billing banner is ~250px tall. In short panels (the chat welcome view | ||
| * in a narrow auxiliary bar, the inline chat, etc.) the banner can squeeze | ||
| * the input editor below its minimum height, making the input vanish. When | ||
| * we detect that, hide the banner so the user always has a usable input. | ||
| * | ||
| * `scrollHeight` is read from the container so the natural banner height is | ||
| * known even while the banner is currently hidden via CSS; this keeps the | ||
| * decision stable instead of oscillating once the banner is squeezed out. | ||
| */ | ||
| private _updateBillingBannerVisibility(): void { | ||
| if (!this.chatBillingBannerContainer) { | ||
| return; | ||
| } | ||
| if (this._maxHeight === undefined) { | ||
| this.chatBillingBannerContainer.classList.remove('chat-billing-banner-squeezed'); | ||
| return; | ||
| } | ||
|
|
||
| const bannerNaturalHeight = this.chatBillingBannerContainer.scrollHeight; | ||
| if (bannerNaturalHeight === 0) { | ||
| this.chatBillingBannerContainer.classList.remove('chat-billing-banner-squeezed'); | ||
| return; | ||
| } | ||
|
|
||
| const isCurrentlySqueezed = this.chatBillingBannerContainer.classList.contains('chat-billing-banner-squeezed'); | ||
| const containerHeight = this.container.offsetHeight; |
| if (this.options.renderStyle === 'compact') { | ||
| elements = dom.h('.interactive-input-part', [ | ||
| dom.h('.interactive-input-and-edit-session', [ | ||
| dom.h('.chat-billing-banner-container@chatBillingBannerContainer'), |
Comment on lines
+143
to
+154
|
|
||
| const observer = new (targetWindow as Window & typeof globalThis).ResizeObserver(() => this._resize()); | ||
| observer.observe(this._container); | ||
| this._register(toDisposable(() => observer.disconnect())); | ||
|
|
||
| this._readColor(); | ||
| this._resize(); | ||
|
|
||
| const tick = (ts: number) => { | ||
| if (this._stopped) { | ||
| return; | ||
| } |
Contributor
Screenshot ChangesBase: Changed (26)Errored (46)Fixtures that failed to render — no screenshot was produced.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
5.18.UBB.First.Time.-.Chat.mov
5.18.UBB.First.Time.-.Agents.Window.mov
Summary
When a user becomes eligible for Copilot usage-based billing (i.e. their Copilot token flips
isUsageBasedBilling), we should explain the new credit model on first use so they understand:This PR adds a lightweight, dismissible 3‑slide carousel banner that sits directly above the chat input in both surfaces, then disappears for good once the user clicks the check‑mark on the final slide.
Banner visibility is driven by a new workbench service the Copilot extension talks to via a bridge command, plus a hidden dev command so we can demo / screenshot / iterate without round‑tripping through token state.
Where it shows up
ChatInputPart)Panelworkbench.action.chat.openStatusDashboard(clicks the Copilot status-bar entry to open the existingChatStatusDashboardtooltip)newChatInput)Agentsworkbench.action.agents.openAccountMenu(opens the title‑bar account panel, which already shows the user's Copilot subscription state — the in-product status dashboard isn't reachable from the Agents window)The banner is gated per-user: state lives in
IChatBillingBannerService. Once the user dismisses,markCompleted()flips a persisted flag and the banner never reappears for that user.UX
Learn more on GitHub Docslink in slide 3's description.Dismiss(secondary) button and a primaryButtonwhose label + icon switch by variant. On the last slide we also reveal a check-mark dismiss icon..chat-billing-banner-body.fading { opacity: 0; }) — the per-glyph ASCII scramble we tried first felt noisy; the native fade reads much calmer.min-height: 26pxso card height is identical across slides — no jump as you click forward.ChatInputPartcomputes whether the banner would crush the input editor below its minimum height. If so it adds achat-billing-banner-squeezedclass that hides the banner for that layout pass. The banner never steals room from the editor.Architecture
IChatBillingBannerService(new,src/vs/workbench/contrib/chat/browser/widget/input/chatBillingBannerService.ts)Owns the entire visibility state machine:
isEnabled()/markCompleted()/setEnabled(boolean).onDidChangeso the widget re-renders without polling._chat.billing.usageBannerSetEnabled— the Copilot extension calls this from itscopilotTokenlistener so eligibility flips drive UI without the extension reaching into workbench internals.github.copilot.dev.showUsageBillingBanner— gated onIsDevelopmentContext, surfaced as Developer: Show Copilot Billing Banner for screenshots and review.ChatBillingBannerWidget(new, same folder)Stateless renderer over
IChatBillingBannerService+ a smallISlide[]model. Notable details:ChatBillingBannerVariant.Panel | .Agents); the variant is reflected on the root asvariant-panel/variant-agentsfor CSS targeting.descriptionand the CTA label / icon are picked from the variant. Slide 1, 2, and the docs link are shared.PANEL_CTA_COMMAND_ID,AGENTS_CTA_COMMAND_ID) and executed viaICommandService. This is what keeps layering clean — the workbench widget never imports anything fromsrc/vs/sessions, the Agents-window command is just a string.Codicon.copilot.id/Codicon.account.idfor CTA glyphs throughsupportIcons: trueonButton.workbench.action.chat.openStatusDashboard(new)Action that locates the Copilot status-bar entry and synthesizes a click on it to open the existing
ChatStatusDashboardhover. We deliberately re-use the existing dashboard tooltip rather than open a separate dialog — the panel variant CTA should send users to the same surface they'd reach themselves.workbench.action.agents.openAccountMenu(new,src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts)TitleBarAccountWidgetgains apublic openAccountPanel()that internally calls the existingshowCombinedPanel()(same code path as clicking the avatar).let activeTitleBarAccountWidgethandle; the widget assigns itself in its constructor and clears the handle on dispose. There's only ever one of these in the Agents window, so this is enough — no need for a dedicated service.Action2(f1: false) with idSESSIONS_OPEN_ACCOUNT_MENU_COMMAND_ID = 'workbench.action.agents.openAccountMenu'callsactiveTitleBarAccountWidget?.openAccountPanel().Agents-window eager import (
src/vs/sessions/contrib/chat/browser/chat.contribution.ts)The Agents window has its own
chat.contribution.ts, so workbench-only side-effect imports don't reach it until DI lazily resolves the service. Without help, the dev command Show Copilot Billing Banner registers in the panel but not in the Agents window. Fixed with a single side-effect import ofchatBillingBannerService.jsso the command and bridge are registered before the chat input widget tries to instantiate the banner.Layering
src/vs/sessionsmay import fromsrc/vs/workbench. The reverse is not allowed.vs/workbenchand references the Agents command by string id only, so this rule is preserved.vs/sessions/contrib/accountMenunext to the widget it drives.Files changed
New
src/vs/workbench/contrib/chat/browser/widget/input/chatBillingBannerService.tssrc/vs/workbench/contrib/chat/browser/widget/input/chatBillingBannerWidget.tssrc/vs/workbench/contrib/chat/browser/widget/input/media/chatBillingBannerWidget.cssModified
src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts— hosts the banner; adds_updateBillingBannerVisibility()and thechat-billing-banner-squeezedshort-panel class.src/vs/sessions/contrib/chat/browser/newChatInput.ts— hosts theAgentsvariant.src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts— exposesopenAccountPanel()+ registers the open-account-menu command.src/vs/sessions/contrib/chat/browser/chat.contribution.ts— eager import so the banner service registers in the Agents window.workbench.action.chat.openStatusDashboard.Commits
chat: add Copilot usage-based billing onboarding banner— the service, the widget, both surfaces wired up, panel CTA action, and the initial visual treatment.chat: polish billing banner copy, transition, and short-panel fit— rewrites the slide copy against the GitHub docs, replaces the ASCII scramble with the 160ms opacity fade, renames "Got it" → "Dismiss", reserves the nav row min-height so the card stops jumping, and adds thechat-billing-banner-squeezedshort-panel guard inchatInputPart.chat: route Agents billing banner CTA to title-bar account panel— drops the externalgithub.com/settings/copilotURL, adds theworkbench.action.agents.openAccountMenucommand + theTitleBarAccountWidget.openAccountPanel()entry point, eager-imports the banner service in the Agentschat.contribution.ts, and tailors slide 3 copy + the CTA label for the Agents variant.How to test
In both Code OSS (
./scripts/code.sh) and the Agents window (./scripts/code.sh --agents …):$(copilot) Copilot Dashboard; clicking opens the Copilot status-bar dashboard tooltip.$(account) View Account; clicking opens the title-bar account popover (avatar + Copilot subscription details).Localization
All user-facing strings go through
localize/localize2. Slide 3 has two description keys (billingBanner.slide3.descandbillingBanner.slide3.desc.agents) so the panel and Agents variants can be translated independently. New keys introduced this PR:billingBanner.slide1.title|desc,billingBanner.slide2.title|desc,billingBanner.slide3.title|desc|desc.agents|linkbillingBanner.cta.dashboard,billingBanner.cta.viewAccountbillingBanner.dismissopenAgentsAccountMenu(
billingBanner.cta.managefrom earlier iterations is removed.)