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); } }