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<void> (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>
This commit is contained in:
Benjamin Christopher Simmonds
2026-07-01 15:17:14 +02:00
committed by GitHub
parent f30225dd69
commit 5a980fc4aa
5 changed files with 185 additions and 6 deletions
@@ -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",
@@ -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: '<angle>';
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;
}
}
@@ -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<unknown>;
}
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<void> {
if (this._running) {
return;
}
let result: void | Promise<unknown>;
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;
}
}
}
@@ -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<void> {
try {
await this.commandService.executeCommand(commandId);
} catch (err) {
this.logService.error('[SessionInputBanners] command failed', commandId, err);
}
}
private async _addressComments(sessionResource: URI): Promise<void> {
@@ -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);
}
}