From 5a980fc4aaeb00bc3a381b77ee58edae3fcbde82 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:17:14 +0200 Subject: [PATCH] sessions: animate input banner border while an action is running (#323857) * sessions: animate input banner border while an action is running The CI checks and comments input banners in the Agents window now show an animated "border beam" while an async action is running, mirroring the chat input's in-flight border. This is primarily for the CI "Fix Checks" action, which spends a noticeable amount of time fetching check annotations before submitting the prompt. - SessionInputBannerWidget: action run() may return a promise; while it is pending the banner gets a `.working` class and its buttons are disabled. The animation is only shown after a 50ms delay so fast actions don't flicker. - CSS: added the comet/glow ::before/::after rings (padding + inverted mask trick) driven by a spinning conic gradient, respecting reduced-motion. - Color matches the chat input working-border token for comments banners, and the orange accent (same as the primary button) for CI banners. - Fix Checks / Address Comments await their work; Reveal Checks stays fire-and-forget. Added a CIFailuresLoading screenshot fixture. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: address input banner loading review feedback - Disable banner buttons immediately while an action is pending; only the animated border is delayed by 50ms to avoid flicker. - Make the click handler explicitly fire-and-forget and swallow action errors inside _runAction so it can never produce an unhandled rejection. - Type _executeCommand cleanly as Promise (await + try/catch) instead of returning the unknown-typed executeCommand result. - Expose SessionInputBannerWidget.setWorking so the CIFailuresLoading fixture reflects the real disabled-button state, not just the .working class. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * build: register session input banner CSS variables for hygiene The animated banner border introduced new CSS custom properties that must be listed in build/lib/stylelint/vscode-known-variables.json, otherwise the hygiene 'Unknown variable' check fails. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../lib/stylelint/vscode-known-variables.json | 3 + .../browser/media/sessionInputBanners.css | 89 +++++++++++++++++++ .../browser/sessionInputBannerWidget.ts | 81 ++++++++++++++++- .../browser/sessionInputBanners.ts | 10 ++- .../browser/sessionInputBanners.fixture.ts | 8 +- 5 files changed, 185 insertions(+), 6 deletions(-) diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 7d6bef2b41b..930248b9da5 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -1067,6 +1067,9 @@ "--chat-input-anim-angle", "--chat-input-anim-duration", "--chat-input-working-border-color1", + "--session-input-banner-anim-angle", + "--session-input-banner-anim-duration", + "--session-input-banner-working-border-color", "--chat-send-button-anim-angle", "--chat-setup-dialog-glow-angle", "--vscode-chat-font-family", diff --git a/src/vs/sessions/contrib/sessionInputBanners/browser/media/sessionInputBanners.css b/src/vs/sessions/contrib/sessionInputBanners/browser/media/sessionInputBanners.css index 04f547dcf78..5221910f525 100644 --- a/src/vs/sessions/contrib/sessionInputBanners/browser/media/sessionInputBanners.css +++ b/src/vs/sessions/contrib/sessionInputBanners/browser/media/sessionInputBanners.css @@ -22,18 +22,26 @@ gap: 8px; min-width: 0; box-sizing: border-box; + position: relative; padding: 4px 6px 4px 10px; border: var(--vscode-strokeThickness) solid var(--vscode-input-border, transparent); border-radius: var(--vscode-cornerRadius-small, 4px); background-color: color-mix(in srgb, var(--vscode-focusBorder) 6%, var(--vscode-editorWidget-background)); font-size: var(--vscode-chat-font-size-body-s); font-family: var(--vscode-chat-font-family, inherit); + /* Duration of the working/progress border comet animation and its accent + color. The color defaults to the same token used by the chat input's + animated border; the CI (accent-orange) banner overrides it below. */ + --session-input-banner-anim-duration: 4s; + --session-input-banner-working-border-color: var(--vscode-chat-inputWorkingBorderColor1); } /* Orange accent variant (used by the CI failures banner). */ .session-input-banner.accent-orange { border-color: var(--vscode-charts-orange); background-color: color-mix(in srgb, var(--vscode-charts-orange) 6%, var(--vscode-editorWidget-background)); + /* Match the animated border to the orange accent used for CI failures. */ + --session-input-banner-working-border-color: var(--vscode-charts-orange); } /* Leading icon. */ @@ -114,3 +122,84 @@ outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); outline-offset: -1px; } + +/* Animated "border beam" shown around the banner while an action is running + (e.g. the CI "Fix Checks" action, which fetches check annotations before + submitting a prompt). This mirrors the chat input's working border: a bright + comet travels around the perimeter leaving a short fading trail. The ring is + rendered as `::before` (sharp hairline) and `::after` (blurred glow) pseudo- + elements clipped to a hairline with a padding + inverted mask trick, so it + follows the banner's corner radius without disturbing its background. */ +@property --session-input-banner-anim-angle { + syntax: ''; + inherits: false; + initial-value: 135deg; +} + +@keyframes session-input-banner-working-border-spin { + from { + --session-input-banner-anim-angle: 135deg; + } + + to { + --session-input-banner-anim-angle: 495deg; + } +} + +.session-input-banner.working { + /* Let the comet ring (inset: -1px) show outside the card's edge. */ + overflow: visible; +} + +.session-input-banner.working::before, +.session-input-banner.working::after { + content: ''; + position: absolute; + inset: -1px; + border-radius: inherit; + -webkit-mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + pointer-events: none; + animation: session-input-banner-working-border-spin var(--session-input-banner-anim-duration) linear infinite; +} + +/* The beam: a tight bright arc (~40deg) with a short fade on an otherwise + transparent ring. As the angle rotates the bright spot travels like a comet. */ +.session-input-banner.working::before { + padding: 1px; + background: conic-gradient(from var(--session-input-banner-anim-angle), + transparent 0deg, + color-mix(in srgb, var(--session-input-banner-working-border-color) 90%, transparent) 20deg, + var(--session-input-banner-working-border-color) 30deg, + color-mix(in srgb, var(--session-input-banner-working-border-color) 60%, transparent) 50deg, + transparent 90deg, + transparent 360deg); + z-index: 2; +} + +/* Glow ring: a wider blurred conic sharing the beam's angle, forming a soft + halo that overlaps the beam line directly — no gap. */ +.session-input-banner.working::after { + padding: 2px; + background: conic-gradient(from var(--session-input-banner-anim-angle), + transparent 0deg, + color-mix(in srgb, var(--session-input-banner-working-border-color) 60%, transparent) 25deg, + color-mix(in srgb, var(--session-input-banner-working-border-color) 35%, transparent) 50deg, + transparent 90deg, + transparent 360deg); + filter: blur(1.5px); + z-index: 1; +} + +@media (prefers-reduced-motion: reduce) { + .session-input-banner.working::before, + .session-input-banner.working::after { + animation: none; + } +} diff --git a/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBannerWidget.ts b/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBannerWidget.ts index 8747d3ea890..81db26b1cfc 100644 --- a/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBannerWidget.ts +++ b/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBannerWidget.ts @@ -8,6 +8,7 @@ import * as dom from '../../../../base/browser/dom.js'; import { Button } from '../../../../base/browser/ui/button/button.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; +import { disposableTimeout } from '../../../../base/common/async.js'; import type { ThemeIcon } from '../../../../base/common/themables.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; @@ -16,11 +17,22 @@ import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultS import { asCssVariable } from '../../../../platform/theme/common/colorUtils.js'; import { chartsOrange } from '../../../../platform/theme/common/colors/chartsColors.js'; +/** + * Delay before the "working" border animation is shown after an async action + * starts. Actions that settle faster than this don't animate, avoiding a + * loading flicker for very fast work. + */ +const SHOW_WORKING_DELAY_MS = 50; + export interface ISessionInputBannerAction { readonly label: string; /** Renders the action with the prominent button colors. */ readonly primary?: boolean; - run(): void; + /** + * Runs the action. When a {@link Promise} is returned, the banner shows an + * animated "working" border and disables its buttons until it settles. + */ + run(): void | Promise; } export interface ISessionInputBanner { @@ -45,6 +57,11 @@ export class SessionInputBannerWidget extends Disposable { readonly domNode: HTMLElement; + private readonly _buttons: Button[] = []; + + /** Guards against overlapping runs while an action is already in flight. */ + private _running = false; + constructor( banner: ISessionInputBanner, @IHoverService private readonly hoverService: IHoverService, @@ -89,7 +106,8 @@ export class SessionInputBannerWidget extends Disposable { button.element.classList.add('session-input-banner-action'); button.label = action.label; button.element.ariaLabel = `${banner.ariaLabel} ${action.label}`; - this._register(button.onDidClick(() => action.run())); + this._buttons.push(button); + this._register(button.onDidClick(() => { void this._runAction(action); })); } const dismiss = dom.append(this.domNode, dom.$('button.session-input-banner-dismiss')) as HTMLButtonElement; @@ -102,4 +120,63 @@ export class SessionInputBannerWidget extends Disposable { banner.dismiss(); })); } + + /** + * Runs an action. When it returns a promise (e.g. the CI "Fix Checks" + * action, which fetches check annotations before submitting a prompt), the + * banner disables its buttons for the duration and shows an animated + * "working" border so the delay is visible to the user. Buttons are disabled + * immediately, but the animation is only shown once the work has been running + * for {@link SHOW_WORKING_DELAY_MS} so very fast actions don't cause a + * loading flicker. Never rejects: action errors are swallowed here since this + * is invoked fire-and-forget from the click handler (the action is + * responsible for surfacing its own errors). + */ + private async _runAction(action: ISessionInputBannerAction): Promise { + if (this._running) { + return; + } + let result: void | Promise; + try { + result = action.run(); + } catch { + return; + } + if (!result) { + return; + } + this._running = true; + // Disable the buttons immediately while the action is pending, but delay + // showing the animated border so very fast actions don't flicker. + this._setButtonsEnabled(false); + const showAnimation = disposableTimeout(() => this.domNode.classList.add('working'), SHOW_WORKING_DELAY_MS); + try { + await result; + } catch { + // Swallow: the action logs/surfaces its own errors and this handler + // is fire-and-forget, so it must not produce an unhandled rejection. + } finally { + showAnimation.dispose(); + this.domNode.classList.remove('working'); + this._setButtonsEnabled(true); + this._running = false; + } + } + + /** + * Renders the in-flight "working" state: shows the animated border and + * disables the action buttons. Intended for fixtures/tests that need to + * display the loading appearance statically; production toggles this state + * via {@link _runAction} (which additionally delays the animation). + */ + setWorking(working: boolean): void { + this.domNode.classList.toggle('working', working); + this._setButtonsEnabled(!working); + } + + private _setButtonsEnabled(enabled: boolean): void { + for (const button of this._buttons) { + button.enabled = enabled; + } + } } diff --git a/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBanners.ts b/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBanners.ts index 19fb1d9f0ce..25c33f140a0 100644 --- a/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBanners.ts +++ b/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBanners.ts @@ -198,7 +198,7 @@ export class SessionInputBanners extends Disposable { }, { label: localize('ci.revealChecks', "Reveal Checks"), - run: () => this._executeCommand(REVEAL_CI_CHECKS_COMMAND_ID), + run: () => { void this._executeCommand(REVEAL_CI_CHECKS_COMMAND_ID); }, }, ], dismiss: () => this._dismiss(STORAGE_KEY_CI_DISMISSED, this._ciDismissed, state.sessionId), @@ -257,8 +257,12 @@ export class SessionInputBanners extends Disposable { } } - private _executeCommand(commandId: string): void { - this.commandService.executeCommand(commandId).catch(err => this.logService.error('[SessionInputBanners] command failed', commandId, err)); + private async _executeCommand(commandId: string): Promise { + try { + await this.commandService.executeCommand(commandId); + } catch (err) { + this.logService.error('[SessionInputBanners] command failed', commandId, err); + } } private async _addressComments(sessionResource: URI): Promise { diff --git a/src/vs/sessions/contrib/sessionInputBanners/test/browser/sessionInputBanners.fixture.ts b/src/vs/sessions/contrib/sessionInputBanners/test/browser/sessionInputBanners.fixture.ts index a4a0840593b..44602c64f1e 100644 --- a/src/vs/sessions/contrib/sessionInputBanners/test/browser/sessionInputBanners.fixture.ts +++ b/src/vs/sessions/contrib/sessionInputBanners/test/browser/sessionInputBanners.fixture.ts @@ -13,6 +13,11 @@ export default defineThemedFixtureGroup({ path: 'sessions/inputBanners/' }, { render: (context) => renderBanners(context, [ciBanner(2, 5, 3)]), }), + CIFailuresLoading: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (context) => renderBanners(context, [ciBanner(2, 5, 3)], 480, true), + }), + Comments: defineComponentFixture({ labels: { kind: 'screenshot' }, render: (context) => renderBanners(context, [commentsBanner(3, 'mixed')]), @@ -73,7 +78,7 @@ function commentsBanner(count: number, kind: 'pr' | 'agent' | 'mixed'): ISession }; } -function renderBanners({ container, disposableStore, theme }: ComponentFixtureContext, banners: readonly ISessionInputBanner[], width = 480): void { +function renderBanners({ container, disposableStore, theme }: ComponentFixtureContext, banners: readonly ISessionInputBanner[], width = 480, working = false): void { container.style.width = `${width}px`; container.style.display = 'flex'; container.style.flexDirection = 'column'; @@ -85,6 +90,7 @@ function renderBanners({ container, disposableStore, theme }: ComponentFixtureCo for (const banner of banners) { const widget = disposableStore.add(instantiationService.createInstance(SessionInputBannerWidget, banner)); + widget.setWorking(working); container.appendChild(widget.domNode); } }