From 80a71ed39a43df8dd88609eed263e333a39343f6 Mon Sep 17 00:00:00 2001 From: justschen Date: Sun, 26 Apr 2026 16:41:04 -0700 Subject: [PATCH 1/8] aquarium? --- build/lib/i18n.resources.json | 4 + .../lib/stylelint/vscode-known-variables.json | 2 + src/vs/sessions/common/contextkeys.ts | 6 + .../aquarium/browser/aquarium.contribution.ts | 28 + .../aquarium/browser/aquariumOverlay.ts | 526 ++++++++++++++++++ .../sessions/contrib/aquarium/browser/fish.ts | 218 ++++++++ .../aquarium/browser/media/aquarium.css | 203 +++++++ src/vs/sessions/sessions.common.main.ts | 1 + 8 files changed, 988 insertions(+) create mode 100644 src/vs/sessions/contrib/aquarium/browser/aquarium.contribution.ts create mode 100644 src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts create mode 100644 src/vs/sessions/contrib/aquarium/browser/fish.ts create mode 100644 src/vs/sessions/contrib/aquarium/browser/media/aquarium.css diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json index fa167fffcc93d..8e8e57d092bd7 100644 --- a/build/lib/i18n.resources.json +++ b/build/lib/i18n.resources.json @@ -708,6 +708,10 @@ "name": "vs/sessions/contrib/welcome", "project": "vscode-sessions" }, + { + "name": "vs/sessions/contrib/aquarium", + "project": "vscode-sessions" + }, { "name": "vs/sessions/contrib/chatDebug", "project": "vscode-sessions" diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index ce2c2f708dd47..91b697e2c61a5 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -941,6 +941,7 @@ "--chat-current-response-min-height", "--chat-smooth-delay", "--chat-smooth-duration", + "--fish-wiggle-delay", "--inline-chat-frame-progress", "--insert-border-color", "--last-tab-margin-right", @@ -958,6 +959,7 @@ "--notebook-editor-font-size", "--notebook-editor-font-weight", "--outline-element-color", + "--strip-index", "--separator-border", "--chat-bar-background", "--chat-tab-active-foreground", diff --git a/src/vs/sessions/common/contextkeys.ts b/src/vs/sessions/common/contextkeys.ts index a42408dbc50da..20de8e03df77a 100644 --- a/src/vs/sessions/common/contextkeys.ts +++ b/src/vs/sessions/common/contextkeys.ts @@ -33,6 +33,12 @@ export const SessionsWelcomeVisibleContext = new RawContextKey('session //#endregion +//#region < --- Aquarium --- > + +export const SessionsAquariumActiveContext = new RawContextKey('sessionsAquariumActive', false, localize('sessionsAquariumActive', "Whether the sessions aquarium overlay is active")); + +//#endregion + //#region < --- Editor --- > export const EditorMaximizedContext = new RawContextKey('editorMaximized', false, localize('editorMaximized', "Whether the editor area is maximized")); diff --git a/src/vs/sessions/contrib/aquarium/browser/aquarium.contribution.ts b/src/vs/sessions/contrib/aquarium/browser/aquarium.contribution.ts new file mode 100644 index 0000000000000..1b297239d4312 --- /dev/null +++ b/src/vs/sessions/contrib/aquarium/browser/aquarium.contribution.ts @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/aquarium.css'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { AquariumOverlay } from './aquariumOverlay.js'; + +/** + * Lifecycle owner for the Agents window aquarium. Instantiates the overlay + * (which renders its persistent toggle button and manages the on/off state). + */ +class SessionsAquariumContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.sessionsAquarium'; + + constructor( + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + this._register(instantiationService.createInstance(AquariumOverlay)); + } +} + +registerWorkbenchContribution2(SessionsAquariumContribution.ID, SessionsAquariumContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts b/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts new file mode 100644 index 0000000000000..68ce08c45fe52 --- /dev/null +++ b/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts @@ -0,0 +1,526 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { addDisposableListener, EventType, getWindow, scheduleAtNextAnimationFrame } from '../../../../base/browser/dom.js'; +import { createInstantHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { localize } from '../../../../nls.js'; +import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { IsNewChatSessionContext, SessionsAquariumActiveContext } from '../../../common/contextkeys.js'; +import { disposeSharedFishDefs, Fish, pickRandomSpecies } from './fish.js'; + +const FISH_COUNT = 50; +const FISH_MIN_SIZE = 22; +const FISH_MAX_SIZE = 48; + +/** Pixels around the cursor where fish flee. */ +const SCATTER_RADIUS = 130; +/** Pixels around a food pellet where the fish considers it grabbable. */ +const EAT_RADIUS = 14; +/** Maximum distance a fish will sense a food pellet from. Smaller = food + * must land near a fish to attract attention; larger = fish swim across + * the tank to it. */ +const FOOD_DETECT_RADIUS = 160; +/** Maximum concurrent food pellets in the water. */ +const MAX_FOOD = 12; +/** Soft margin around the aquarium bounds where fish start to turn back. */ +const WALL_MARGIN = 36; + +/** Base swimming speed (px/sec). */ +const BASE_SPEED = 60; +/** Maximum normal swim speed (px/sec). */ +const MAX_SPEED = 120; +/** Maximum panic swim speed (px/sec). */ +const PANIC_MAX_SPEED = 320; +/** How long a fish stays in panic mode after being scattered. */ +const PANIC_DURATION_MS = 600; + +/** + * Per-fish probability (per second) of starting a spontaneous "dart": a brief + * burst of speed in a random direction with no external trigger. With ~35 fish + * and 0.06/sec each, the aquarium sees a dart roughly every 0.5s. + */ +const DART_RATE_PER_SECOND = 0.06; +/** Random dart impulse strength (px/sec velocity boost). */ +const DART_IMPULSE = 240; + +/** Context keys we react to for visibility changes. */ +const NEW_SESSION_KEY_SET = new Set([IsNewChatSessionContext.key]); + +interface IFoodPellet { + readonly element: HTMLDivElement; + x: number; + y: number; + /** Sink speed (px/sec). */ + vy: number; +} + +/** + * The aquarium overlay: a transparent absolutely-positioned layer mounted + * inside the workbench main container. When activated, it fills the chat bar + * region with VS Code logo "fish" that swim around, scatter from the cursor, + * and chase food pellets dropped on click. Pointer events are not blocked, + * so all underlying chat UI remains fully interactive. + * + * The overlay also owns a small floating toggle button anchored just above + * the chat input box; the button persists across show/hide so users can + * always switch the aquarium back on. + */ +export class AquariumOverlay extends Disposable { + + private readonly mainContainer: HTMLElement; + + /** The persistent toggle button. Always present once the overlay is created. */ + private readonly toggleButton: HTMLButtonElement; + + /** Per-activation state (DOM, RAF, listeners, fish, food). */ + private readonly activeRef = this._register(new MutableDisposable()); + + private readonly activeContextKey: IContextKey; + + constructor( + @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IHoverService private readonly hoverService: IHoverService, + ) { + super(); + + this.mainContainer = layoutService.mainContainer; + this.activeContextKey = SessionsAquariumActiveContext.bindTo(contextKeyService); + + this.toggleButton = this.createToggleButton(); + // Mount the button as a real child of the chat bar's part container so + // CSS positioning (top-right) is relative to that element. No manual + // bounding-rect math required. + this.tryMountToggleButton(0); + + // Only show the button (and allow the aquarium) on the new-session view. + // When the user opens an existing session, hide the button and tear down + // any active aquarium. + this.applyNewSessionVisibility(); + this._register(this.contextKeyService.onDidChangeContext(e => { + if (e.affectsSome(NEW_SESSION_KEY_SET)) { + this.applyNewSessionVisibility(); + } + })); + } + + private isNewSession(): boolean { + return this.contextKeyService.getContextKeyValue(IsNewChatSessionContext.key) ?? true; + } + + private applyNewSessionVisibility(): void { + const isNew = this.isNewSession(); + this.toggleButton.style.display = isNew ? '' : 'none'; + if (!isNew && this.activeRef.value) { + this.deactivate(); + } + } + + /** + * Attempt to mount the toggle button inside the chat bar's part container. + * Retries on the next animation frame while the chat bar is still being + * created during workbench restore. + */ + private tryMountToggleButton(attempt: number): void { + const window = getWindow(this.mainContainer); + const chatBarElement = this.layoutService.getContainer(window, Parts.CHATBAR_PART); + if (chatBarElement && this.layoutService.isVisible(Parts.CHATBAR_PART, window) && chatBarElement.isConnected) { + chatBarElement.appendChild(this.toggleButton); + this._register(toDisposable(() => this.toggleButton.remove())); + return; + } + if (attempt >= 60) { + return; // give up; nothing else to do + } + const sched = scheduleAtNextAnimationFrame(window, () => this.tryMountToggleButton(attempt + 1)); + this._register(sched); + } + + private createToggleButton(): HTMLButtonElement { + const button = document.createElement('button'); + button.className = 'agents-aquarium-toggle'; + button.type = 'button'; + this.updateToggleButtonVisual(button, false); + + this._register(addDisposableListener(button, EventType.CLICK, e => { + // Don't let the click bubble into the chat bar's own handlers. + e.preventDefault(); + e.stopPropagation(); + this.toggle(); + })); + // Tooltip via the workbench hover service. + const hoverDelegate = this._register(createInstantHoverDelegate()); + this._register(this.hoverService.setupManagedHover( + hoverDelegate, + button, + () => this.activeRef.value ? localize('aquarium.hide', "Hide Aquarium") : localize('aquarium.show', "Show Aquarium"), + )); + + return button; + } + + private updateToggleButtonVisual(button: HTMLButtonElement, active: boolean): void { + button.classList.toggle('active', active); + // Build the icon as a real DOM child instead of innerHTML to satisfy + // the workbench Trusted Types policy. + button.replaceChildren(); + const iconSpan = document.createElement('span'); + const iconClasses = ThemeIcon.asClassName(active ? Codicon.close : Codicon.heartFilled).split(/\s+/).filter(Boolean); + for (const cls of iconClasses) { + iconSpan.classList.add(cls); + } + button.appendChild(iconSpan); + button.setAttribute('aria-pressed', String(active)); + button.setAttribute('aria-label', active ? localize('aquarium.hide', "Hide Aquarium") : localize('aquarium.show', "Show Aquarium")); + } + + private toggle(): void { + if (this.activeRef.value) { + this.deactivate(); + } else { + this.activate(); + } + } + + private activate(): void { + if (this.activeRef.value) { + return; + } + let active: IActiveAquarium; + try { + active = createActiveAquarium(this.mainContainer, this.layoutService); + } catch (e) { + // Defensively log and bail; never leave the overlay in a half-built + // state that confuses the toggle button. + console.error('[aquarium] failed to activate', e); + return; + } + this.activeRef.value = active; + this.activeContextKey.set(true); + this.updateToggleButtonVisual(this.toggleButton, true); + } + + private deactivate(): void { + if (!this.activeRef.value) { + return; + } + this.activeRef.clear(); + this.activeContextKey.set(false); + this.updateToggleButtonVisual(this.toggleButton, false); + } +} + +interface IActiveAquarium extends IDisposable { } + +/** + * Build the live aquarium: water layer, fish, food, mouse handling, RAF loop. + * All resources are owned by the returned disposable; `dispose()` removes + * everything and stops the animation loop. + */ +function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbenchLayoutService): IActiveAquarium { + const store = new DisposableStore(); + const targetWindow = getWindow(mainContainer); + + // Host the aquarium INSIDE the chat bar's part container so the chat + // input UI (later DOM siblings) naturally paints on top. This avoids + // any z-index gymnastics — water sits behind, fish included. + const chatBar = layoutService.getContainer(targetWindow, Parts.CHATBAR_PART); + if (!chatBar || !layoutService.isVisible(Parts.CHATBAR_PART, targetWindow)) { + // No chat bar to host the aquarium — return an inert disposable. + return store; + } + + // --- DOM setup --- + const water = document.createElement('div'); + water.className = 'agents-aquarium-water'; + // Insert as the FIRST child so all subsequent chat bar content paints over it. + chatBar.insertBefore(water, chatBar.firstChild); + store.add(toDisposable(() => water.remove())); + + const fishLayer = document.createElement('div'); + fishLayer.className = 'agents-aquarium-fish-layer'; + water.appendChild(fishLayer); + + const foodLayer = document.createElement('div'); + foodLayer.className = 'agents-aquarium-food-layer'; + water.appendChild(foodLayer); + + // --- Bounds (the water element fills the chat bar via CSS inset:0) --- + const bounds = { width: 0, height: 0 }; + // Cached water rect screen-space top/left so the per-mousemove handler + // doesn't trigger a layout flush via getBoundingClientRect(). + const waterScreenOffset = { left: 0, top: 0 }; + const updateBounds = () => { + bounds.width = water.clientWidth; + bounds.height = water.clientHeight; + const r = water.getBoundingClientRect(); + waterScreenOffset.left = r.left; + waterScreenOffset.top = r.top; + }; + + // --- Spawn fish --- + const fish: Fish[] = []; + + updateBounds(); + const resizeObserver = new ResizeObserver(() => { + updateBounds(); + // Re-clamp fish if the bounds shrank below their position. + for (const f of fish) { + f.x = Math.min(f.x, Math.max(0, bounds.width - f.size)); + f.y = Math.min(f.y, Math.max(0, bounds.height - f.size)); + } + }); + resizeObserver.observe(water); + store.add(toDisposable(() => resizeObserver.disconnect())); + + for (let i = 0; i < FISH_COUNT; i++) { + const size = randomBetween(FISH_MIN_SIZE, FISH_MAX_SIZE); + const angle = Math.random() * Math.PI * 2; + const speed = randomBetween(BASE_SPEED * 0.6, BASE_SPEED * 1.2); + const f = new Fish({ + species: pickRandomSpecies(), + size, + x: randomBetween(0, Math.max(1, bounds.width - size)), + y: randomBetween(0, Math.max(1, bounds.height - size)), + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed, + }, targetWindow.document); + fish.push(f); + fishLayer.appendChild(f.element); + } + store.add(toDisposable(() => { + for (const f of fish) { + f.element.remove(); + } + // Tear down the shared SVG defs container along with the last + // active aquarium so we don't leak it across reloads. + disposeSharedFishDefs(); + })); + + // --- Food --- + const food: IFoodPellet[] = []; + const removeFood = (pellet: IFoodPellet) => { + const idx = food.indexOf(pellet); + if (idx !== -1) { + food.splice(idx, 1); + pellet.element.remove(); + } + }; + + // --- Mouse tracking & food drops on the main container --- + // pointer-events:none on the water layer means the underlying chat UI + // receives all events normally; we listen on the main container so we + // always know cursor position even when over the chat input. + // + // `waterScreenOffset` is kept fresh by `updateBounds()` (called from the + // ResizeObserver above). We also refresh on window scroll/resize since + // those don't trigger our element-level ResizeObserver. + store.add(addDisposableListener(targetWindow, EventType.RESIZE, updateBounds, { passive: true })); + store.add(addDisposableListener(targetWindow, 'scroll', updateBounds, { passive: true, capture: true })); + + let mouseX = -1e6; + let mouseY = -1e6; + store.add(addDisposableListener(mainContainer, EventType.MOUSE_MOVE, (e: MouseEvent) => { + mouseX = e.clientX - waterScreenOffset.left; + mouseY = e.clientY - waterScreenOffset.top; + }, { passive: true })); + store.add(addDisposableListener(mainContainer, EventType.MOUSE_LEAVE, () => { + mouseX = -1e6; + mouseY = -1e6; + }, { passive: true })); + + store.add(addDisposableListener(mainContainer, EventType.MOUSE_DOWN, (e: MouseEvent) => { + // Only spawn food on plain left clicks against background-ish surfaces. + if (e.button !== 0) { + return; + } + const target = e.target as HTMLElement | null; + if (!isBackgroundClick(target)) { + return; + } + // Refresh once to be safe (mousedown is rare). + updateBounds(); + const fx = e.clientX - waterScreenOffset.left; + const fy = e.clientY - waterScreenOffset.top; + if (fx < 0 || fy < 0 || fx > bounds.width || fy > bounds.height) { + return; + } + spawnFood(fx, fy); + })); + + function spawnFood(fx: number, fy: number): void { + // Cap concurrent food: drop the oldest pellet to make room. + while (food.length >= MAX_FOOD) { + const oldest = food[0]; + removeFood(oldest); + } + const el = document.createElement('div'); + el.className = 'agents-aquarium-food'; + el.style.transform = `translate(${fx}px, ${fy}px)`; + foodLayer.appendChild(el); + food.push({ element: el, x: fx, y: fy, vy: randomBetween(20, 35) }); + } + + // --- RAF loop --- + const reduceMotion = targetWindow.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false; + let lastFrame = performance.now(); + let rafDisposable: IDisposable | undefined; + + const tick = () => { + const now = performance.now(); + const dtMs = Math.min(now - lastFrame, 100); // clamp big stalls + const dt = dtMs / 1000; + lastFrame = now; + + // Skip work when window is hidden (still keeps the RAF alive lazily). + const visible = targetWindow.document.visibilityState !== 'hidden'; + if (visible && !reduceMotion) { + updateFood(dt); + updateFish(dt); + } + + rafDisposable = scheduleAtNextAnimationFrame(targetWindow, tick); + }; + + function updateFood(dt: number): void { + // Sink the pellets and remove any that fall off the bottom. + for (let i = food.length - 1; i >= 0; i--) { + const p = food[i]; + p.y += p.vy * dt; + p.element.style.transform = `translate(${p.x.toFixed(1)}px, ${p.y.toFixed(1)}px)`; + if (p.y > bounds.height + 10) { + removeFood(p); + } + } + } + + function updateFish(dt: number): void { + const now = performance.now(); + for (const f of fish) { + // --- Steering: gentle wander --- + let ax = (Math.random() - 0.5) * 60; + let ay = (Math.random() - 0.5) * 60; + + // --- Spontaneous dart --- + // With probability DART_RATE_PER_SECOND * dt this frame, kick the + // fish in a random direction and put it briefly into panic mode so + // it can exceed normal max speed. + if (Math.random() < DART_RATE_PER_SECOND * dt) { + const dartAngle = Math.random() * Math.PI * 2; + f.vx += Math.cos(dartAngle) * DART_IMPULSE; + f.vy += Math.sin(dartAngle) * DART_IMPULSE; + f.panicUntil = now + PANIC_DURATION_MS; + } + + // --- Wall repel (soft) --- + const cx = f.x + f.size / 2; + const cy = f.y + f.size / 2; + if (cx < WALL_MARGIN) { + ax += (WALL_MARGIN - cx) * 6; + } else if (cx > bounds.width - WALL_MARGIN) { + ax -= (cx - (bounds.width - WALL_MARGIN)) * 6; + } + if (cy < WALL_MARGIN) { + ay += (WALL_MARGIN - cy) * 6; + } else if (cy > bounds.height - WALL_MARGIN) { + ay -= (cy - (bounds.height - WALL_MARGIN)) * 6; + } + + // --- Mouse scatter --- + const dxM = cx - mouseX; + const dyM = cy - mouseY; + const distM2 = dxM * dxM + dyM * dyM; + if (distM2 < SCATTER_RADIUS * SCATTER_RADIUS) { + const distM = Math.max(Math.sqrt(distM2), 1); + const force = (1 - distM / SCATTER_RADIUS) * 1200; + ax += (dxM / distM) * force; + ay += (dyM / distM) * force; + f.panicUntil = now + PANIC_DURATION_MS; + } + + // --- Seek nearest food (only within FOOD_DETECT_RADIUS) --- + let nearest: IFoodPellet | undefined; + let nearestDist2 = FOOD_DETECT_RADIUS * FOOD_DETECT_RADIUS; + for (const p of food) { + const dxF = (p.x) - cx; + const dyF = (p.y) - cy; + const d2 = dxF * dxF + dyF * dyF; + if (d2 < nearestDist2) { + nearestDist2 = d2; + nearest = p; + } + } + if (nearest) { + const distF = Math.max(Math.sqrt(nearestDist2), 1); + if (distF < EAT_RADIUS) { + removeFood(nearest); + } else { + ax += ((nearest.x) - cx) / distF * 200; + ay += ((nearest.y) - cy) / distF * 200; + } + } + + // --- Integrate --- + f.vx += ax * dt; + f.vy += ay * dt; + + // Damp toward base speed; cap by panic state. + const speed2 = f.vx * f.vx + f.vy * f.vy; + const maxSpeed = now < f.panicUntil ? PANIC_MAX_SPEED : MAX_SPEED; + if (speed2 > maxSpeed * maxSpeed) { + const speed = Math.sqrt(speed2); + f.vx = (f.vx / speed) * maxSpeed; + f.vy = (f.vy / speed) * maxSpeed; + } + + f.x += f.vx * dt; + f.y += f.vy * dt; + + // Hard clamp as a safety net. + f.x = clamp(f.x, -f.size * 0.25, bounds.width - f.size * 0.75); + f.y = clamp(f.y, -f.size * 0.25, bounds.height - f.size * 0.75); + + f.applyTransform(); + } + } + + rafDisposable = scheduleAtNextAnimationFrame(targetWindow, tick); + store.add(toDisposable(() => rafDisposable?.dispose())); + + // --- Fade-in class --- + scheduleAtNextAnimationFrame(targetWindow, () => water.classList.add('visible')); + + return store; +} + +/** Determine whether a click target is "background-ish" (not on a control). */ +function isBackgroundClick(target: HTMLElement | null): boolean { + if (!target) { + return false; + } + // Don't drop food when the user is clicking on an input, button, link, + // or anything inside the chat input editor. + if (target.closest('input, textarea, select, button, a, [role="button"], [role="link"], [role="textbox"], [role="combobox"], [role="menuitem"], [role="tab"], .monaco-editor, .scroll-decoration, .monaco-list-row')) { + return false; + } + return true; +} + +function randomBetween(min: number, max: number): number { + return min + Math.random() * (max - min); +} + +function clamp(value: number, min: number, max: number): number { + if (max < min) { + // Bounds smaller than the fish; keep it pinned to min. + return min; + } + return Math.min(Math.max(value, min), max); +} diff --git a/src/vs/sessions/contrib/aquarium/browser/fish.ts b/src/vs/sessions/contrib/aquarium/browser/fish.ts new file mode 100644 index 0000000000000..6cad0916bcdd9 --- /dev/null +++ b/src/vs/sessions/contrib/aquarium/browser/fish.ts @@ -0,0 +1,218 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * VS Code logo "fish" used by the Agents window aquarium. Each fish is a small + * SVG element styled with `color:` so the silhouette inherits via `currentColor`, + * with a tail group running a CSS wiggle animation. + */ + +/** VS Code logo silhouette path (extracted from sessions/contrib/chat/browser/media/vscode-icon.svg). */ +const VSCODE_LOGO_PATH = 'M65.566 89.4264C66.889 89.9418 68.3976 89.9087 69.7329 89.2662L87.0271 80.9446C88.8444 80.0701 90 78.231 90 76.2132V19.7872C90 17.7695 88.8444 15.9303 87.0271 15.0559L69.7329 6.73395C67.9804 5.89069 65.9295 6.09724 64.3914 7.21543C64.1716 7.37517 63.9624 7.55352 63.7659 7.75007L30.6583 37.9548L16.2372 27.0081C14.8948 25.9891 13.0171 26.0726 11.7702 27.2067L7.14495 31.4141C5.61986 32.8014 5.61811 35.2007 7.14117 36.5902L19.6476 48.0001L7.14117 59.4099C5.61811 60.7995 5.61986 63.1988 7.14495 64.5861L11.7702 68.7934C13.0171 69.9276 14.8948 70.0111 16.2372 68.9921L30.6583 58.0453L63.7659 88.2501C64.2897 88.7741 64.9046 89.1688 65.566 89.4264ZM69.0128 28.9311L43.8917 48.0001L69.0128 67.069V28.9311Z'; + +/** The three VS Code release channel colors used as fish "species". */ +export const enum FishSpecies { + Stable = 'stable', + Insiders = 'insiders', + Exploration = 'exploration', +} + +const SPECIES_COLOR: Record = { + [FishSpecies.Stable]: '#007ACC', + [FishSpecies.Insiders]: '#24bfa5', + [FishSpecies.Exploration]: '#E04F00', +}; + +/** Pick a random species, weighted Stable > Insiders > Exploration. */ +export function pickRandomSpecies(): FishSpecies { + const r = Math.random(); + if (r < 0.5) { + return FishSpecies.Stable; + } + if (r < 0.8) { + return FishSpecies.Insiders; + } + return FishSpecies.Exploration; +} + +/** + * Tear down the shared SVG defs container. Call when no fish are active. + */ +export function disposeSharedFishDefs(): void { + if (sharedDefsContainer) { + sharedDefsContainer.remove(); + sharedDefsContainer = undefined; + } +} + +export interface IFishOptions { + readonly species: FishSpecies; + readonly size: number; + readonly x: number; + readonly y: number; + readonly vx: number; + readonly vy: number; +} + +/** + * A swimming fish. Owns its DOM element and exposes mutable position/velocity + * for the aquarium's RAF loop to update. + */ +export class Fish { + + readonly element: HTMLDivElement; + private readonly innerElement: HTMLDivElement; + + x: number; + y: number; + vx: number; + vy: number; + readonly size: number; + + /** Timestamp until which this fish is in "panic" mode (faster, scattering). */ + panicUntil = 0; + + /** Last facing direction; only flip the element when it changes. */ + private facingRight = true; + + constructor(opts: IFishOptions, targetDocument: Document) { + this.x = opts.x; + this.y = opts.y; + this.vx = opts.vx; + this.vy = opts.vy; + this.size = opts.size; + + this.element = targetDocument.createElement('div'); + this.element.className = 'agents-aquarium-fish'; + this.element.style.width = `${opts.size}px`; + this.element.style.height = `${opts.size}px`; + this.element.style.color = SPECIES_COLOR[opts.species]; + // Stagger the wiggle so fish aren't synchronized. + this.element.style.setProperty('--fish-wiggle-delay', `${(Math.random() * -1).toFixed(2)}s`); + + // Inner element receives the directional flip so the wiggle keyframes + // (applied to the tail) are unaffected by direction changes. + this.innerElement = targetDocument.createElement('div'); + this.innerElement.className = 'agents-aquarium-fish-inner'; + this.innerElement.appendChild(buildFishSvg(targetDocument)); + this.element.appendChild(this.innerElement); + + this.applyTransform(); + } + + /** Write the current position/facing to the DOM. */ + applyTransform(): void { + // Translate is on the outer element; flip is on the inner element so the + // tail's CSS animation keeps spinning around its local origin. + this.element.style.transform = `translate(${this.x.toFixed(1)}px, ${this.y.toFixed(1)}px)`; + const wantFacingRight = this.vx >= 0; + if (wantFacingRight !== this.facingRight) { + this.facingRight = wantFacingRight; + this.innerElement.style.transform = wantFacingRight ? '' : 'scaleX(-1)'; + } + } +} + +const SVG_NS = 'http://www.w3.org/2000/svg'; + +/** + * Number of vertical strips the body is sliced into. More strips = smoother + * wave (smaller per-strip phase delta), fewer visible seams. Kept moderate + * because each strip = one path + one CSS animation per fish; with 50 fish + * this contributes meaningfully to layer/animation work. + */ +const NUM_BODY_STRIPS = 10; + +/** The body's bounding range in the original logo's user units. */ +const BODY_X_START = 5; +const BODY_X_END = 90; + +/** + * Lazily-built shared SVG element holding the strip clipPath defs. All fish + * reference these via `clip-path: url(#...)` instead of redefining their own. + * Saves ~NUM_BODY_STRIPS * (FISH_COUNT - 1) clipPath nodes. + */ +let sharedDefsContainer: SVGSVGElement | undefined; + +function ensureSharedDefs(targetDocument: Document): void { + if (sharedDefsContainer) { + return; + } + const stripWidth = (BODY_X_END - BODY_X_START) / NUM_BODY_STRIPS; + const container = targetDocument.createElementNS(SVG_NS, 'svg'); + container.setAttribute('xmlns', SVG_NS); + container.setAttribute('width', '0'); + container.setAttribute('height', '0'); + container.setAttribute('aria-hidden', 'true'); + container.style.position = 'absolute'; + container.style.width = '0'; + container.style.height = '0'; + container.style.overflow = 'hidden'; + container.style.pointerEvents = 'none'; + const defs = targetDocument.createElementNS(SVG_NS, 'defs'); + for (let i = 0; i < NUM_BODY_STRIPS; i++) { + const clip = targetDocument.createElementNS(SVG_NS, 'clipPath'); + clip.setAttribute('id', `agents-aquarium-fish-clip-${i}`); + clip.setAttribute('clipPathUnits', 'userSpaceOnUse'); + const rect = targetDocument.createElementNS(SVG_NS, 'rect'); + rect.setAttribute('x', String(BODY_X_START + i * stripWidth)); + rect.setAttribute('y', '-20'); + // Larger overlap (0.8 user-units) hides seams when adjacent strips + // are at slightly different translateY values. + rect.setAttribute('width', String(stripWidth + 0.8)); + rect.setAttribute('height', '136'); + clip.appendChild(rect); + defs.appendChild(clip); + } + container.appendChild(defs); + targetDocument.body.appendChild(container); + sharedDefsContainer = container; +} + +/** + * Build the inline SVG element tree for a fish: + * - VS Code logo body, sliced into N vertical strips that each oscillate in + * Y with a phase-offset CSS animation (the "swimming" sine wave) + * + * Colors come from `currentColor` on the parent element. Built with + * `document.createElementNS` (no innerHTML) to satisfy Trusted Types. + * + * The strip clipPath defs are shared across all fish via {@link ensureSharedDefs}. + */ +function buildFishSvg(targetDocument: Document): SVGSVGElement { + ensureSharedDefs(targetDocument); + + const svg = targetDocument.createElementNS(SVG_NS, 'svg'); + svg.setAttribute('xmlns', SVG_NS); + // viewBox 0..96 matches the original VS Code icon. + svg.setAttribute('viewBox', '0 0 96 96'); + svg.setAttribute('preserveAspectRatio', 'xMidYMid meet'); + // Tell the rasterizer to optimize for visual quality, not speed: smoother + // edges on the (potentially upscaled) logo paths. + svg.setAttribute('shape-rendering', 'geometricPrecision'); + + // Body: NUM_BODY_STRIPS overlapping copies of the full logo, each clipped + // to its vertical band via shared clipPath defs. Each strip animates + // translateY with a phase offset driven by --strip-index. + const bodyGroup = targetDocument.createElementNS(SVG_NS, 'g'); + bodyGroup.setAttribute('class', 'agents-aquarium-fish-body'); + for (let i = 0; i < NUM_BODY_STRIPS; i++) { + const stripG = targetDocument.createElementNS(SVG_NS, 'g'); + stripG.setAttribute('class', 'agents-aquarium-fish-strip'); + stripG.style.setProperty('--strip-index', String(i)); + const stripPath = targetDocument.createElementNS(SVG_NS, 'path'); + stripPath.setAttribute('d', VSCODE_LOGO_PATH); + stripPath.setAttribute('fill', 'currentColor'); + // Use even-odd fill so the inner chevron sub-path of the VS Code logo + // becomes a visible cutout (the iconic open V shape). + stripPath.setAttribute('fill-rule', 'evenodd'); + stripPath.setAttribute('clip-path', `url(#agents-aquarium-fish-clip-${i})`); + stripG.appendChild(stripPath); + bodyGroup.appendChild(stripG); + } + svg.appendChild(bodyGroup); + + return svg; +} diff --git a/src/vs/sessions/contrib/aquarium/browser/media/aquarium.css b/src/vs/sessions/contrib/aquarium/browser/media/aquarium.css new file mode 100644 index 0000000000000..f190576544aa7 --- /dev/null +++ b/src/vs/sessions/contrib/aquarium/browser/media/aquarium.css @@ -0,0 +1,203 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* =========================================================================== + * Agents Aquarium + * =========================================================================== + * - .agents-aquarium-water full backdrop layer over the chat bar region + * - .agents-aquarium-fish one per swimming fish (DOM + inline SVG) + * - .agents-aquarium-food one per food pellet + * - .agents-aquarium-toggle persistent floating button above the chat input + * + * All layers use pointer-events: none so the underlying chat UI keeps + * receiving clicks/keystrokes; mouse handling is done at the workbench main + * container level by aquariumOverlay.ts. + */ + +/* ---- Water (background tint + caustics) ---- */ + +/* Ensure the chat bar establishes a positioning context so the water layer + * (its first child) anchors exactly to the chat bar's bounds. */ +.monaco-workbench .part.chatbar { + position: relative; +} + +.agents-aquarium-water { + position: absolute; + inset: 0; + pointer-events: none; + overflow: hidden; + z-index: 0; + /* sits behind any later sibling content in the chat bar */ + border-radius: 12px; + opacity: 0; + transition: opacity 320ms ease-out; + background: + radial-gradient(circle at 30% 20%, rgba(36, 191, 165, 0.10), transparent 55%), + radial-gradient(circle at 75% 65%, rgba(0, 122, 204, 0.10), transparent 60%), + radial-gradient(circle at 50% 90%, rgba(188, 63, 188, 0.08), transparent 65%); +} + +.agents-aquarium-water.visible { + opacity: 1; +} + +/* Soft caustics shimmer overlay. */ +.agents-aquarium-water::after { + content: ''; + position: absolute; + inset: -10%; + pointer-events: none; + background: + radial-gradient(ellipse 30% 20% at 20% 30%, rgba(255, 255, 255, 0.06), transparent 70%), + radial-gradient(ellipse 25% 18% at 70% 60%, rgba(255, 255, 255, 0.05), transparent 70%), + radial-gradient(ellipse 22% 16% at 50% 80%, rgba(255, 255, 255, 0.04), transparent 70%); + animation: agents-aquarium-caustics 9s ease-in-out infinite alternate; + mix-blend-mode: screen; +} + +@keyframes agents-aquarium-caustics { + 0% { + transform: translate(0, 0) scale(1); + opacity: 0.7; + } + 50% { + transform: translate(2%, -1%) scale(1.05); + opacity: 1; + } + 100% { + transform: translate(-1%, 2%) scale(0.98); + opacity: 0.8; + } +} + +/* ---- Fish ---- */ + +.agents-aquarium-fish-layer, +.agents-aquarium-food-layer { + position: absolute; + inset: 0; + pointer-events: none; +} + +.agents-aquarium-fish { + position: absolute; + top: 0; + left: 0; + will-change: transform; + pointer-events: none; + /* No drop-shadow / blur filter: those break GPU compositing and force + * a software rasterization pass per fish per frame. Worth ~10x in paint + * cost when scaled to 50 fish. */ +} + +.agents-aquarium-fish-inner { + width: 100%; + height: 100%; + transform-origin: center; + transition: transform 180ms ease-out; +} + +.agents-aquarium-fish svg { + width: 100%; + height: 100%; + display: block; + overflow: visible; +} + +/* Body strips: each strip is a clipped vertical slice of the VS Code logo. + * Strips animate translateY with a per-strip phase offset, producing a + * sine wave that travels along the body — the actual swimming motion. */ +.agents-aquarium-fish-strip { + animation: agents-aquarium-body-wave 720ms ease-in-out infinite; + /* 720ms / 10 strips = ~72ms phase delay between strips. Negative delays + * start each strip mid-cycle so the wave is fully formed at t=0. */ + animation-delay: calc(var(--strip-index, 0) * -72ms); +} + +@keyframes agents-aquarium-body-wave { + 0%, 100% { + transform: translateY(-3.5px); + } + 50% { + transform: translateY(3.5px); + } +} + +/* ---- Food pellets ---- */ + +.agents-aquarium-food { + position: absolute; + top: 0; + left: 0; + width: 6px; + height: 6px; + margin-left: -3px; + margin-top: -3px; + border-radius: 50%; + background: radial-gradient(circle at 35% 35%, #ffd56a, #c98a17 80%); + box-shadow: 0 0 4px rgba(255, 200, 80, 0.6); + pointer-events: none; + will-change: transform; +} + +/* ---- Toggle button (always visible, top-right of the chat bar) ---- */ + +/* The chat bar's `.part.chatbar` already establishes a positioning context + * (it has its own padding/border), so absolute positioning anchors the + * button relative to it. */ +.agents-aquarium-toggle { + position: absolute; + top: 12px; + right: 14px; + z-index: 100; + width: 24px; + height: 24px; + border-radius: 50%; + border: 1px solid transparent; + background: transparent; + color: var(--vscode-foreground, #cccccc); + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + cursor: pointer; + opacity: 0.5; + transition: opacity 160ms ease-out, background-color 160ms ease-out, color 160ms ease-out; +} + +.agents-aquarium-toggle:hover { + opacity: 1; + background: var(--vscode-toolbar-hoverBackground, rgba(255, 255, 255, 0.08)); +} + +/* Use the same `.monaco-workbench` prefix as the workbench's own + * `button:focus` rule so we win the cascade by source order without + * resorting to !important. */ +.monaco-workbench button.agents-aquarium-toggle:focus, +.monaco-workbench button.agents-aquarium-toggle:focus-visible { + outline: none; +} + +.agents-aquarium-toggle.active { + opacity: 1; + color: #ff6b88; +} + +.agents-aquarium-toggle .codicon { + font-size: 14px; +} + +/* ---- Reduced motion ---- */ + +@media (prefers-reduced-motion: reduce) { + .agents-aquarium-fish-strip, + .agents-aquarium-water::after { + animation: none; + } + .agents-aquarium-water { + transition: none; + } +} diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts index bb11f776b1b58..dcb4fd1484901 100644 --- a/src/vs/sessions/sessions.common.main.ts +++ b/src/vs/sessions/sessions.common.main.ts @@ -457,6 +457,7 @@ import './contrib/terminal/browser/sessionsTerminalContribution.js'; import './contrib/chatDebug/browser/chatDebug.contribution.js'; import './contrib/workspace/browser/workspace.contribution.js'; import './contrib/welcome/browser/welcome.contribution.js'; +import './contrib/aquarium/browser/aquarium.contribution.js'; import './contrib/policyBlocked/browser/policyBlocked.contribution.js'; import './services/sessions/browser/sessionsManagementService.js'; From 1fa75dd875c6789ce4a451653f0b5bb6f7ad9e3d Mon Sep 17 00:00:00 2001 From: justschen Date: Sun, 26 Apr 2026 22:42:34 -0700 Subject: [PATCH 2/8] address comments and making animations cleaner --- .../lib/stylelint/vscode-known-variables.json | 1 - .../aquarium/browser/aquariumOverlay.ts | 288 +++++++++++++++--- .../sessions/contrib/aquarium/browser/fish.ts | 103 +++++-- .../aquarium/browser/media/aquarium.css | 148 +++++++-- 4 files changed, 438 insertions(+), 102 deletions(-) diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 91b697e2c61a5..e063c7e15fdc4 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -941,7 +941,6 @@ "--chat-current-response-min-height", "--chat-smooth-delay", "--chat-smooth-duration", - "--fish-wiggle-delay", "--inline-chat-frame-progress", "--insert-border-color", "--last-tab-margin-right", diff --git a/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts b/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts index 68ce08c45fe52..ccc39b8b929aa 100644 --- a/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts +++ b/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { addDisposableListener, EventType, getWindow, scheduleAtNextAnimationFrame } from '../../../../base/browser/dom.js'; +import { addDisposableGenericMouseDownListener, addDisposableGenericMouseMoveListener, addDisposableListener, EventType, getWindow, scheduleAtNextAnimationFrame } from '../../../../base/browser/dom.js'; import { createInstantHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; @@ -11,6 +11,7 @@ import { ThemeIcon } from '../../../../base/common/themables.js'; import { localize } from '../../../../nls.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; import { IsNewChatSessionContext, SessionsAquariumActiveContext } from '../../../common/contextkeys.js'; import { disposeSharedFishDefs, Fish, pickRandomSpecies } from './fish.js'; @@ -20,7 +21,7 @@ const FISH_MIN_SIZE = 22; const FISH_MAX_SIZE = 48; /** Pixels around the cursor where fish flee. */ -const SCATTER_RADIUS = 130; +const SCATTER_RADIUS = 145; /** Pixels around a food pellet where the fish considers it grabbable. */ const EAT_RADIUS = 14; /** Maximum distance a fish will sense a food pellet from. Smaller = food @@ -33,26 +34,33 @@ const MAX_FOOD = 12; const WALL_MARGIN = 36; /** Base swimming speed (px/sec). */ -const BASE_SPEED = 60; +const BASE_SPEED = 24; /** Maximum normal swim speed (px/sec). */ -const MAX_SPEED = 120; +const MAX_SPEED = 50; /** Maximum panic swim speed (px/sec). */ -const PANIC_MAX_SPEED = 320; +const PANIC_MAX_SPEED = 240; /** How long a fish stays in panic mode after being scattered. */ const PANIC_DURATION_MS = 600; +/** How long the exit animation runs before the aquarium is disposed. */ +const EXIT_DURATION_MS = 900; + /** * Per-fish probability (per second) of starting a spontaneous "dart": a brief - * burst of speed in a random direction with no external trigger. With ~35 fish - * and 0.06/sec each, the aquarium sees a dart roughly every 0.5s. + * burst of speed in a random direction with no external trigger. With FISH_COUNT + * fish each rolling at this rate, the aquarium sees roughly + * FISH_COUNT * DART_RATE_PER_SECOND darts/sec total. */ -const DART_RATE_PER_SECOND = 0.06; +const DART_RATE_PER_SECOND = 0.04; /** Random dart impulse strength (px/sec velocity boost). */ -const DART_IMPULSE = 240; +const DART_IMPULSE = 150; /** Context keys we react to for visibility changes. */ const NEW_SESSION_KEY_SET = new Set([IsNewChatSessionContext.key]); +/** Storage key for remembering whether the user wants the aquarium on. */ +const AQUARIUM_ENABLED_STORAGE_KEY = 'workbench.sessions.aquarium.enabled'; + interface IFoodPellet { readonly element: HTMLDivElement; x: number; @@ -88,6 +96,7 @@ export class AquariumOverlay extends Disposable { @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IHoverService private readonly hoverService: IHoverService, + @IStorageService private readonly storageService: IStorageService, ) { super(); @@ -102,7 +111,7 @@ export class AquariumOverlay extends Disposable { // Only show the button (and allow the aquarium) on the new-session view. // When the user opens an existing session, hide the button and tear down - // any active aquarium. + // any active aquarium. When they return, restore the previous state. this.applyNewSessionVisibility(); this._register(this.contextKeyService.onDidChangeContext(e => { if (e.affectsSome(NEW_SESSION_KEY_SET)) { @@ -115,11 +124,28 @@ export class AquariumOverlay extends Disposable { return this.contextKeyService.getContextKeyValue(IsNewChatSessionContext.key) ?? true; } + private isStoredEnabled(): boolean { + return this.storageService.getBoolean(AQUARIUM_ENABLED_STORAGE_KEY, StorageScope.APPLICATION, false); + } + + private setStoredEnabled(enabled: boolean): void { + this.storageService.store(AQUARIUM_ENABLED_STORAGE_KEY, enabled, StorageScope.APPLICATION, StorageTarget.USER); + } + private applyNewSessionVisibility(): void { const isNew = this.isNewSession(); this.toggleButton.style.display = isNew ? '' : 'none'; - if (!isNew && this.activeRef.value) { - this.deactivate(); + if (!isNew) { + // Leaving the new-session view: tear down without persisting (the + // stored preference still reflects what the user wants on return). + if (this.activeRef.value) { + this.deactivate(/* persist */ false); + } + } else { + // Returning to the new-session view: restore the saved state. + if (this.isStoredEnabled() && !this.activeRef.value) { + this.activate(/* persist */ false); + } } } @@ -134,6 +160,11 @@ export class AquariumOverlay extends Disposable { if (chatBarElement && this.layoutService.isVisible(Parts.CHATBAR_PART, window) && chatBarElement.isConnected) { chatBarElement.appendChild(this.toggleButton); this._register(toDisposable(() => this.toggleButton.remove())); + // Now that the chat bar is mountable, restore the saved state + // (no-op if the user wasn't on the new-session view or had it off). + if (this.isNewSession() && this.isStoredEnabled() && !this.activeRef.value) { + this.activate(/* persist */ false); + } return; } if (attempt >= 60) { @@ -172,7 +203,7 @@ export class AquariumOverlay extends Disposable { // the workbench Trusted Types policy. button.replaceChildren(); const iconSpan = document.createElement('span'); - const iconClasses = ThemeIcon.asClassName(active ? Codicon.close : Codicon.heartFilled).split(/\s+/).filter(Boolean); + const iconClasses = ThemeIcon.asClassName(active ? Codicon.close : Codicon.smiley).split(/\s+/).filter(Boolean); for (const cls of iconClasses) { iconSpan.classList.add(cls); } @@ -181,15 +212,22 @@ export class AquariumOverlay extends Disposable { button.setAttribute('aria-label', active ? localize('aquarium.hide', "Hide Aquarium") : localize('aquarium.show', "Show Aquarium")); } + /** User clicked the toggle button — flip state AND persist the choice. */ private toggle(): void { if (this.activeRef.value) { - this.deactivate(); + this.deactivate(/* persist */ true); } else { - this.activate(); + this.activate(/* persist */ true); } } - private activate(): void { + /** + * @param persist When true, store the new on-state so it's restored on + * next visit. False is used by `applyNewSessionVisibility` + * when restoring previously-stored state — we don't want + * to write storage in that case. + */ + private activate(persist: boolean): void { if (this.activeRef.value) { return; } @@ -205,19 +243,49 @@ export class AquariumOverlay extends Disposable { this.activeRef.value = active; this.activeContextKey.set(true); this.updateToggleButtonVisual(this.toggleButton, true); + if (persist) { + this.setStoredEnabled(true); + } } - private deactivate(): void { - if (!this.activeRef.value) { + /** + * @param persist When true, store the new off-state. False is used when + * tearing down because we left the new-session view — + * the stored preference should still reflect what the + * user wants the next time they come back. + */ + private deactivate(persist: boolean): void { + // Detach the active aquarium WITHOUT disposing it (clearAndLeak does + // not call dispose) — otherwise the fish DOM would be torn down + // synchronously and the exit animation would never run. + const active = this.activeRef.clearAndLeak(); + if (!active) { return; } - this.activeRef.clear(); this.activeContextKey.set(false); this.updateToggleButtonVisual(this.toggleButton, false); + // Run the exit animation, then dispose. Stash on the overlay so a + // rapid re-activate during the exit can cancel the pending dispose. + this.pendingExit?.dispose(); + const pending = active.exit(); + this.pendingExit = pending; + this._register(pending); + if (persist) { + this.setStoredEnabled(false); + } } + + private pendingExit: IDisposable | undefined; } -interface IActiveAquarium extends IDisposable { } +interface IActiveAquarium extends IDisposable { + /** + * Trigger the exit animation and dispose the aquarium when it completes. + * Returns a disposable that, if disposed before the animation finishes, + * will short-circuit and dispose the aquarium immediately. + */ + exit(): IDisposable; +} /** * Build the live aquarium: water layer, fish, food, mouse handling, RAF loop. @@ -233,8 +301,11 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe // any z-index gymnastics — water sits behind, fish included. const chatBar = layoutService.getContainer(targetWindow, Parts.CHATBAR_PART); if (!chatBar || !layoutService.isVisible(Parts.CHATBAR_PART, targetWindow)) { - // No chat bar to host the aquarium — return an inert disposable. - return store; + // No chat bar to host the aquarium — return an inert active aquarium. + return { + dispose: () => store.dispose(), + exit: () => { store.dispose(); return store; }, + }; } // --- DOM setup --- @@ -293,7 +364,37 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe vy: Math.sin(angle) * speed, }, targetWindow.document); fish.push(f); - fishLayer.appendChild(f.element); + } + // Spawn fish in two batches: the first half is appended synchronously + // (via a DocumentFragment for a single layout pass), and the rest on + // the next animation frame so the toggle click stays snappy. Each batch + // gets the per-fish stagger applied in the fade-in pass below. + const SYNC_BATCH = Math.ceil(FISH_COUNT / 2); + const firstBatch = targetWindow.document.createDocumentFragment(); + for (let i = 0; i < Math.min(SYNC_BATCH, fish.length); i++) { + firstBatch.appendChild(fish[i].element); + } + fishLayer.appendChild(firstBatch); + if (SYNC_BATCH < fish.length) { + const deferred = scheduleAtNextAnimationFrame(targetWindow, () => { + const restBatch = targetWindow.document.createDocumentFragment(); + for (let i = SYNC_BATCH; i < fish.length; i++) { + restBatch.appendChild(fish[i].element); + } + fishLayer.appendChild(restBatch); + // Add `.visible` on the NEXT frame so the elements have one paint + // at opacity:0 first — guarantees the CSS transition actually fires. + const fadeIn = scheduleAtNextAnimationFrame(targetWindow, () => { + for (let i = SYNC_BATCH; i < fish.length; i++) { + const localIndex = i - SYNC_BATCH; + const delay = Math.min(localIndex * 12, 400); + fish[i].element.style.transitionDelay = `${delay}ms`; + fish[i].element.classList.add('visible'); + } + }); + store.add(fadeIn); + }); + store.add(deferred); } store.add(toDisposable(() => { for (const f of fish) { @@ -327,16 +428,17 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe let mouseX = -1e6; let mouseY = -1e6; - store.add(addDisposableListener(mainContainer, EventType.MOUSE_MOVE, (e: MouseEvent) => { + // Use the generic mouse helpers so this works under iOS pointer events too. + store.add(addDisposableGenericMouseMoveListener(mainContainer, (e: MouseEvent) => { mouseX = e.clientX - waterScreenOffset.left; mouseY = e.clientY - waterScreenOffset.top; - }, { passive: true })); + })); store.add(addDisposableListener(mainContainer, EventType.MOUSE_LEAVE, () => { mouseX = -1e6; mouseY = -1e6; }, { passive: true })); - store.add(addDisposableListener(mainContainer, EventType.MOUSE_DOWN, (e: MouseEvent) => { + store.add(addDisposableGenericMouseDownListener(mainContainer, (e: MouseEvent) => { // Only spawn food on plain left clicks against background-ish surfaces. if (e.button !== 0) { return; @@ -404,9 +506,28 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe function updateFish(dt: number): void { const now = performance.now(); for (const f of fish) { - // --- Steering: gentle wander --- - let ax = (Math.random() - 0.5) * 60; - let ay = (Math.random() - 0.5) * 60; + const cx = f.x + f.size / 2; + const cy = f.y + f.size / 2; + + // --- Wall steering: actively turn the heading away from any wall + // the fish is approaching. Doing this on the heading (not just on + // acceleration) prevents fish from parking against the edge with + // their thrust pinning them in place. + const targetAngleFromWall = computeWallAvoidAngle(cx, cy, bounds.width, bounds.height); + if (targetAngleFromWall !== undefined) { + // Turn at up to 4 rad/s toward the safe direction. + const turn = shortestAngleDelta(f.wanderAngle, targetAngleFromWall); + const maxTurn = 4 * dt; + f.wanderAngle += Math.max(-maxTurn, Math.min(maxTurn, turn)); + } else { + // Free water: drift the heading by a small random delta. + f.wanderAngle += (Math.random() - 0.5) * 1.2 * dt + (Math.random() - 0.5) * 0.04; + } + + // --- Steering: gentle thrust along the wander heading --- + const thrust = 32; + let ax = Math.cos(f.wanderAngle) * thrust; + let ay = Math.sin(f.wanderAngle) * thrust; // --- Spontaneous dart --- // With probability DART_RATE_PER_SECOND * dt this frame, kick the @@ -419,9 +540,9 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe f.panicUntil = now + PANIC_DURATION_MS; } - // --- Wall repel (soft) --- - const cx = f.x + f.size / 2; - const cy = f.y + f.size / 2; + // --- Wall repel (soft, secondary) --- + // Backstop the heading-based steering so a fish entering the + // margin still gets pushed inward immediately. if (cx < WALL_MARGIN) { ax += (WALL_MARGIN - cx) * 6; } else if (cx > bounds.width - WALL_MARGIN) { @@ -439,7 +560,7 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe const distM2 = dxM * dxM + dyM * dyM; if (distM2 < SCATTER_RADIUS * SCATTER_RADIUS) { const distM = Math.max(Math.sqrt(distM2), 1); - const force = (1 - distM / SCATTER_RADIUS) * 1200; + const force = (1 - distM / SCATTER_RADIUS) * 1100; ax += (dxM / distM) * force; ay += (dyM / distM) * force; f.panicUntil = now + PANIC_DURATION_MS; @@ -471,7 +592,7 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe f.vx += ax * dt; f.vy += ay * dt; - // Damp toward base speed; cap by panic state. + // Cap speed based on panic state. const speed2 = f.vx * f.vx + f.vy * f.vy; const maxSpeed = now < f.panicUntil ? PANIC_MAX_SPEED : MAX_SPEED; if (speed2 > maxSpeed * maxSpeed) { @@ -487,17 +608,65 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe f.x = clamp(f.x, -f.size * 0.25, bounds.width - f.size * 0.75); f.y = clamp(f.y, -f.size * 0.25, bounds.height - f.size * 0.75); - f.applyTransform(); + f.applyTransform(dt); } } rafDisposable = scheduleAtNextAnimationFrame(targetWindow, tick); store.add(toDisposable(() => rafDisposable?.dispose())); - // --- Fade-in class --- - scheduleAtNextAnimationFrame(targetWindow, () => water.classList.add('visible')); + // --- Fade-in: water + per-fish stagger (first batch only; the deferred + // batch fades in inline when it's mounted next frame). --- + scheduleAtNextAnimationFrame(targetWindow, () => { + water.classList.add('visible'); + for (let i = 0; i < Math.min(SYNC_BATCH, fish.length); i++) { + const f = fish[i]; + // Slight stagger so the school appears progressively rather than + // all at once. Cap at ~400ms total so it doesn't drag on. + const delay = Math.min(i * 12, 400); + f.element.style.transitionDelay = `${delay}ms`; + f.element.classList.add('visible'); + } + }); + + let exiting = false; + + const result: IActiveAquarium = { + dispose: () => store.dispose(), + exit: () => { + if (exiting) { + return store; + } + exiting = true; + + // Mirror of fade-in: per-fish stagger, then drop the `.visible` + // class so the same CSS transition (`opacity 360ms ease-out`) + // interpolates back to 0. Fish keep doing their normal swim + // while fading — no choreographed "all swim one direction". + for (let i = 0; i < fish.length; i++) { + const f = fish[i]; + const delay = Math.min(i * 12, 400); + f.element.style.transitionDelay = `${delay}ms`; + f.element.classList.remove('visible'); + } + water.classList.remove('visible'); + + // After the animation completes, dispose everything. + let timer: ReturnType | undefined = setTimeout(() => { + timer = undefined; + store.dispose(); + }, EXIT_DURATION_MS); + return toDisposable(() => { + if (timer !== undefined) { + clearTimeout(timer); + timer = undefined; + store.dispose(); + } + }); + }, + }; - return store; + return result; } /** Determine whether a click target is "background-ish" (not on a control). */ @@ -524,3 +693,46 @@ function clamp(value: number, min: number, max: number): number { } return Math.min(Math.max(value, min), max); } + +/** + * If the fish is inside the wall margin, return the heading (radians) that + * points back into the open water. Returns `undefined` when the fish is + * comfortably away from all walls so the caller can apply free wandering. + * + * The chosen direction sums per-wall vectors weighted by how far inside the + * margin the fish has crept, with a small randomized tangential bias so + * neighbors don't all converge to the exact same heading. + */ +function computeWallAvoidAngle(cx: number, cy: number, width: number, height: number): number | undefined { + let dx = 0; + let dy = 0; + if (cx < WALL_MARGIN) { + dx += (WALL_MARGIN - cx) / WALL_MARGIN; + } else if (cx > width - WALL_MARGIN) { + dx -= (cx - (width - WALL_MARGIN)) / WALL_MARGIN; + } + if (cy < WALL_MARGIN) { + dy += (WALL_MARGIN - cy) / WALL_MARGIN; + } else if (cy > height - WALL_MARGIN) { + dy -= (cy - (height - WALL_MARGIN)) / WALL_MARGIN; + } + if (dx === 0 && dy === 0) { + return undefined; + } + // Add a small tangential perturbation so a row of fish hitting the same + // wall don't all turn to the exact same angle. + return Math.atan2(dy, dx) + (Math.random() - 0.5) * 0.4; +} + +/** + * Smallest signed angular delta that takes `from` to `to`, in [-PI, PI]. + */ +function shortestAngleDelta(from: number, to: number): number { + let d = (to - from) % (Math.PI * 2); + if (d > Math.PI) { + d -= Math.PI * 2; + } else if (d < -Math.PI) { + d += Math.PI * 2; + } + return d; +} diff --git a/src/vs/sessions/contrib/aquarium/browser/fish.ts b/src/vs/sessions/contrib/aquarium/browser/fish.ts index 6cad0916bcdd9..f898f1467e128 100644 --- a/src/vs/sessions/contrib/aquarium/browser/fish.ts +++ b/src/vs/sessions/contrib/aquarium/browser/fish.ts @@ -6,7 +6,7 @@ /** * VS Code logo "fish" used by the Agents window aquarium. Each fish is a small * SVG element styled with `color:` so the silhouette inherits via `currentColor`, - * with a tail group running a CSS wiggle animation. + * with animated body strips providing the swimming motion. */ /** VS Code logo silhouette path (extracted from sessions/contrib/chat/browser/media/vscode-icon.svg). */ @@ -74,8 +74,19 @@ export class Fish { /** Timestamp until which this fish is in "panic" mode (faster, scattering). */ panicUntil = 0; - /** Last facing direction; only flip the element when it changes. */ - private facingRight = true; + /** + * The fish's preferred swim heading in radians. Drifts smoothly each frame + * via a small random delta — much less jittery than randomizing per-axis + * acceleration every frame. + */ + wanderAngle: number; + + /** + * Smoothed facing in [-1, 1] (1 = right, -1 = left). Eased toward + * sign(vx) each frame so direction changes look like a turn instead of + * a snap-flip. + */ + private facing = 1; constructor(opts: IFishOptions, targetDocument: Document) { this.x = opts.x; @@ -83,17 +94,16 @@ export class Fish { this.vx = opts.vx; this.vy = opts.vy; this.size = opts.size; + this.wanderAngle = Math.atan2(opts.vy, opts.vx); this.element = targetDocument.createElement('div'); this.element.className = 'agents-aquarium-fish'; this.element.style.width = `${opts.size}px`; this.element.style.height = `${opts.size}px`; this.element.style.color = SPECIES_COLOR[opts.species]; - // Stagger the wiggle so fish aren't synchronized. - this.element.style.setProperty('--fish-wiggle-delay', `${(Math.random() * -1).toFixed(2)}s`); - // Inner element receives the directional flip so the wiggle keyframes - // (applied to the tail) are unaffected by direction changes. + // Inner element receives the directional flip so the body strip animations + // (driven by --strip-index) are unaffected by direction changes. this.innerElement = targetDocument.createElement('div'); this.innerElement.className = 'agents-aquarium-fish-inner'; this.innerElement.appendChild(buildFishSvg(targetDocument)); @@ -102,16 +112,31 @@ export class Fish { this.applyTransform(); } - /** Write the current position/facing to the DOM. */ - applyTransform(): void { - // Translate is on the outer element; flip is on the inner element so the - // tail's CSS animation keeps spinning around its local origin. - this.element.style.transform = `translate(${this.x.toFixed(1)}px, ${this.y.toFixed(1)}px)`; - const wantFacingRight = this.vx >= 0; - if (wantFacingRight !== this.facingRight) { - this.facingRight = wantFacingRight; - this.innerElement.style.transform = wantFacingRight ? '' : 'scaleX(-1)'; + /** + * Write the current position/facing to the DOM. + * + * @param dt seconds since last frame, used to ease facing toward velocity + * direction. Pass 0 for the initial paint. + */ + applyTransform(dt: number = 0): void { + // Translate is on the outer element. Sub-pixel precision (2 decimals) + // avoids visible 0.1 px stepping when fish move slowly. + this.element.style.transform = `translate(${this.x.toFixed(2)}px, ${this.y.toFixed(2)}px)`; + + // Ease `facing` toward sign(vx) so the flip looks like a turn instead + // of an instant mirror. Time-constant ~120 ms (turnRate = 8/s). + const target = this.vx >= 0 ? 1 : -1; + if (dt > 0) { + const turnRate = 8; + const k = 1 - Math.exp(-turnRate * dt); + this.facing += (target - this.facing) * k; + } else { + this.facing = target; } + // scaleX through 0 in the middle of a turn flattens the fish for one + // frame, mimicking a body roll. Floor at 0.05 to avoid zero-width. + const scaleX = Math.sign(this.facing) * Math.max(Math.abs(this.facing), 0.05); + this.innerElement.style.transform = `scaleX(${scaleX.toFixed(3)})`; } } @@ -130,12 +155,16 @@ const BODY_X_START = 5; const BODY_X_END = 90; /** - * Lazily-built shared SVG element holding the strip clipPath defs. All fish - * reference these via `clip-path: url(#...)` instead of redefining their own. - * Saves ~NUM_BODY_STRIPS * (FISH_COUNT - 1) clipPath nodes. + * Lazily-built shared SVG element holding both the strip clipPath defs AND + * a single `` containing the VS Code logo path. All fish reference + * these via `clip-path: url(#…)` and `` instead of duplicating + * the path data per strip per fish (which previously caused 50 fish * 10 + * strips = 500 path parses on every aquarium activation). */ let sharedDefsContainer: SVGSVGElement | undefined; +const SHARED_LOGO_SYMBOL_ID = 'agents-aquarium-fish-logo'; + function ensureSharedDefs(targetDocument: Document): void { if (sharedDefsContainer) { return; @@ -151,6 +180,21 @@ function ensureSharedDefs(targetDocument: Document): void { container.style.height = '0'; container.style.overflow = 'hidden'; container.style.pointerEvents = 'none'; + + // One `` containing the VS Code logo path. All strips reference + // this via ``, so the path data + // is parsed exactly ONCE per session instead of FISH_COUNT * NUM_STRIPS. + const symbol = targetDocument.createElementNS(SVG_NS, 'symbol'); + symbol.setAttribute('id', SHARED_LOGO_SYMBOL_ID); + symbol.setAttribute('viewBox', '0 0 96 96'); + symbol.setAttribute('overflow', 'visible'); + const logoPath = targetDocument.createElementNS(SVG_NS, 'path'); + logoPath.setAttribute('d', VSCODE_LOGO_PATH); + logoPath.setAttribute('fill', 'currentColor'); + logoPath.setAttribute('fill-rule', 'evenodd'); + symbol.appendChild(logoPath); + container.appendChild(symbol); + const defs = targetDocument.createElementNS(SVG_NS, 'defs'); for (let i = 0; i < NUM_BODY_STRIPS; i++) { const clip = targetDocument.createElementNS(SVG_NS, 'clipPath'); @@ -179,7 +223,8 @@ function ensureSharedDefs(targetDocument: Document): void { * Colors come from `currentColor` on the parent element. Built with * `document.createElementNS` (no innerHTML) to satisfy Trusted Types. * - * The strip clipPath defs are shared across all fish via {@link ensureSharedDefs}. + * The strip clipPath defs and the logo symbol are shared across all fish via + * {@link ensureSharedDefs}. */ function buildFishSvg(targetDocument: Document): SVGSVGElement { ensureSharedDefs(targetDocument); @@ -193,23 +238,19 @@ function buildFishSvg(targetDocument: Document): SVGSVGElement { // edges on the (potentially upscaled) logo paths. svg.setAttribute('shape-rendering', 'geometricPrecision'); - // Body: NUM_BODY_STRIPS overlapping copies of the full logo, each clipped - // to its vertical band via shared clipPath defs. Each strip animates - // translateY with a phase offset driven by --strip-index. + // Body: NUM_BODY_STRIPS overlapping references to the shared logo symbol, + // each clipped to its vertical band via shared clipPath defs. Each strip + // animates translateY with a phase offset driven by --strip-index. const bodyGroup = targetDocument.createElementNS(SVG_NS, 'g'); bodyGroup.setAttribute('class', 'agents-aquarium-fish-body'); for (let i = 0; i < NUM_BODY_STRIPS; i++) { const stripG = targetDocument.createElementNS(SVG_NS, 'g'); stripG.setAttribute('class', 'agents-aquarium-fish-strip'); stripG.style.setProperty('--strip-index', String(i)); - const stripPath = targetDocument.createElementNS(SVG_NS, 'path'); - stripPath.setAttribute('d', VSCODE_LOGO_PATH); - stripPath.setAttribute('fill', 'currentColor'); - // Use even-odd fill so the inner chevron sub-path of the VS Code logo - // becomes a visible cutout (the iconic open V shape). - stripPath.setAttribute('fill-rule', 'evenodd'); - stripPath.setAttribute('clip-path', `url(#agents-aquarium-fish-clip-${i})`); - stripG.appendChild(stripPath); + const stripUse = targetDocument.createElementNS(SVG_NS, 'use'); + stripUse.setAttribute('href', `#${SHARED_LOGO_SYMBOL_ID}`); + stripUse.setAttribute('clip-path', `url(#agents-aquarium-fish-clip-${i})`); + stripG.appendChild(stripUse); bodyGroup.appendChild(stripG); } svg.appendChild(bodyGroup); diff --git a/src/vs/sessions/contrib/aquarium/browser/media/aquarium.css b/src/vs/sessions/contrib/aquarium/browser/media/aquarium.css index f190576544aa7..6a79ca5402e7f 100644 --- a/src/vs/sessions/contrib/aquarium/browser/media/aquarium.css +++ b/src/vs/sessions/contrib/aquarium/browser/media/aquarium.css @@ -34,42 +34,88 @@ border-radius: 12px; opacity: 0; transition: opacity 320ms ease-out; - background: - radial-gradient(circle at 30% 20%, rgba(36, 191, 165, 0.10), transparent 55%), - radial-gradient(circle at 75% 65%, rgba(0, 122, 204, 0.10), transparent 60%), - radial-gradient(circle at 50% 90%, rgba(188, 63, 188, 0.08), transparent 65%); + /* Simple linear gradient base. Linear gradients band far less than + * radial ones because the alpha changes on a single axis at uniform + * speed — no concentric "rings" to perceive. */ + background: linear-gradient(160deg, + rgba(36, 191, 165, 0.06) 0%, + rgba(0, 122, 204, 0.05) 55%, + rgba(188, 63, 188, 0.05) 100%); } .agents-aquarium-water.visible { opacity: 1; } -/* Soft caustics shimmer overlay. */ +/* Floating accent circles. Three blurred radial sources slowly drift around + * to give the water a subtle living quality. They're positioned via CSS + * transforms (GPU-accelerated) and the blur keeps any individual edge from + * being visible — the eye reads the result as a soft moving glow rather than + * three discrete circles. */ +/* Floating accent circles. Two soft radial sources slowly drift around to + * give the water a subtle living quality. + * + * Pixelation gotchas (and how we avoid them): + * - The blur filter is rasterized; if its edge is clipped by the parent's + * `overflow: hidden`, the clip happens BEFORE the blur smears, leaving + * a visible hard edge. We use radial-gradients with a soft falloff + * INSIDE the circles instead of relying on `filter: blur(...)` so the + * edge softness is part of the gradient itself, not a post-process. + * - GPU-accelerate the animations via translate3d so the whole layer + * composites once and isn't re-rasterized every frame. + * - Make the circles much larger than the visible area so even the + * softest part of the falloff has somewhere to go (no concentric ring + * cutting off at the edge). + */ +.agents-aquarium-water::before, .agents-aquarium-water::after { content: ''; position: absolute; - inset: -10%; + width: 140%; + aspect-ratio: 1; + border-radius: 50%; pointer-events: none; - background: - radial-gradient(ellipse 30% 20% at 20% 30%, rgba(255, 255, 255, 0.06), transparent 70%), - radial-gradient(ellipse 25% 18% at 70% 60%, rgba(255, 255, 255, 0.05), transparent 70%), - radial-gradient(ellipse 22% 16% at 50% 80%, rgba(255, 255, 255, 0.04), transparent 70%); - animation: agents-aquarium-caustics 9s ease-in-out infinite alternate; - mix-blend-mode: screen; + will-change: transform; +} + +.agents-aquarium-water::before { + top: -70%; + left: -50%; + background: radial-gradient(closest-side, rgba(36, 191, 165, 0.10), rgba(36, 191, 165, 0.05) 35%, transparent 75%); + animation: agents-aquarium-float-a 18s ease-in-out infinite alternate; } -@keyframes agents-aquarium-caustics { +.agents-aquarium-water::after { + bottom: -70%; + right: -50%; + background: radial-gradient(closest-side, rgba(0, 122, 204, 0.10), rgba(0, 122, 204, 0.05) 35%, transparent 75%); + animation: agents-aquarium-float-b 22s ease-in-out infinite alternate; +} + +/* Use translate3d so the GPU composites the moving layer instead of + * re-rasterizing it each frame (which can introduce visible pixelation + * on large soft elements). */ +@keyframes agents-aquarium-float-a { 0% { - transform: translate(0, 0) scale(1); - opacity: 0.7; + transform: translate3d(0, 0, 0) scale(1); } 50% { - transform: translate(2%, -1%) scale(1.05); - opacity: 1; + transform: translate3d(8%, 6%, 0) scale(1.05); } 100% { - transform: translate(-1%, 2%) scale(0.98); - opacity: 0.8; + transform: translate3d(-5%, 10%, 0) scale(0.97); + } +} + +@keyframes agents-aquarium-float-b { + 0% { + transform: translate3d(0, 0, 0) scale(1); + } + 50% { + transform: translate3d(-6%, -8%, 0) scale(1.03); + } + 100% { + transform: translate3d(5%, -4%, 0) scale(1.07); } } @@ -86,18 +132,28 @@ position: absolute; top: 0; left: 0; - will-change: transform; + will-change: transform, opacity; pointer-events: none; + opacity: 0; + transition: opacity 360ms ease-out; /* No drop-shadow / blur filter: those break GPU compositing and force * a software rasterization pass per fish per frame. Worth ~10x in paint * cost when scaled to 50 fish. */ } +/* Stagger-friendly fade-in: the JS sets transition-delay per-fish before + * adding `.visible` so the school appears progressively. The fade-out is + * driven by inline styles in the exit handler (see aquariumOverlay.ts). */ +.agents-aquarium-fish.visible { + opacity: 1; +} + .agents-aquarium-fish-inner { width: 100%; height: 100%; transform-origin: center; - transition: transform 180ms ease-out; + /* No CSS transition: facing is eased in JS each frame via Fish.applyTransform + * so it stays perfectly in sync with the swim direction. */ } .agents-aquarium-fish svg { @@ -109,21 +165,40 @@ /* Body strips: each strip is a clipped vertical slice of the VS Code logo. * Strips animate translateY with a per-strip phase offset, producing a - * sine wave that travels along the body — the actual swimming motion. */ + * sine wave that travels along the body — the actual swimming motion. + * + * Per-strip color shading mirrors the real VS Code logo's depth: strips at + * the front (left, --strip-index ~ 0) stay at the species color, while + * strips at the back (right, --strip-index ~ NUM_BODY_STRIPS - 1) are + * mixed toward white. Inherits `currentColor` from the fish element so + * each species (Stable / Insiders / Exploration) keeps its own hue. */ .agents-aquarium-fish-strip { - animation: agents-aquarium-body-wave 720ms ease-in-out infinite; + color: color-mix(in srgb, currentColor, white calc(var(--strip-index, 0) * 4%)); + animation: agents-aquarium-body-wave 720ms linear infinite; /* 720ms / 10 strips = ~72ms phase delay between strips. Negative delays * start each strip mid-cycle so the wave is fully formed at t=0. */ animation-delay: calc(var(--strip-index, 0) * -72ms); } +/* 5-keyframe approximation of a sine wave. Linear timing between keyframes + * lands much closer to a true sinusoid than the previous 2-keyframe + * ease-in-out (which over-emphasized the extremes). */ @keyframes agents-aquarium-body-wave { - 0%, 100% { - transform: translateY(-3.5px); + 0% { + transform: translateY(0); } - 50% { + 25% { transform: translateY(3.5px); } + 50% { + transform: translateY(0); + } + 75% { + transform: translateY(-3.5px); + } + 100% { + transform: translateY(0); + } } /* ---- Food pellets ---- */ @@ -173,14 +248,21 @@ background: var(--vscode-toolbar-hoverBackground, rgba(255, 255, 255, 0.08)); } -/* Use the same `.monaco-workbench` prefix as the workbench's own - * `button:focus` rule so we win the cascade by source order without - * resorting to !important. */ -.monaco-workbench button.agents-aquarium-toggle:focus, -.monaco-workbench button.agents-aquarium-toggle:focus-visible { +/* Suppress the workbench's default `button:focus` outline when the user + * isn't doing keyboard navigation, but keep an explicit `:focus-visible` + * indicator so the button remains usable with a keyboard / screen magnifier. + * Using the same `.monaco-workbench` prefix as the workbench's own rule + * ensures we win the cascade without resorting to !important. */ +.monaco-workbench button.agents-aquarium-toggle:focus { outline: none; } +.monaco-workbench button.agents-aquarium-toggle:focus-visible { + opacity: 1; + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 2px; +} + .agents-aquarium-toggle.active { opacity: 1; color: #ff6b88; @@ -194,10 +276,12 @@ @media (prefers-reduced-motion: reduce) { .agents-aquarium-fish-strip, + .agents-aquarium-water::before, .agents-aquarium-water::after { animation: none; } - .agents-aquarium-water { + .agents-aquarium-water, + .agents-aquarium-fish { transition: none; } } From c86818c26247c15db33285fcc58f55f3cadd9c32 Mon Sep 17 00:00:00 2001 From: justschen Date: Sun, 26 Apr 2026 23:49:35 -0700 Subject: [PATCH 3/8] address commentS --- .../lib/stylelint/vscode-known-variables.json | 2 +- .../aquarium/browser/aquariumOverlay.ts | 32 ++++++++++++------- .../sessions/contrib/aquarium/browser/fish.ts | 29 ++++++++++------- .../aquarium/browser/media/aquarium.css | 22 +++++++++---- 4 files changed, 56 insertions(+), 29 deletions(-) diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index e063c7e15fdc4..465f865984f6e 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -958,7 +958,7 @@ "--notebook-editor-font-size", "--notebook-editor-font-weight", "--outline-element-color", - "--strip-index", + "--agents-aquarium-strip-index", "--separator-border", "--chat-bar-background", "--chat-tab-active-foreground", diff --git a/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts b/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts index ccc39b8b929aa..3e0c447219d5b 100644 --- a/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts +++ b/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts @@ -175,7 +175,8 @@ export class AquariumOverlay extends Disposable { } private createToggleButton(): HTMLButtonElement { - const button = document.createElement('button'); + const doc = getWindow(this.mainContainer).document; + const button = doc.createElement('button'); button.className = 'agents-aquarium-toggle'; button.type = 'button'; this.updateToggleButtonVisual(button, false); @@ -202,7 +203,7 @@ export class AquariumOverlay extends Disposable { // Build the icon as a real DOM child instead of innerHTML to satisfy // the workbench Trusted Types policy. button.replaceChildren(); - const iconSpan = document.createElement('span'); + const iconSpan = button.ownerDocument.createElement('span'); const iconClasses = ThemeIcon.asClassName(active ? Codicon.close : Codicon.smiley).split(/\s+/).filter(Boolean); for (const cls of iconClasses) { iconSpan.classList.add(cls); @@ -231,6 +232,10 @@ export class AquariumOverlay extends Disposable { if (this.activeRef.value) { return; } + // Cancel any in-flight exit from a previous deactivation so its + // delayed dispose can't tear down the new aquarium's shared SVG defs. + this.pendingExit?.dispose(); + this.pendingExit = undefined; let active: IActiveAquarium; try { active = createActiveAquarium(this.mainContainer, this.layoutService); @@ -309,17 +314,18 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe } // --- DOM setup --- - const water = document.createElement('div'); + const doc = targetWindow.document; + const water = doc.createElement('div'); water.className = 'agents-aquarium-water'; // Insert as the FIRST child so all subsequent chat bar content paints over it. chatBar.insertBefore(water, chatBar.firstChild); store.add(toDisposable(() => water.remove())); - const fishLayer = document.createElement('div'); + const fishLayer = doc.createElement('div'); fishLayer.className = 'agents-aquarium-fish-layer'; water.appendChild(fishLayer); - const foodLayer = document.createElement('div'); + const foodLayer = doc.createElement('div'); foodLayer.className = 'agents-aquarium-food-layer'; water.appendChild(foodLayer); @@ -402,7 +408,7 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe } // Tear down the shared SVG defs container along with the last // active aquarium so we don't leak it across reloads. - disposeSharedFishDefs(); + disposeSharedFishDefs(targetWindow.document); })); // --- Food --- @@ -428,15 +434,19 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe let mouseX = -1e6; let mouseY = -1e6; + const resetMousePosition = () => { + mouseX = -1e6; + mouseY = -1e6; + }; // Use the generic mouse helpers so this works under iOS pointer events too. store.add(addDisposableGenericMouseMoveListener(mainContainer, (e: MouseEvent) => { mouseX = e.clientX - waterScreenOffset.left; mouseY = e.clientY - waterScreenOffset.top; })); - store.add(addDisposableListener(mainContainer, EventType.MOUSE_LEAVE, () => { - mouseX = -1e6; - mouseY = -1e6; - }, { passive: true })); + // Listen to BOTH mouseleave and pointerleave so cursor reset works on + // touch/pointer-only platforms (where mouseleave never fires). + store.add(addDisposableListener(mainContainer, EventType.MOUSE_LEAVE, resetMousePosition, { passive: true })); + store.add(addDisposableListener(mainContainer, EventType.POINTER_LEAVE, resetMousePosition, { passive: true })); store.add(addDisposableGenericMouseDownListener(mainContainer, (e: MouseEvent) => { // Only spawn food on plain left clicks against background-ish surfaces. @@ -463,7 +473,7 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe const oldest = food[0]; removeFood(oldest); } - const el = document.createElement('div'); + const el = doc.createElement('div'); el.className = 'agents-aquarium-food'; el.style.transform = `translate(${fx}px, ${fy}px)`; foodLayer.appendChild(el); diff --git a/src/vs/sessions/contrib/aquarium/browser/fish.ts b/src/vs/sessions/contrib/aquarium/browser/fish.ts index f898f1467e128..fd08ff6557fef 100644 --- a/src/vs/sessions/contrib/aquarium/browser/fish.ts +++ b/src/vs/sessions/contrib/aquarium/browser/fish.ts @@ -38,12 +38,14 @@ export function pickRandomSpecies(): FishSpecies { } /** - * Tear down the shared SVG defs container. Call when no fish are active. + * Tear down the shared SVG defs container for the given document. Call when + * no fish are active in that document. */ -export function disposeSharedFishDefs(): void { - if (sharedDefsContainer) { - sharedDefsContainer.remove(); - sharedDefsContainer = undefined; +export function disposeSharedFishDefs(targetDocument: Document): void { + const container = sharedDefsByDocument.get(targetDocument); + if (container) { + container.remove(); + sharedDefsByDocument.delete(targetDocument); } } @@ -103,7 +105,7 @@ export class Fish { this.element.style.color = SPECIES_COLOR[opts.species]; // Inner element receives the directional flip so the body strip animations - // (driven by --strip-index) are unaffected by direction changes. + // (driven by --agents-aquarium-strip-index) are unaffected by direction changes. this.innerElement = targetDocument.createElement('div'); this.innerElement.className = 'agents-aquarium-fish-inner'; this.innerElement.appendChild(buildFishSvg(targetDocument)); @@ -160,13 +162,18 @@ const BODY_X_END = 90; * these via `clip-path: url(#…)` and `` instead of duplicating * the path data per strip per fish (which previously caused 50 fish * 10 * strips = 500 path parses on every aquarium activation). + * + * Keyed by `Document` so multi-window scenarios (auxiliary windows) each get + * their own defs in their own document — `` references can't cross + * document boundaries, so a single global would break in any window other + * than the first to activate. */ -let sharedDefsContainer: SVGSVGElement | undefined; +const sharedDefsByDocument = new WeakMap(); const SHARED_LOGO_SYMBOL_ID = 'agents-aquarium-fish-logo'; function ensureSharedDefs(targetDocument: Document): void { - if (sharedDefsContainer) { + if (sharedDefsByDocument.has(targetDocument)) { return; } const stripWidth = (BODY_X_END - BODY_X_START) / NUM_BODY_STRIPS; @@ -212,7 +219,7 @@ function ensureSharedDefs(targetDocument: Document): void { } container.appendChild(defs); targetDocument.body.appendChild(container); - sharedDefsContainer = container; + sharedDefsByDocument.set(targetDocument, container); } /** @@ -240,13 +247,13 @@ function buildFishSvg(targetDocument: Document): SVGSVGElement { // Body: NUM_BODY_STRIPS overlapping references to the shared logo symbol, // each clipped to its vertical band via shared clipPath defs. Each strip - // animates translateY with a phase offset driven by --strip-index. + // animates translateY with a phase offset driven by --agents-aquarium-strip-index. const bodyGroup = targetDocument.createElementNS(SVG_NS, 'g'); bodyGroup.setAttribute('class', 'agents-aquarium-fish-body'); for (let i = 0; i < NUM_BODY_STRIPS; i++) { const stripG = targetDocument.createElementNS(SVG_NS, 'g'); stripG.setAttribute('class', 'agents-aquarium-fish-strip'); - stripG.style.setProperty('--strip-index', String(i)); + stripG.style.setProperty('--agents-aquarium-strip-index', String(i)); const stripUse = targetDocument.createElementNS(SVG_NS, 'use'); stripUse.setAttribute('href', `#${SHARED_LOGO_SYMBOL_ID}`); stripUse.setAttribute('clip-path', `url(#agents-aquarium-fish-clip-${i})`); diff --git a/src/vs/sessions/contrib/aquarium/browser/media/aquarium.css b/src/vs/sessions/contrib/aquarium/browser/media/aquarium.css index 6a79ca5402e7f..9376a9ca607fb 100644 --- a/src/vs/sessions/contrib/aquarium/browser/media/aquarium.css +++ b/src/vs/sessions/contrib/aquarium/browser/media/aquarium.css @@ -24,6 +24,16 @@ position: relative; } +/* Keep the chat bar UI (title + content) above the decorative water layer. + * Without explicit stacking, an absolutely-positioned `.agents-aquarium-water` + * (z-index: 0) would paint ON TOP of the non-positioned chat children since + * positioned elements paint after non-positioned ones. */ +.monaco-workbench .part.chatbar > .title, +.monaco-workbench .part.chatbar > .content { + position: relative; + z-index: 1; +} + .agents-aquarium-water { position: absolute; inset: 0; @@ -168,16 +178,16 @@ * sine wave that travels along the body — the actual swimming motion. * * Per-strip color shading mirrors the real VS Code logo's depth: strips at - * the front (left, --strip-index ~ 0) stay at the species color, while - * strips at the back (right, --strip-index ~ NUM_BODY_STRIPS - 1) are - * mixed toward white. Inherits `currentColor` from the fish element so - * each species (Stable / Insiders / Exploration) keeps its own hue. */ + * the front (left, --agents-aquarium-strip-index ~ 0) stay at the species + * color, while strips at the back (right, ~ NUM_BODY_STRIPS - 1) are mixed + * toward white. Inherits `currentColor` from the fish element so each + * species (Stable / Insiders / Exploration) keeps its own hue. */ .agents-aquarium-fish-strip { - color: color-mix(in srgb, currentColor, white calc(var(--strip-index, 0) * 4%)); + color: color-mix(in srgb, currentColor, white calc(var(--agents-aquarium-strip-index, 0) * 4%)); animation: agents-aquarium-body-wave 720ms linear infinite; /* 720ms / 10 strips = ~72ms phase delay between strips. Negative delays * start each strip mid-cycle so the wave is fully formed at t=0. */ - animation-delay: calc(var(--strip-index, 0) * -72ms); + animation-delay: calc(var(--agents-aquarium-strip-index, 0) * -72ms); } /* 5-keyframe approximation of a sine wave. Linear timing between keyframes From d8118f7caf0a8d7594d7d383de179e9811613e91 Mon Sep 17 00:00:00 2001 From: justschen Date: Mon, 27 Apr 2026 13:56:30 -0700 Subject: [PATCH 4/8] fix disposable leak --- src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts b/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts index 3e0c447219d5b..5d267a039b152 100644 --- a/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts +++ b/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts @@ -267,6 +267,12 @@ export class AquariumOverlay extends Disposable { if (!active) { return; } + // Re-register the orphaned aquarium with the overlay so the lifecycle + // tracker still sees a parent for it (clearAndLeak detaches the + // disposable from its previous parent). The exit timer disposes it + // when the animation completes; the _register here is purely a + // backstop in case the overlay itself is disposed mid-exit. + this._register(active); this.activeContextKey.set(false); this.updateToggleButtonVisual(this.toggleButton, false); // Run the exit animation, then dispose. Stash on the overlay so a From dc485c4618e19d79612bfba5e79a19d591fba69e Mon Sep 17 00:00:00 2001 From: justschen Date: Wed, 29 Apr 2026 22:25:20 -0700 Subject: [PATCH 5/8] improve overall readability, extracted into service --- .../aquarium/browser/aquarium.contribution.ts | 38 +- .../aquarium/browser/aquariumOverlay.ts | 541 ++++++++---------- .../aquarium/browser/media/aquarium.css | 173 +++--- .../contrib/chat/browser/newChatViewPane.ts | 4 + 4 files changed, 334 insertions(+), 422 deletions(-) diff --git a/src/vs/sessions/contrib/aquarium/browser/aquarium.contribution.ts b/src/vs/sessions/contrib/aquarium/browser/aquarium.contribution.ts index 1b297239d4312..22bbff401e809 100644 --- a/src/vs/sessions/contrib/aquarium/browser/aquarium.contribution.ts +++ b/src/vs/sessions/contrib/aquarium/browser/aquarium.contribution.ts @@ -4,25 +4,23 @@ *--------------------------------------------------------------------------------------------*/ import './media/aquarium.css'; -import { Disposable } from '../../../../base/common/lifecycle.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { AquariumOverlay } from './aquariumOverlay.js'; +import { localize } from '../../../../nls.js'; +import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import product from '../../../../platform/product/common/product.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { AquariumService, IAquariumService, SESSIONS_DEVELOPER_JOY_ENABLED_SETTING } from './aquariumOverlay.js'; -/** - * Lifecycle owner for the Agents window aquarium. Instantiates the overlay - * (which renders its persistent toggle button and manages the on/off state). - */ -class SessionsAquariumContribution extends Disposable implements IWorkbenchContribution { +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + id: 'sessions', + properties: { + [SESSIONS_DEVELOPER_JOY_ENABLED_SETTING]: { + type: 'boolean', + default: product.quality !== 'stable', + description: localize('sessions.developerJoy.enabled', "Adds an easter egg to the Agents application."), + tags: ['experimental'], + }, + }, +}); - static readonly ID = 'workbench.contrib.sessionsAquarium'; - - constructor( - @IInstantiationService instantiationService: IInstantiationService, - ) { - super(); - this._register(instantiationService.createInstance(AquariumOverlay)); - } -} - -registerWorkbenchContribution2(SessionsAquariumContribution.ID, SessionsAquariumContribution, WorkbenchPhase.AfterRestored); +registerSingleton(IAquariumService, AquariumService, InstantiationType.Delayed); diff --git a/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts b/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts index 5d267a039b152..e283d7d8da92f 100644 --- a/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts +++ b/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts @@ -9,119 +9,145 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { localize } from '../../../../nls.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; -import { IsNewChatSessionContext, SessionsAquariumActiveContext } from '../../../common/contextkeys.js'; +import { SessionsAquariumActiveContext } from '../../../common/contextkeys.js'; import { disposeSharedFishDefs, Fish, pickRandomSpecies } from './fish.js'; +export const SESSIONS_DEVELOPER_JOY_ENABLED_SETTING = 'sessions.developerJoy.enabled'; + const FISH_COUNT = 50; const FISH_MIN_SIZE = 22; const FISH_MAX_SIZE = 48; -/** Pixels around the cursor where fish flee. */ const SCATTER_RADIUS = 145; -/** Pixels around a food pellet where the fish considers it grabbable. */ const EAT_RADIUS = 14; -/** Maximum distance a fish will sense a food pellet from. Smaller = food - * must land near a fish to attract attention; larger = fish swim across - * the tank to it. */ const FOOD_DETECT_RADIUS = 160; -/** Maximum concurrent food pellets in the water. */ const MAX_FOOD = 12; -/** Soft margin around the aquarium bounds where fish start to turn back. */ +/** Soft margin where fish start to turn back. */ const WALL_MARGIN = 36; -/** Base swimming speed (px/sec). */ const BASE_SPEED = 24; -/** Maximum normal swim speed (px/sec). */ const MAX_SPEED = 50; -/** Maximum panic swim speed (px/sec). */ const PANIC_MAX_SPEED = 240; -/** How long a fish stays in panic mode after being scattered. */ const PANIC_DURATION_MS = 600; - -/** How long the exit animation runs before the aquarium is disposed. */ const EXIT_DURATION_MS = 900; -/** - * Per-fish probability (per second) of starting a spontaneous "dart": a brief - * burst of speed in a random direction with no external trigger. With FISH_COUNT - * fish each rolling at this rate, the aquarium sees roughly - * FISH_COUNT * DART_RATE_PER_SECOND darts/sec total. - */ +/** Per-fish per-second probability of starting a spontaneous burst. */ const DART_RATE_PER_SECOND = 0.04; -/** Random dart impulse strength (px/sec velocity boost). */ const DART_IMPULSE = 150; -/** Context keys we react to for visibility changes. */ -const NEW_SESSION_KEY_SET = new Set([IsNewChatSessionContext.key]); - -/** Storage key for remembering whether the user wants the aquarium on. */ const AQUARIUM_ENABLED_STORAGE_KEY = 'workbench.sessions.aquarium.enabled'; interface IFoodPellet { readonly element: HTMLDivElement; x: number; y: number; - /** Sink speed (px/sec). */ vy: number; } /** - * The aquarium overlay: a transparent absolutely-positioned layer mounted - * inside the workbench main container. When activated, it fills the chat bar - * region with VS Code logo "fish" that swim around, scatter from the cursor, - * and chase food pellets dropped on click. Pointer events are not blocked, - * so all underlying chat UI remains fully interactive. - * - * The overlay also owns a small floating toggle button anchored just above - * the chat input box; the button persists across show/hide so users can - * always switch the aquarium back on. + * Owns the toggle button(s), the persisted on/off preference, and the active + * aquarium. Hosts call {@link IAquariumService.mountToggle} to attach a button + * as a child of their container; the active aquarium itself is mounted inside + * the chat bar part so the chat input naturally paints on top of the water. */ -export class AquariumOverlay extends Disposable { +export const IAquariumService = createDecorator('aquariumService'); - private readonly mainContainer: HTMLElement; +export interface IAquariumService { + readonly _serviceBrand: undefined; - /** The persistent toggle button. Always present once the overlay is created. */ - private readonly toggleButton: HTMLButtonElement; + /** + * Mount a toggle button into `parent`. Returns a disposable that removes + * the button and tears down the active aquarium if it was the last mount. + */ + mountToggle(parent: HTMLElement): IDisposable; +} - /** Per-activation state (DOM, RAF, listeners, fish, food). */ - private readonly activeRef = this._register(new MutableDisposable()); +interface IMountedToggle { + readonly button: HTMLButtonElement; +} + +export class AquariumService extends Disposable implements IAquariumService { + + declare readonly _serviceBrand: undefined; + + private readonly mainContainer: HTMLElement; + private readonly mounts = new Set(); + private readonly activeRef = this._register(new MutableDisposable()); private readonly activeContextKey: IContextKey; + private pendingExit: IDisposable | undefined; constructor( @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, - @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IContextKeyService contextKeyService: IContextKeyService, @IHoverService private readonly hoverService: IHoverService, @IStorageService private readonly storageService: IStorageService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { super(); this.mainContainer = layoutService.mainContainer; this.activeContextKey = SessionsAquariumActiveContext.bindTo(contextKeyService); - this.toggleButton = this.createToggleButton(); - // Mount the button as a real child of the chat bar's part container so - // CSS positioning (top-right) is relative to that element. No manual - // bounding-rect math required. - this.tryMountToggleButton(0); - - // Only show the button (and allow the aquarium) on the new-session view. - // When the user opens an existing session, hide the button and tear down - // any active aquarium. When they return, restore the previous state. - this.applyNewSessionVisibility(); - this._register(this.contextKeyService.onDidChangeContext(e => { - if (e.affectsSome(NEW_SESSION_KEY_SET)) { - this.applyNewSessionVisibility(); + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(SESSIONS_DEVELOPER_JOY_ENABLED_SETTING)) { + this.applyFeatureEnabledState(); } })); } - private isNewSession(): boolean { - return this.contextKeyService.getContextKeyValue(IsNewChatSessionContext.key) ?? true; + mountToggle(parent: HTMLElement): IDisposable { + const doc = parent.ownerDocument; + const button = doc.createElement('button'); + button.className = 'agents-aquarium-toggle'; + button.type = 'button'; + this.updateToggleButtonVisual(button, !!this.activeRef.value); + + const store = new DisposableStore(); + store.add(addDisposableListener(button, EventType.CLICK, e => { + // Don't bubble into the chat widget's own click handlers. + e.preventDefault(); + e.stopPropagation(); + this.toggle(); + })); + const hoverDelegate = store.add(createInstantHoverDelegate()); + store.add(this.hoverService.setupManagedHover( + hoverDelegate, + button, + () => this.activeRef.value ? localize('aquarium.hide', "Hide Aquarium") : localize('aquarium.show', "Show Aquarium"), + )); + + parent.appendChild(button); + + const mount: IMountedToggle = { button }; + this.mounts.add(mount); + this.applyFeatureEnabledStateForButton(button); + + // First mount with the user's stored preference on — auto-restore. + if (this.isFeatureEnabled() && this.isStoredEnabled() && !this.activeRef.value) { + this.activate(/* persist */ false); + } + + return toDisposable(() => { + store.dispose(); + button.remove(); + this.mounts.delete(mount); + // Last host gone — tear down without persisting so the user's + // preference for next time stays as it was. + if (this.mounts.size === 0 && this.activeRef.value) { + this.deactivate(/* persist */ false); + } + }); + } + + private isFeatureEnabled(): boolean { + return this.configurationService.getValue(SESSIONS_DEVELOPER_JOY_ENABLED_SETTING) === true; } private isStoredEnabled(): boolean { @@ -132,88 +158,40 @@ export class AquariumOverlay extends Disposable { this.storageService.store(AQUARIUM_ENABLED_STORAGE_KEY, enabled, StorageScope.APPLICATION, StorageTarget.USER); } - private applyNewSessionVisibility(): void { - const isNew = this.isNewSession(); - this.toggleButton.style.display = isNew ? '' : 'none'; - if (!isNew) { - // Leaving the new-session view: tear down without persisting (the - // stored preference still reflects what the user wants on return). - if (this.activeRef.value) { - this.deactivate(/* persist */ false); - } - } else { - // Returning to the new-session view: restore the saved state. - if (this.isStoredEnabled() && !this.activeRef.value) { - this.activate(/* persist */ false); - } - } - } - - /** - * Attempt to mount the toggle button inside the chat bar's part container. - * Retries on the next animation frame while the chat bar is still being - * created during workbench restore. - */ - private tryMountToggleButton(attempt: number): void { - const window = getWindow(this.mainContainer); - const chatBarElement = this.layoutService.getContainer(window, Parts.CHATBAR_PART); - if (chatBarElement && this.layoutService.isVisible(Parts.CHATBAR_PART, window) && chatBarElement.isConnected) { - chatBarElement.appendChild(this.toggleButton); - this._register(toDisposable(() => this.toggleButton.remove())); - // Now that the chat bar is mountable, restore the saved state - // (no-op if the user wasn't on the new-session view or had it off). - if (this.isNewSession() && this.isStoredEnabled() && !this.activeRef.value) { - this.activate(/* persist */ false); - } - return; + private applyFeatureEnabledState(): void { + for (const mount of this.mounts) { + this.applyFeatureEnabledStateForButton(mount.button); } - if (attempt >= 60) { - return; // give up; nothing else to do + if (!this.isFeatureEnabled() && this.activeRef.value) { + // Setting turned off — don't persist so the prior preference survives a re-enable. + this.deactivate(/* persist */ false); + } else if (this.isFeatureEnabled() && this.isStoredEnabled() && !this.activeRef.value && this.mounts.size > 0) { + this.activate(/* persist */ false); } - const sched = scheduleAtNextAnimationFrame(window, () => this.tryMountToggleButton(attempt + 1)); - this._register(sched); } - private createToggleButton(): HTMLButtonElement { - const doc = getWindow(this.mainContainer).document; - const button = doc.createElement('button'); - button.className = 'agents-aquarium-toggle'; - button.type = 'button'; - this.updateToggleButtonVisual(button, false); - - this._register(addDisposableListener(button, EventType.CLICK, e => { - // Don't let the click bubble into the chat bar's own handlers. - e.preventDefault(); - e.stopPropagation(); - this.toggle(); - })); - // Tooltip via the workbench hover service. - const hoverDelegate = this._register(createInstantHoverDelegate()); - this._register(this.hoverService.setupManagedHover( - hoverDelegate, - button, - () => this.activeRef.value ? localize('aquarium.hide', "Hide Aquarium") : localize('aquarium.show', "Show Aquarium"), - )); - - return button; + private applyFeatureEnabledStateForButton(button: HTMLButtonElement): void { + button.style.display = this.isFeatureEnabled() ? '' : 'none'; } private updateToggleButtonVisual(button: HTMLButtonElement, active: boolean): void { button.classList.toggle('active', active); - // Build the icon as a real DOM child instead of innerHTML to satisfy - // the workbench Trusted Types policy. + // Build the icon as a real DOM child instead of innerHTML to satisfy Trusted Types. button.replaceChildren(); const iconSpan = button.ownerDocument.createElement('span'); - const iconClasses = ThemeIcon.asClassName(active ? Codicon.close : Codicon.smiley).split(/\s+/).filter(Boolean); - for (const cls of iconClasses) { - iconSpan.classList.add(cls); + if (active) { + const iconClasses = ThemeIcon.asClassName(Codicon.close).split(/\s+/).filter(Boolean); + for (const cls of iconClasses) { + iconSpan.classList.add(cls); + } + } else { + iconSpan.classList.add('agents-aquarium-toggle-logo'); } button.appendChild(iconSpan); button.setAttribute('aria-pressed', String(active)); button.setAttribute('aria-label', active ? localize('aquarium.hide', "Hide Aquarium") : localize('aquarium.show', "Show Aquarium")); } - /** User clicked the toggle button — flip state AND persist the choice. */ private toggle(): void { if (this.activeRef.value) { this.deactivate(/* persist */ true); @@ -222,61 +200,48 @@ export class AquariumOverlay extends Disposable { } } - /** - * @param persist When true, store the new on-state so it's restored on - * next visit. False is used by `applyNewSessionVisibility` - * when restoring previously-stored state — we don't want - * to write storage in that case. - */ + private updateAllToggleButtonsVisual(active: boolean): void { + for (const mount of this.mounts) { + this.updateToggleButtonVisual(mount.button, active); + } + } + + /** @param persist false when restoring previously-stored state. */ private activate(persist: boolean): void { if (this.activeRef.value) { return; } - // Cancel any in-flight exit from a previous deactivation so its - // delayed dispose can't tear down the new aquarium's shared SVG defs. + // Cancel any in-flight exit so its delayed dispose can't tear down + // the new aquarium's shared SVG defs. this.pendingExit?.dispose(); this.pendingExit = undefined; let active: IActiveAquarium; try { active = createActiveAquarium(this.mainContainer, this.layoutService); } catch (e) { - // Defensively log and bail; never leave the overlay in a half-built - // state that confuses the toggle button. console.error('[aquarium] failed to activate', e); return; } this.activeRef.value = active; this.activeContextKey.set(true); - this.updateToggleButtonVisual(this.toggleButton, true); + this.updateAllToggleButtonsVisual(true); if (persist) { this.setStoredEnabled(true); } } - /** - * @param persist When true, store the new off-state. False is used when - * tearing down because we left the new-session view — - * the stored preference should still reflect what the - * user wants the next time they come back. - */ + /** @param persist false when tearing down for non-user reasons. */ private deactivate(persist: boolean): void { - // Detach the active aquarium WITHOUT disposing it (clearAndLeak does - // not call dispose) — otherwise the fish DOM would be torn down - // synchronously and the exit animation would never run. const active = this.activeRef.clearAndLeak(); if (!active) { return; } - // Re-register the orphaned aquarium with the overlay so the lifecycle - // tracker still sees a parent for it (clearAndLeak detaches the - // disposable from its previous parent). The exit timer disposes it - // when the animation completes; the _register here is purely a - // backstop in case the overlay itself is disposed mid-exit. + // Re-register so the orphaned aquarium is still disposed if the + // service itself is torn down mid-exit. this._register(active); this.activeContextKey.set(false); - this.updateToggleButtonVisual(this.toggleButton, false); - // Run the exit animation, then dispose. Stash on the overlay so a - // rapid re-activate during the exit can cancel the pending dispose. + this.updateAllToggleButtonsVisual(false); + // Stash the pending exit so a rapid re-activate can cancel it. this.pendingExit?.dispose(); const pending = active.exit(); this.pendingExit = pending; @@ -285,45 +250,35 @@ export class AquariumOverlay extends Disposable { this.setStoredEnabled(false); } } - - private pendingExit: IDisposable | undefined; } interface IActiveAquarium extends IDisposable { /** - * Trigger the exit animation and dispose the aquarium when it completes. - * Returns a disposable that, if disposed before the animation finishes, - * will short-circuit and dispose the aquarium immediately. + * Trigger the exit animation and dispose when it completes. Disposing the + * returned handle before the animation finishes disposes immediately. */ exit(): IDisposable; } -/** - * Build the live aquarium: water layer, fish, food, mouse handling, RAF loop. - * All resources are owned by the returned disposable; `dispose()` removes - * everything and stops the animation loop. - */ +/** Build the live aquarium: water, fish, food, mouse handling, RAF loop. */ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbenchLayoutService): IActiveAquarium { const store = new DisposableStore(); const targetWindow = getWindow(mainContainer); - // Host the aquarium INSIDE the chat bar's part container so the chat - // input UI (later DOM siblings) naturally paints on top. This avoids - // any z-index gymnastics — water sits behind, fish included. + // Host inside the chat bar so chat input UI naturally paints on top — + // no z-index gymnastics required. const chatBar = layoutService.getContainer(targetWindow, Parts.CHATBAR_PART); if (!chatBar || !layoutService.isVisible(Parts.CHATBAR_PART, targetWindow)) { - // No chat bar to host the aquarium — return an inert active aquarium. return { dispose: () => store.dispose(), exit: () => { store.dispose(); return store; }, }; } - // --- DOM setup --- const doc = targetWindow.document; const water = doc.createElement('div'); water.className = 'agents-aquarium-water'; - // Insert as the FIRST child so all subsequent chat bar content paints over it. + // First child so subsequent chat bar content paints over it. chatBar.insertBefore(water, chatBar.firstChild); store.add(toDisposable(() => water.remove())); @@ -335,10 +290,8 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe foodLayer.className = 'agents-aquarium-food-layer'; water.appendChild(foodLayer); - // --- Bounds (the water element fills the chat bar via CSS inset:0) --- const bounds = { width: 0, height: 0 }; - // Cached water rect screen-space top/left so the per-mousemove handler - // doesn't trigger a layout flush via getBoundingClientRect(). + // Cached so the per-mousemove handler doesn't trigger a layout flush. const waterScreenOffset = { left: 0, top: 0 }; const updateBounds = () => { bounds.width = water.clientWidth; @@ -348,13 +301,11 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe waterScreenOffset.top = r.top; }; - // --- Spawn fish --- const fish: Fish[] = []; updateBounds(); const resizeObserver = new ResizeObserver(() => { updateBounds(); - // Re-clamp fish if the bounds shrank below their position. for (const f of fish) { f.x = Math.min(f.x, Math.max(0, bounds.width - f.size)); f.y = Math.min(f.y, Math.max(0, bounds.height - f.size)); @@ -377,10 +328,8 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe }, targetWindow.document); fish.push(f); } - // Spawn fish in two batches: the first half is appended synchronously - // (via a DocumentFragment for a single layout pass), and the rest on - // the next animation frame so the toggle click stays snappy. Each batch - // gets the per-fish stagger applied in the fade-in pass below. + // Spawn in two batches: first half synchronous (single layout pass via + // DocumentFragment), rest on the next frame so the toggle click stays snappy. const SYNC_BATCH = Math.ceil(FISH_COUNT / 2); const firstBatch = targetWindow.document.createDocumentFragment(); for (let i = 0; i < Math.min(SYNC_BATCH, fish.length); i++) { @@ -394,8 +343,8 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe restBatch.appendChild(fish[i].element); } fishLayer.appendChild(restBatch); - // Add `.visible` on the NEXT frame so the elements have one paint - // at opacity:0 first — guarantees the CSS transition actually fires. + // Add `.visible` on the NEXT frame so a paint at opacity:0 happens + // first — guarantees the CSS transition fires. const fadeIn = scheduleAtNextAnimationFrame(targetWindow, () => { for (let i = SYNC_BATCH; i < fish.length; i++) { const localIndex = i - SYNC_BATCH; @@ -412,12 +361,10 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe for (const f of fish) { f.element.remove(); } - // Tear down the shared SVG defs container along with the last - // active aquarium so we don't leak it across reloads. + // Tear down shared SVG defs so we don't leak across reloads. disposeSharedFishDefs(targetWindow.document); })); - // --- Food --- const food: IFoodPellet[] = []; const removeFood = (pellet: IFoodPellet) => { const idx = food.indexOf(pellet); @@ -427,14 +374,8 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe } }; - // --- Mouse tracking & food drops on the main container --- - // pointer-events:none on the water layer means the underlying chat UI - // receives all events normally; we listen on the main container so we - // always know cursor position even when over the chat input. - // - // `waterScreenOffset` is kept fresh by `updateBounds()` (called from the - // ResizeObserver above). We also refresh on window scroll/resize since - // those don't trigger our element-level ResizeObserver. + // Listen on the main container so we always know cursor position even + // when over the chat input (water has pointer-events:none). store.add(addDisposableListener(targetWindow, EventType.RESIZE, updateBounds, { passive: true })); store.add(addDisposableListener(targetWindow, 'scroll', updateBounds, { passive: true, capture: true })); @@ -444,13 +385,12 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe mouseX = -1e6; mouseY = -1e6; }; - // Use the generic mouse helpers so this works under iOS pointer events too. + // Generic helpers so this also works under iOS pointer events. store.add(addDisposableGenericMouseMoveListener(mainContainer, (e: MouseEvent) => { mouseX = e.clientX - waterScreenOffset.left; mouseY = e.clientY - waterScreenOffset.top; })); - // Listen to BOTH mouseleave and pointerleave so cursor reset works on - // touch/pointer-only platforms (where mouseleave never fires). + // Both mouseleave AND pointerleave so reset works on touch/pointer-only platforms. store.add(addDisposableListener(mainContainer, EventType.MOUSE_LEAVE, resetMousePosition, { passive: true })); store.add(addDisposableListener(mainContainer, EventType.POINTER_LEAVE, resetMousePosition, { passive: true })); @@ -465,15 +405,15 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe } // Refresh once to be safe (mousedown is rare). updateBounds(); - const fx = e.clientX - waterScreenOffset.left; - const fy = e.clientY - waterScreenOffset.top; - if (fx < 0 || fy < 0 || fx > bounds.width || fy > bounds.height) { + const dropX = e.clientX - waterScreenOffset.left; + const dropY = e.clientY - waterScreenOffset.top; + if (dropX < 0 || dropY < 0 || dropX > bounds.width || dropY > bounds.height) { return; } - spawnFood(fx, fy); + spawnFood(dropX, dropY); })); - function spawnFood(fx: number, fy: number): void { + function spawnFood(dropX: number, dropY: number): void { // Cap concurrent food: drop the oldest pellet to make room. while (food.length >= MAX_FOOD) { const oldest = food[0]; @@ -481,12 +421,11 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe } const el = doc.createElement('div'); el.className = 'agents-aquarium-food'; - el.style.transform = `translate(${fx}px, ${fy}px)`; + el.style.transform = `translate(${dropX}px, ${dropY}px)`; foodLayer.appendChild(el); - food.push({ element: el, x: fx, y: fy, vy: randomBetween(20, 35) }); + food.push({ element: el, x: dropX, y: dropY, vy: randomBetween(20, 35) }); } - // --- RAF loop --- const reduceMotion = targetWindow.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false; let lastFrame = performance.now(); let rafDisposable: IDisposable | undefined; @@ -497,7 +436,7 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe const dt = dtMs / 1000; lastFrame = now; - // Skip work when window is hidden (still keeps the RAF alive lazily). + // Skip work when window is hidden (RAF stays alive lazily). const visible = targetWindow.document.visibilityState !== 'hidden'; if (visible && !reduceMotion) { updateFood(dt); @@ -508,13 +447,12 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe }; function updateFood(dt: number): void { - // Sink the pellets and remove any that fall off the bottom. for (let i = food.length - 1; i >= 0; i--) { - const p = food[i]; - p.y += p.vy * dt; - p.element.style.transform = `translate(${p.x.toFixed(1)}px, ${p.y.toFixed(1)}px)`; - if (p.y > bounds.height + 10) { - removeFood(p); + const pellet = food[i]; + pellet.y += pellet.vy * dt; + pellet.element.style.transform = `translate(${pellet.x.toFixed(1)}px, ${pellet.y.toFixed(1)}px)`; + if (pellet.y > bounds.height + 10) { + removeFood(pellet); } } } @@ -522,33 +460,28 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe function updateFish(dt: number): void { const now = performance.now(); for (const f of fish) { - const cx = f.x + f.size / 2; - const cy = f.y + f.size / 2; - - // --- Wall steering: actively turn the heading away from any wall - // the fish is approaching. Doing this on the heading (not just on - // acceleration) prevents fish from parking against the edge with - // their thrust pinning them in place. - const targetAngleFromWall = computeWallAvoidAngle(cx, cy, bounds.width, bounds.height); - if (targetAngleFromWall !== undefined) { + const centerX = f.x + f.size / 2; + const centerY = f.y + f.size / 2; + + // Wall steering: turn the heading (not just acceleration) away from + // walls, otherwise fish park against the edge with their thrust + // pinning them in place. + const wallEscapeAngle = computeWallAvoidAngle(centerX, centerY, bounds.width, bounds.height); + if (wallEscapeAngle !== undefined) { // Turn at up to 4 rad/s toward the safe direction. - const turn = shortestAngleDelta(f.wanderAngle, targetAngleFromWall); - const maxTurn = 4 * dt; - f.wanderAngle += Math.max(-maxTurn, Math.min(maxTurn, turn)); + const turnDelta = shortestAngleDelta(f.wanderAngle, wallEscapeAngle); + const maxTurnPerFrame = 4 * dt; + f.wanderAngle += Math.max(-maxTurnPerFrame, Math.min(maxTurnPerFrame, turnDelta)); } else { // Free water: drift the heading by a small random delta. f.wanderAngle += (Math.random() - 0.5) * 1.2 * dt + (Math.random() - 0.5) * 0.04; } - // --- Steering: gentle thrust along the wander heading --- const thrust = 32; - let ax = Math.cos(f.wanderAngle) * thrust; - let ay = Math.sin(f.wanderAngle) * thrust; + let accelX = Math.cos(f.wanderAngle) * thrust; + let accelY = Math.sin(f.wanderAngle) * thrust; - // --- Spontaneous dart --- - // With probability DART_RATE_PER_SECOND * dt this frame, kick the - // fish in a random direction and put it briefly into panic mode so - // it can exceed normal max speed. + // Spontaneous dart with brief panic so it can exceed normal max speed. if (Math.random() < DART_RATE_PER_SECOND * dt) { const dartAngle = Math.random() * Math.PI * 2; f.vx += Math.cos(dartAngle) * DART_IMPULSE; @@ -556,63 +489,59 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe f.panicUntil = now + PANIC_DURATION_MS; } - // --- Wall repel (soft, secondary) --- - // Backstop the heading-based steering so a fish entering the - // margin still gets pushed inward immediately. - if (cx < WALL_MARGIN) { - ax += (WALL_MARGIN - cx) * 6; - } else if (cx > bounds.width - WALL_MARGIN) { - ax -= (cx - (bounds.width - WALL_MARGIN)) * 6; + // Wall repel — backstop so a fish entering the margin is pushed inward immediately. + if (centerX < WALL_MARGIN) { + accelX += (WALL_MARGIN - centerX) * 6; + } else if (centerX > bounds.width - WALL_MARGIN) { + accelX -= (centerX - (bounds.width - WALL_MARGIN)) * 6; } - if (cy < WALL_MARGIN) { - ay += (WALL_MARGIN - cy) * 6; - } else if (cy > bounds.height - WALL_MARGIN) { - ay -= (cy - (bounds.height - WALL_MARGIN)) * 6; + if (centerY < WALL_MARGIN) { + accelY += (WALL_MARGIN - centerY) * 6; + } else if (centerY > bounds.height - WALL_MARGIN) { + accelY -= (centerY - (bounds.height - WALL_MARGIN)) * 6; } - // --- Mouse scatter --- - const dxM = cx - mouseX; - const dyM = cy - mouseY; - const distM2 = dxM * dxM + dyM * dyM; - if (distM2 < SCATTER_RADIUS * SCATTER_RADIUS) { - const distM = Math.max(Math.sqrt(distM2), 1); - const force = (1 - distM / SCATTER_RADIUS) * 1100; - ax += (dxM / distM) * force; - ay += (dyM / distM) * force; + // Mouse scatter + const mouseDeltaX = centerX - mouseX; + const mouseDeltaY = centerY - mouseY; + const mouseDistSq = mouseDeltaX * mouseDeltaX + mouseDeltaY * mouseDeltaY; + if (mouseDistSq < SCATTER_RADIUS * SCATTER_RADIUS) { + const mouseDist = Math.max(Math.sqrt(mouseDistSq), 1); + const force = (1 - mouseDist / SCATTER_RADIUS) * 1100; + accelX += (mouseDeltaX / mouseDist) * force; + accelY += (mouseDeltaY / mouseDist) * force; f.panicUntil = now + PANIC_DURATION_MS; } - // --- Seek nearest food (only within FOOD_DETECT_RADIUS) --- - let nearest: IFoodPellet | undefined; - let nearestDist2 = FOOD_DETECT_RADIUS * FOOD_DETECT_RADIUS; - for (const p of food) { - const dxF = (p.x) - cx; - const dyF = (p.y) - cy; - const d2 = dxF * dxF + dyF * dyF; - if (d2 < nearestDist2) { - nearestDist2 = d2; - nearest = p; + // Seek nearest food within FOOD_DETECT_RADIUS + let nearestPellet: IFoodPellet | undefined; + let nearestDistSq = FOOD_DETECT_RADIUS * FOOD_DETECT_RADIUS; + for (const pellet of food) { + const foodDeltaX = pellet.x - centerX; + const foodDeltaY = pellet.y - centerY; + const distSq = foodDeltaX * foodDeltaX + foodDeltaY * foodDeltaY; + if (distSq < nearestDistSq) { + nearestDistSq = distSq; + nearestPellet = pellet; } } - if (nearest) { - const distF = Math.max(Math.sqrt(nearestDist2), 1); - if (distF < EAT_RADIUS) { - removeFood(nearest); + if (nearestPellet) { + const nearestDist = Math.max(Math.sqrt(nearestDistSq), 1); + if (nearestDist < EAT_RADIUS) { + removeFood(nearestPellet); } else { - ax += ((nearest.x) - cx) / distF * 200; - ay += ((nearest.y) - cy) / distF * 200; + accelX += (nearestPellet.x - centerX) / nearestDist * 200; + accelY += (nearestPellet.y - centerY) / nearestDist * 200; } } - // --- Integrate --- - f.vx += ax * dt; - f.vy += ay * dt; + f.vx += accelX * dt; + f.vy += accelY * dt; - // Cap speed based on panic state. - const speed2 = f.vx * f.vx + f.vy * f.vy; + const speedSq = f.vx * f.vx + f.vy * f.vy; const maxSpeed = now < f.panicUntil ? PANIC_MAX_SPEED : MAX_SPEED; - if (speed2 > maxSpeed * maxSpeed) { - const speed = Math.sqrt(speed2); + if (speedSq > maxSpeed * maxSpeed) { + const speed = Math.sqrt(speedSq); f.vx = (f.vx / speed) * maxSpeed; f.vy = (f.vy / speed) * maxSpeed; } @@ -620,7 +549,7 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe f.x += f.vx * dt; f.y += f.vy * dt; - // Hard clamp as a safety net. + // Hard clamp safety net. f.x = clamp(f.x, -f.size * 0.25, bounds.width - f.size * 0.75); f.y = clamp(f.y, -f.size * 0.25, bounds.height - f.size * 0.75); @@ -631,14 +560,12 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe rafDisposable = scheduleAtNextAnimationFrame(targetWindow, tick); store.add(toDisposable(() => rafDisposable?.dispose())); - // --- Fade-in: water + per-fish stagger (first batch only; the deferred - // batch fades in inline when it's mounted next frame). --- + // First-batch fade-in (the deferred batch fades in when it mounts). scheduleAtNextAnimationFrame(targetWindow, () => { water.classList.add('visible'); for (let i = 0; i < Math.min(SYNC_BATCH, fish.length); i++) { const f = fish[i]; - // Slight stagger so the school appears progressively rather than - // all at once. Cap at ~400ms total so it doesn't drag on. + // Slight stagger, capped at ~400ms so it doesn't drag on. const delay = Math.min(i * 12, 400); f.element.style.transitionDelay = `${delay}ms`; f.element.classList.add('visible'); @@ -655,10 +582,6 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe } exiting = true; - // Mirror of fade-in: per-fish stagger, then drop the `.visible` - // class so the same CSS transition (`opacity 360ms ease-out`) - // interpolates back to 0. Fish keep doing their normal swim - // while fading — no choreographed "all swim one direction". for (let i = 0; i < fish.length; i++) { const f = fish[i]; const delay = Math.min(i * 12, 400); @@ -667,7 +590,6 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe } water.classList.remove('visible'); - // After the animation completes, dispose everything. let timer: ReturnType | undefined = setTimeout(() => { timer = undefined; store.dispose(); @@ -685,13 +607,11 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe return result; } -/** Determine whether a click target is "background-ish" (not on a control). */ +/** True for clicks not on a control — i.e. safe targets for spawning food. */ function isBackgroundClick(target: HTMLElement | null): boolean { if (!target) { return false; } - // Don't drop food when the user is clicking on an input, button, link, - // or anything inside the chat input editor. if (target.closest('input, textarea, select, button, a, [role="button"], [role="link"], [role="textbox"], [role="combobox"], [role="menuitem"], [role="tab"], .monaco-editor, .scroll-decoration, .monaco-list-row')) { return false; } @@ -704,51 +624,44 @@ function randomBetween(min: number, max: number): number { function clamp(value: number, min: number, max: number): number { if (max < min) { - // Bounds smaller than the fish; keep it pinned to min. return min; } return Math.min(Math.max(value, min), max); } /** - * If the fish is inside the wall margin, return the heading (radians) that - * points back into the open water. Returns `undefined` when the fish is - * comfortably away from all walls so the caller can apply free wandering. - * - * The chosen direction sums per-wall vectors weighted by how far inside the - * margin the fish has crept, with a small randomized tangential bias so - * neighbors don't all converge to the exact same heading. + * If the fish is inside the wall margin, return the heading (radians) pointing + * back into open water. Returns `undefined` when the fish is comfortably away + * from all walls. Direction sums per-wall vectors weighted by encroachment, + * with a small tangential perturbation so neighbors don't all converge to the + * same heading. */ -function computeWallAvoidAngle(cx: number, cy: number, width: number, height: number): number | undefined { - let dx = 0; - let dy = 0; - if (cx < WALL_MARGIN) { - dx += (WALL_MARGIN - cx) / WALL_MARGIN; - } else if (cx > width - WALL_MARGIN) { - dx -= (cx - (width - WALL_MARGIN)) / WALL_MARGIN; +function computeWallAvoidAngle(centerX: number, centerY: number, width: number, height: number): number | undefined { + let escapeX = 0; + let escapeY = 0; + if (centerX < WALL_MARGIN) { + escapeX += (WALL_MARGIN - centerX) / WALL_MARGIN; + } else if (centerX > width - WALL_MARGIN) { + escapeX -= (centerX - (width - WALL_MARGIN)) / WALL_MARGIN; } - if (cy < WALL_MARGIN) { - dy += (WALL_MARGIN - cy) / WALL_MARGIN; - } else if (cy > height - WALL_MARGIN) { - dy -= (cy - (height - WALL_MARGIN)) / WALL_MARGIN; + if (centerY < WALL_MARGIN) { + escapeY += (WALL_MARGIN - centerY) / WALL_MARGIN; + } else if (centerY > height - WALL_MARGIN) { + escapeY -= (centerY - (height - WALL_MARGIN)) / WALL_MARGIN; } - if (dx === 0 && dy === 0) { + if (escapeX === 0 && escapeY === 0) { return undefined; } - // Add a small tangential perturbation so a row of fish hitting the same - // wall don't all turn to the exact same angle. - return Math.atan2(dy, dx) + (Math.random() - 0.5) * 0.4; + return Math.atan2(escapeY, escapeX) + (Math.random() - 0.5) * 0.4; } -/** - * Smallest signed angular delta that takes `from` to `to`, in [-PI, PI]. - */ +/** Smallest signed angular delta from `from` to `to`, in [-PI, PI]. */ function shortestAngleDelta(from: number, to: number): number { - let d = (to - from) % (Math.PI * 2); - if (d > Math.PI) { - d -= Math.PI * 2; - } else if (d < -Math.PI) { - d += Math.PI * 2; + let delta = (to - from) % (Math.PI * 2); + if (delta > Math.PI) { + delta -= Math.PI * 2; + } else if (delta < -Math.PI) { + delta += Math.PI * 2; } - return d; + return delta; } diff --git a/src/vs/sessions/contrib/aquarium/browser/media/aquarium.css b/src/vs/sessions/contrib/aquarium/browser/media/aquarium.css index 9376a9ca607fb..d371c5f0e6e13 100644 --- a/src/vs/sessions/contrib/aquarium/browser/media/aquarium.css +++ b/src/vs/sessions/contrib/aquarium/browser/media/aquarium.css @@ -3,31 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* =========================================================================== - * Agents Aquarium - * =========================================================================== - * - .agents-aquarium-water full backdrop layer over the chat bar region - * - .agents-aquarium-fish one per swimming fish (DOM + inline SVG) - * - .agents-aquarium-food one per food pellet - * - .agents-aquarium-toggle persistent floating button above the chat input - * - * All layers use pointer-events: none so the underlying chat UI keeps - * receiving clicks/keystrokes; mouse handling is done at the workbench main - * container level by aquariumOverlay.ts. - */ - -/* ---- Water (background tint + caustics) ---- */ - -/* Ensure the chat bar establishes a positioning context so the water layer - * (its first child) anchors exactly to the chat bar's bounds. */ +/* Agents aquarium. All decorative layers use pointer-events:none; mouse + * handling is done at the workbench main container level. See aquariumOverlay.ts. */ + +/* --- Water --- */ + +/* Make the chat bar a positioning context for the water layer (its first + * child) and keep its real children above the decorative water — without + * explicit z-index, positioned siblings paint after non-positioned ones. */ .monaco-workbench .part.chatbar { position: relative; } - -/* Keep the chat bar UI (title + content) above the decorative water layer. - * Without explicit stacking, an absolutely-positioned `.agents-aquarium-water` - * (z-index: 0) would paint ON TOP of the non-positioned chat children since - * positioned elements paint after non-positioned ones. */ .monaco-workbench .part.chatbar > .title, .monaco-workbench .part.chatbar > .content { position: relative; @@ -40,13 +26,10 @@ pointer-events: none; overflow: hidden; z-index: 0; - /* sits behind any later sibling content in the chat bar */ border-radius: 12px; opacity: 0; transition: opacity 320ms ease-out; - /* Simple linear gradient base. Linear gradients band far less than - * radial ones because the alpha changes on a single axis at uniform - * speed — no concentric "rings" to perceive. */ + /* Linear (not radial) — bands far less than radial alpha gradients. */ background: linear-gradient(160deg, rgba(36, 191, 165, 0.06) 0%, rgba(0, 122, 204, 0.05) 55%, @@ -57,26 +40,10 @@ opacity: 1; } -/* Floating accent circles. Three blurred radial sources slowly drift around - * to give the water a subtle living quality. They're positioned via CSS - * transforms (GPU-accelerated) and the blur keeps any individual edge from - * being visible — the eye reads the result as a soft moving glow rather than - * three discrete circles. */ -/* Floating accent circles. Two soft radial sources slowly drift around to - * give the water a subtle living quality. - * - * Pixelation gotchas (and how we avoid them): - * - The blur filter is rasterized; if its edge is clipped by the parent's - * `overflow: hidden`, the clip happens BEFORE the blur smears, leaving - * a visible hard edge. We use radial-gradients with a soft falloff - * INSIDE the circles instead of relying on `filter: blur(...)` so the - * edge softness is part of the gradient itself, not a post-process. - * - GPU-accelerate the animations via translate3d so the whole layer - * composites once and isn't re-rasterized every frame. - * - Make the circles much larger than the visible area so even the - * softest part of the falloff has somewhere to go (no concentric ring - * cutting off at the edge). - */ +/* Two soft drifting accent glows. We use radial-gradient softness inside + * each pseudo (not `filter: blur()`) so the parent's `overflow: hidden` + * doesn't clip the blur radius and leave a hard edge. Circles are sized + * larger than visible so the falloff has somewhere to fade into. */ .agents-aquarium-water::before, .agents-aquarium-water::after { content: ''; @@ -102,9 +69,7 @@ animation: agents-aquarium-float-b 22s ease-in-out infinite alternate; } -/* Use translate3d so the GPU composites the moving layer instead of - * re-rasterizing it each frame (which can introduce visible pixelation - * on large soft elements). */ +/* translate3d so the GPU composites instead of re-rasterizing each frame. */ @keyframes agents-aquarium-float-a { 0% { transform: translate3d(0, 0, 0) scale(1); @@ -129,7 +94,7 @@ } } -/* ---- Fish ---- */ +/* --- Fish --- */ .agents-aquarium-fish-layer, .agents-aquarium-food-layer { @@ -138,6 +103,8 @@ pointer-events: none; } +/* No drop-shadow / blur filter: those force a software rasterization pass + * per fish per frame (~10x paint cost at 50 fish). */ .agents-aquarium-fish { position: absolute; top: 0; @@ -146,24 +113,20 @@ pointer-events: none; opacity: 0; transition: opacity 360ms ease-out; - /* No drop-shadow / blur filter: those break GPU compositing and force - * a software rasterization pass per fish per frame. Worth ~10x in paint - * cost when scaled to 50 fish. */ } -/* Stagger-friendly fade-in: the JS sets transition-delay per-fish before - * adding `.visible` so the school appears progressively. The fade-out is - * driven by inline styles in the exit handler (see aquariumOverlay.ts). */ +/* JS sets per-fish transition-delay before adding `.visible` for staggered + * entry; the exit handler drives the fade-out via inline styles. */ .agents-aquarium-fish.visible { opacity: 1; } +/* No transition on the inner: facing is eased in JS each frame via + * Fish.applyTransform so it stays in sync with the swim direction. */ .agents-aquarium-fish-inner { width: 100%; height: 100%; transform-origin: center; - /* No CSS transition: facing is eased in JS each frame via Fish.applyTransform - * so it stays perfectly in sync with the swim direction. */ } .agents-aquarium-fish svg { @@ -173,26 +136,19 @@ overflow: visible; } -/* Body strips: each strip is a clipped vertical slice of the VS Code logo. - * Strips animate translateY with a per-strip phase offset, producing a - * sine wave that travels along the body — the actual swimming motion. - * - * Per-strip color shading mirrors the real VS Code logo's depth: strips at - * the front (left, --agents-aquarium-strip-index ~ 0) stay at the species - * color, while strips at the back (right, ~ NUM_BODY_STRIPS - 1) are mixed - * toward white. Inherits `currentColor` from the fish element so each - * species (Stable / Insiders / Exploration) keeps its own hue. */ +/* Each strip is a clipped vertical slice of the VS Code logo; staggered + * translateY produces a sine wave traveling along the body (the swim). + * Front strips (--agents-aquarium-strip-index ~ 0) keep the species color; + * back strips mix toward white for depth shading. 720ms / 10 strips approximately 72ms + * phase delay; negative delay starts each strip mid-cycle. */ .agents-aquarium-fish-strip { color: color-mix(in srgb, currentColor, white calc(var(--agents-aquarium-strip-index, 0) * 4%)); animation: agents-aquarium-body-wave 720ms linear infinite; - /* 720ms / 10 strips = ~72ms phase delay between strips. Negative delays - * start each strip mid-cycle so the wave is fully formed at t=0. */ animation-delay: calc(var(--agents-aquarium-strip-index, 0) * -72ms); } -/* 5-keyframe approximation of a sine wave. Linear timing between keyframes - * lands much closer to a true sinusoid than the previous 2-keyframe - * ease-in-out (which over-emphasized the extremes). */ +/* 5-keyframe sine approximation; linear timing lands closer to a true + * sinusoid than 2-keyframe ease-in-out (which over-emphasizes extremes). */ @keyframes agents-aquarium-body-wave { 0% { transform: translateY(0); @@ -211,7 +167,7 @@ } } -/* ---- Food pellets ---- */ +/* --- Food pellets --- */ .agents-aquarium-food { position: absolute; @@ -219,8 +175,7 @@ left: 0; width: 6px; height: 6px; - margin-left: -3px; - margin-top: -3px; + margin: -3px 0 0 -3px; border-radius: 50%; background: radial-gradient(circle at 35% 35%, #ffd56a, #c98a17 80%); box-shadow: 0 0 4px rgba(255, 200, 80, 0.6); @@ -228,11 +183,8 @@ will-change: transform; } -/* ---- Toggle button (always visible, top-right of the chat bar) ---- */ +/* --- Toggle button (anchored to the chat bar) --- */ -/* The chat bar's `.part.chatbar` already establishes a positioning context - * (it has its own padding/border), so absolute positioning anchors the - * button relative to it. */ .agents-aquarium-toggle { position: absolute; top: 12px; @@ -258,15 +210,11 @@ background: var(--vscode-toolbar-hoverBackground, rgba(255, 255, 255, 0.08)); } -/* Suppress the workbench's default `button:focus` outline when the user - * isn't doing keyboard navigation, but keep an explicit `:focus-visible` - * indicator so the button remains usable with a keyboard / screen magnifier. - * Using the same `.monaco-workbench` prefix as the workbench's own rule - * ensures we win the cascade without resorting to !important. */ +/* `.monaco-workbench` prefix to match the workbench's own `button:focus` + * rule so we win the cascade without resorting to !important. */ .monaco-workbench button.agents-aquarium-toggle:focus { outline: none; } - .monaco-workbench button.agents-aquarium-toggle:focus-visible { opacity: 1; outline: 1px solid var(--vscode-focusBorder); @@ -277,19 +225,68 @@ opacity: 1; color: #ff6b88; } - .agents-aquarium-toggle .codicon { font-size: 14px; } -/* ---- Reduced motion ---- */ +/* Off-state icon: the agents window logo (same asset as the mobile chat + * shell's workspace picker). Theme-swapped under `.vs` / `.hc-light`. */ +.agents-aquarium-toggle-logo { + display: inline-block; + width: 14px; + height: 14px; + background: url('../../../../browser/media/sessions-logo-light.svg') center / contain no-repeat; + transform-origin: 50% 50%; + /* Brief attention-grabbing wiggle every 12s. Keyframes hold the rest + * pose for ~93% of the cycle and only animate during the first ~7%. */ + animation: agents-aquarium-toggle-wiggle 12s ease-in-out infinite; +} + +.vs .agents-aquarium-toggle-logo, +.hc-light .agents-aquarium-toggle-logo { + background-image: url('../../../../browser/media/sessions-logo-dark.svg'); +} + +/* No need to nag once the user is engaging with the button. */ +.agents-aquarium-toggle:hover .agents-aquarium-toggle-logo, +.agents-aquarium-toggle:focus-visible .agents-aquarium-toggle-logo { + animation: none; +} + +@keyframes agents-aquarium-toggle-wiggle { + 0% { + transform: rotate(0deg) scale(1); + } + 1.5% { + transform: rotate(-12deg) scale(1.08); + } + 3% { + transform: rotate(10deg) scale(1.08); + } + 4.5% { + transform: rotate(-8deg) scale(1.05); + } + 6% { + transform: rotate(6deg) scale(1.03); + } + 7% { + transform: rotate(0deg) scale(1); + } + 100% { + transform: rotate(0deg) scale(1); + } +} + +/* --- Reduced motion --- */ @media (prefers-reduced-motion: reduce) { .agents-aquarium-fish-strip, .agents-aquarium-water::before, - .agents-aquarium-water::after { + .agents-aquarium-water::after, + .agents-aquarium-toggle-logo { animation: none; } + .agents-aquarium-water, .agents-aquarium-fish { transition: none; diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 3e2bcb734c0e1..c0d7ee74d7365 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -21,6 +21,7 @@ import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { localize } from '../../../../nls.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; +import { IAquariumService } from '../../aquarium/browser/aquariumOverlay.js'; import { IViewDescriptorService } from '../../../../workbench/common/views.js'; import { IWorkspaceTrustRequestService } from '../../../../platform/workspace/common/workspaceTrust.js'; import { IViewPaneOptions, ViewPane } from '../../../../workbench/browser/parts/views/viewPane.js'; @@ -45,6 +46,7 @@ class NewChatWidget extends Disposable { @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService, + @IAquariumService private readonly aquariumService: IAquariumService, ) { super(); this._workspacePicker = this._register(this.instantiationService.createInstance(isWeb ? ScopedWorkspacePicker : WorkspacePicker)); @@ -94,6 +96,8 @@ class NewChatWidget extends Disposable { const chatWidgetContainer = dom.append(element, dom.$('.new-chat-widget-container')); const chatWidgetContent = dom.append(chatWidgetContainer, dom.$('.new-chat-widget-content')); + this._register(this.aquariumService.mountToggle(element)); + const workspacePickerContainer = dom.append(chatWidgetContent, dom.$('.new-session-workspace-picker-container')); this._register(this._renderWorkspacePicker(workspacePickerContainer)); From 382d95330df751c2dd6f36717eb04c4352b7388b Mon Sep 17 00:00:00 2001 From: justschen Date: Wed, 29 Apr 2026 22:45:47 -0700 Subject: [PATCH 6/8] address code review comments --- .../aquarium/browser/aquariumOverlay.ts | 150 +++++++++++++----- .../sessions/contrib/aquarium/browser/fish.ts | 1 + .../aquarium/browser/media/aquarium.css | 2 +- 3 files changed, 109 insertions(+), 44 deletions(-) diff --git a/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts b/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts index e283d7d8da92f..ab079d36a0e82 100644 --- a/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts +++ b/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts @@ -9,6 +9,7 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { localize } from '../../../../nls.js'; +import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; @@ -41,7 +42,7 @@ const EXIT_DURATION_MS = 900; const DART_RATE_PER_SECOND = 0.04; const DART_IMPULSE = 150; -const AQUARIUM_ENABLED_STORAGE_KEY = 'workbench.sessions.aquarium.enabled'; +const ENABLED_STORAGE_KEY = 'sessions.developerJoy.enabled'; interface IFoodPellet { readonly element: HTMLDivElement; @@ -80,8 +81,8 @@ export class AquariumService extends Disposable implements IAquariumService { private readonly mounts = new Set(); private readonly activeRef = this._register(new MutableDisposable()); + private readonly pendingExit = this._register(new MutableDisposable()); private readonly activeContextKey: IContextKey; - private pendingExit: IDisposable | undefined; constructor( @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @@ -89,6 +90,7 @@ export class AquariumService extends Disposable implements IAquariumService { @IHoverService private readonly hoverService: IHoverService, @IStorageService private readonly storageService: IStorageService, @IConfigurationService private readonly configurationService: IConfigurationService, + @IAccessibilityService private readonly accessibilityService: IAccessibilityService, ) { super(); @@ -120,7 +122,7 @@ export class AquariumService extends Disposable implements IAquariumService { store.add(this.hoverService.setupManagedHover( hoverDelegate, button, - () => this.activeRef.value ? localize('aquarium.hide', "Hide Aquarium") : localize('aquarium.show', "Show Aquarium"), + () => this.getToggleLabel(!!this.activeRef.value), )); parent.appendChild(button); @@ -151,11 +153,11 @@ export class AquariumService extends Disposable implements IAquariumService { } private isStoredEnabled(): boolean { - return this.storageService.getBoolean(AQUARIUM_ENABLED_STORAGE_KEY, StorageScope.APPLICATION, false); + return this.storageService.getBoolean(ENABLED_STORAGE_KEY, StorageScope.APPLICATION, false); } private setStoredEnabled(enabled: boolean): void { - this.storageService.store(AQUARIUM_ENABLED_STORAGE_KEY, enabled, StorageScope.APPLICATION, StorageTarget.USER); + this.storageService.store(ENABLED_STORAGE_KEY, enabled, StorageScope.APPLICATION, StorageTarget.USER); } private applyFeatureEnabledState(): void { @@ -188,8 +190,13 @@ export class AquariumService extends Disposable implements IAquariumService { iconSpan.classList.add('agents-aquarium-toggle-logo'); } button.appendChild(iconSpan); + const label = this.getToggleLabel(active); button.setAttribute('aria-pressed', String(active)); - button.setAttribute('aria-label', active ? localize('aquarium.hide', "Hide Aquarium") : localize('aquarium.show', "Show Aquarium")); + button.setAttribute('aria-label', label); + } + + private getToggleLabel(active: boolean): string { + return active ? localize('aquarium.hide', "Hide Aquarium") : localize('aquarium.show', "Show Aquarium"); } private toggle(): void { @@ -213,15 +220,19 @@ export class AquariumService extends Disposable implements IAquariumService { } // Cancel any in-flight exit so its delayed dispose can't tear down // the new aquarium's shared SVG defs. - this.pendingExit?.dispose(); - this.pendingExit = undefined; - let active: IActiveAquarium; + this.pendingExit.clear(); + let active: IActiveAquarium | undefined; try { - active = createActiveAquarium(this.mainContainer, this.layoutService); + active = createActiveAquarium(this.mainContainer, this.layoutService, this.accessibilityService); } catch (e) { console.error('[aquarium] failed to activate', e); return; } + // No host (e.g. chat bar isn't visible yet) — leave the toggle + // untouched and don't persist; a later toggle attempt will retry. + if (!active) { + return; + } this.activeRef.value = active; this.activeContextKey.set(true); this.updateAllToggleButtonsVisual(true); @@ -232,20 +243,23 @@ export class AquariumService extends Disposable implements IAquariumService { /** @param persist false when tearing down for non-user reasons. */ private deactivate(persist: boolean): void { + // Detach from activeRef WITHOUT disposing (clearAndLeak) so the exit + // animation can run; the returned handle from active.exit() is parked + // in `pendingExit` and disposes the underlying store either when the + // animation completes, when the service tears down, or when a rapid + // re-activate replaces it. const active = this.activeRef.clearAndLeak(); if (!active) { return; } - // Re-register so the orphaned aquarium is still disposed if the - // service itself is torn down mid-exit. - this._register(active); this.activeContextKey.set(false); this.updateAllToggleButtonsVisual(false); - // Stash the pending exit so a rapid re-activate can cancel it. - this.pendingExit?.dispose(); - const pending = active.exit(); - this.pendingExit = pending; - this._register(pending); + const pending = active.exit(() => { + if (this.pendingExit.value === pending) { + this.pendingExit.clear(); + } + }); + this.pendingExit.value = pending; if (persist) { this.setStoredEnabled(false); } @@ -257,27 +271,30 @@ interface IActiveAquarium extends IDisposable { * Trigger the exit animation and dispose when it completes. Disposing the * returned handle before the animation finishes disposes immediately. */ - exit(): IDisposable; + exit(onDidComplete: () => void): IDisposable; } -/** Build the live aquarium: water, fish, food, mouse handling, RAF loop. */ -function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbenchLayoutService): IActiveAquarium { - const store = new DisposableStore(); +/** + * Build the live aquarium: water, fish, food, mouse handling, RAF loop. + * Returns `undefined` if the chat bar isn't available so callers can bail + * without leaving the toggle button stuck in an "active but invisible" state. + */ +function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbenchLayoutService, accessibilityService: IAccessibilityService): IActiveAquarium | undefined { const targetWindow = getWindow(mainContainer); // Host inside the chat bar so chat input UI naturally paints on top — // no z-index gymnastics required. const chatBar = layoutService.getContainer(targetWindow, Parts.CHATBAR_PART); if (!chatBar || !layoutService.isVisible(Parts.CHATBAR_PART, targetWindow)) { - return { - dispose: () => store.dispose(), - exit: () => { store.dispose(); return store; }, - }; + return undefined; } + const store = new DisposableStore(); const doc = targetWindow.document; const water = doc.createElement('div'); water.className = 'agents-aquarium-water'; + // Decorative: hide the entire subtree from a11y tree. + water.setAttribute('aria-hidden', 'true'); // First child so subsequent chat bar content paints over it. chatBar.insertBefore(water, chatBar.firstChild); store.add(toDisposable(() => water.remove())); @@ -336,8 +353,13 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe firstBatch.appendChild(fish[i].element); } fishLayer.appendChild(firstBatch); + let exiting = false; + if (SYNC_BATCH < fish.length) { const deferred = scheduleAtNextAnimationFrame(targetWindow, () => { + if (exiting) { + return; + } const restBatch = targetWindow.document.createDocumentFragment(); for (let i = SYNC_BATCH; i < fish.length; i++) { restBatch.appendChild(fish[i].element); @@ -346,6 +368,9 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe // Add `.visible` on the NEXT frame so a paint at opacity:0 happens // first — guarantees the CSS transition fires. const fadeIn = scheduleAtNextAnimationFrame(targetWindow, () => { + if (exiting) { + return; + } for (let i = SYNC_BATCH; i < fish.length; i++) { const localIndex = i - SYNC_BATCH; const delay = Math.min(localIndex * 12, 400); @@ -376,8 +401,14 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe // Listen on the main container so we always know cursor position even // when over the chat input (water has pointer-events:none). - store.add(addDisposableListener(targetWindow, EventType.RESIZE, updateBounds, { passive: true })); - store.add(addDisposableListener(targetWindow, 'scroll', updateBounds, { passive: true, capture: true })); + // + // Coalesce updateBounds() across scroll/resize storms: scroll with capture + // fires for ANY descendant scroll, and updateBounds() reads layout. Mark + // dirty here and let the RAF tick refresh at most once per frame. + let boundsDirty = false; + const markBoundsDirty = () => { boundsDirty = true; }; + store.add(addDisposableListener(targetWindow, EventType.RESIZE, markBoundsDirty, { passive: true })); + store.add(addDisposableListener(targetWindow, 'scroll', markBoundsDirty, { passive: true, capture: true })); let mouseX = -1e6; let mouseY = -1e6; @@ -426,24 +457,42 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe food.push({ element: el, x: dropX, y: dropY, vy: randomBetween(20, 35) }); } - const reduceMotion = targetWindow.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false; let lastFrame = performance.now(); let rafDisposable: IDisposable | undefined; + const stopAnimation = () => { + rafDisposable?.dispose(); + rafDisposable = undefined; + }; + const startAnimation = () => { + if (rafDisposable || accessibilityService.isMotionReduced()) { + return; + } + lastFrame = performance.now(); + rafDisposable = scheduleAtNextAnimationFrame(targetWindow, tick); + }; + const tick = () => { + rafDisposable = undefined; const now = performance.now(); const dtMs = Math.min(now - lastFrame, 100); // clamp big stalls const dt = dtMs / 1000; lastFrame = now; + if (boundsDirty) { + boundsDirty = false; + updateBounds(); + } + // Skip work when window is hidden (RAF stays alive lazily). - const visible = targetWindow.document.visibilityState !== 'hidden'; - if (visible && !reduceMotion) { + if (!accessibilityService.isMotionReduced() && targetWindow.document.visibilityState !== 'hidden') { updateFood(dt); updateFish(dt); } - rafDisposable = scheduleAtNextAnimationFrame(targetWindow, tick); + if (!accessibilityService.isMotionReduced()) { + rafDisposable = scheduleAtNextAnimationFrame(targetWindow, tick); + } }; function updateFood(dt: number): void { @@ -557,11 +606,21 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe } } - rafDisposable = scheduleAtNextAnimationFrame(targetWindow, tick); - store.add(toDisposable(() => rafDisposable?.dispose())); + store.add(accessibilityService.onDidChangeReducedMotion(() => { + if (accessibilityService.isMotionReduced()) { + stopAnimation(); + } else { + startAnimation(); + } + })); + store.add(toDisposable(() => stopAnimation())); + startAnimation(); // First-batch fade-in (the deferred batch fades in when it mounts). - scheduleAtNextAnimationFrame(targetWindow, () => { + const fadeIn = scheduleAtNextAnimationFrame(targetWindow, () => { + if (exiting) { + return; + } water.classList.add('visible'); for (let i = 0; i < Math.min(SYNC_BATCH, fish.length); i++) { const f = fish[i]; @@ -571,14 +630,18 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe f.element.classList.add('visible'); } }); + store.add(fadeIn); - let exiting = false; + const result = new class extends Disposable implements IActiveAquarium { + + constructor() { + super(); + this._register(store); + } - const result: IActiveAquarium = { - dispose: () => store.dispose(), - exit: () => { + exit(onDidComplete: () => void): IDisposable { if (exiting) { - return store; + return toDisposable(() => this.dispose()); } exiting = true; @@ -592,16 +655,17 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe let timer: ReturnType | undefined = setTimeout(() => { timer = undefined; - store.dispose(); + this.dispose(); + onDidComplete(); }, EXIT_DURATION_MS); return toDisposable(() => { if (timer !== undefined) { clearTimeout(timer); timer = undefined; - store.dispose(); } + this.dispose(); }); - }, + } }; return result; diff --git a/src/vs/sessions/contrib/aquarium/browser/fish.ts b/src/vs/sessions/contrib/aquarium/browser/fish.ts index fd08ff6557fef..5c67dcbc94fd5 100644 --- a/src/vs/sessions/contrib/aquarium/browser/fish.ts +++ b/src/vs/sessions/contrib/aquarium/browser/fish.ts @@ -238,6 +238,7 @@ function buildFishSvg(targetDocument: Document): SVGSVGElement { const svg = targetDocument.createElementNS(SVG_NS, 'svg'); svg.setAttribute('xmlns', SVG_NS); + svg.setAttribute('focusable', 'false'); // viewBox 0..96 matches the original VS Code icon. svg.setAttribute('viewBox', '0 0 96 96'); svg.setAttribute('preserveAspectRatio', 'xMidYMid meet'); diff --git a/src/vs/sessions/contrib/aquarium/browser/media/aquarium.css b/src/vs/sessions/contrib/aquarium/browser/media/aquarium.css index d371c5f0e6e13..1428f361ed75e 100644 --- a/src/vs/sessions/contrib/aquarium/browser/media/aquarium.css +++ b/src/vs/sessions/contrib/aquarium/browser/media/aquarium.css @@ -223,7 +223,7 @@ .agents-aquarium-toggle.active { opacity: 1; - color: #ff6b88; + color: var(--vscode-icon-foreground, var(--vscode-foreground, #cccccc)); } .agents-aquarium-toggle .codicon { font-size: 14px; From ac6ca952f19da72ef16be6996e1d1ebd8159cc2d Mon Sep 17 00:00:00 2001 From: justschen Date: Wed, 29 Apr 2026 22:51:35 -0700 Subject: [PATCH 7/8] more readability stuff --- .../aquarium/browser/aquariumOverlay.ts | 66 +++++++++---------- .../sessions/contrib/aquarium/browser/fish.ts | 60 ++++++++--------- 2 files changed, 63 insertions(+), 63 deletions(-) diff --git a/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts b/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts index ab079d36a0e82..61865920e664b 100644 --- a/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts +++ b/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts @@ -46,9 +46,9 @@ const ENABLED_STORAGE_KEY = 'sessions.developerJoy.enabled'; interface IFoodPellet { readonly element: HTMLDivElement; - x: number; - y: number; - vy: number; + positionX: number; + positionY: number; + fallSpeed: number; } /** @@ -313,9 +313,9 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe const updateBounds = () => { bounds.width = water.clientWidth; bounds.height = water.clientHeight; - const r = water.getBoundingClientRect(); - waterScreenOffset.left = r.left; - waterScreenOffset.top = r.top; + const rect = water.getBoundingClientRect(); + waterScreenOffset.left = rect.left; + waterScreenOffset.top = rect.top; }; const fish: Fish[] = []; @@ -324,8 +324,8 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe const resizeObserver = new ResizeObserver(() => { updateBounds(); for (const f of fish) { - f.x = Math.min(f.x, Math.max(0, bounds.width - f.size)); - f.y = Math.min(f.y, Math.max(0, bounds.height - f.size)); + f.positionX = Math.min(f.positionX, Math.max(0, bounds.width - f.size)); + f.positionY = Math.min(f.positionY, Math.max(0, bounds.height - f.size)); } }); resizeObserver.observe(water); @@ -338,10 +338,10 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe const f = new Fish({ species: pickRandomSpecies(), size, - x: randomBetween(0, Math.max(1, bounds.width - size)), - y: randomBetween(0, Math.max(1, bounds.height - size)), - vx: Math.cos(angle) * speed, - vy: Math.sin(angle) * speed, + positionX: randomBetween(0, Math.max(1, bounds.width - size)), + positionY: randomBetween(0, Math.max(1, bounds.height - size)), + velocityX: Math.cos(angle) * speed, + velocityY: Math.sin(angle) * speed, }, targetWindow.document); fish.push(f); } @@ -454,7 +454,7 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe el.className = 'agents-aquarium-food'; el.style.transform = `translate(${dropX}px, ${dropY}px)`; foodLayer.appendChild(el); - food.push({ element: el, x: dropX, y: dropY, vy: randomBetween(20, 35) }); + food.push({ element: el, positionX: dropX, positionY: dropY, fallSpeed: randomBetween(20, 35) }); } let lastFrame = performance.now(); @@ -498,9 +498,9 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe function updateFood(dt: number): void { for (let i = food.length - 1; i >= 0; i--) { const pellet = food[i]; - pellet.y += pellet.vy * dt; - pellet.element.style.transform = `translate(${pellet.x.toFixed(1)}px, ${pellet.y.toFixed(1)}px)`; - if (pellet.y > bounds.height + 10) { + pellet.positionY += pellet.fallSpeed * dt; + pellet.element.style.transform = `translate(${pellet.positionX.toFixed(1)}px, ${pellet.positionY.toFixed(1)}px)`; + if (pellet.positionY > bounds.height + 10) { removeFood(pellet); } } @@ -509,8 +509,8 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe function updateFish(dt: number): void { const now = performance.now(); for (const f of fish) { - const centerX = f.x + f.size / 2; - const centerY = f.y + f.size / 2; + const centerX = f.positionX + f.size / 2; + const centerY = f.positionY + f.size / 2; // Wall steering: turn the heading (not just acceleration) away from // walls, otherwise fish park against the edge with their thrust @@ -533,8 +533,8 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe // Spontaneous dart with brief panic so it can exceed normal max speed. if (Math.random() < DART_RATE_PER_SECOND * dt) { const dartAngle = Math.random() * Math.PI * 2; - f.vx += Math.cos(dartAngle) * DART_IMPULSE; - f.vy += Math.sin(dartAngle) * DART_IMPULSE; + f.velocityX += Math.cos(dartAngle) * DART_IMPULSE; + f.velocityY += Math.sin(dartAngle) * DART_IMPULSE; f.panicUntil = now + PANIC_DURATION_MS; } @@ -566,8 +566,8 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe let nearestPellet: IFoodPellet | undefined; let nearestDistSq = FOOD_DETECT_RADIUS * FOOD_DETECT_RADIUS; for (const pellet of food) { - const foodDeltaX = pellet.x - centerX; - const foodDeltaY = pellet.y - centerY; + const foodDeltaX = pellet.positionX - centerX; + const foodDeltaY = pellet.positionY - centerY; const distSq = foodDeltaX * foodDeltaX + foodDeltaY * foodDeltaY; if (distSq < nearestDistSq) { nearestDistSq = distSq; @@ -579,28 +579,28 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe if (nearestDist < EAT_RADIUS) { removeFood(nearestPellet); } else { - accelX += (nearestPellet.x - centerX) / nearestDist * 200; - accelY += (nearestPellet.y - centerY) / nearestDist * 200; + accelX += (nearestPellet.positionX - centerX) / nearestDist * 200; + accelY += (nearestPellet.positionY - centerY) / nearestDist * 200; } } - f.vx += accelX * dt; - f.vy += accelY * dt; + f.velocityX += accelX * dt; + f.velocityY += accelY * dt; - const speedSq = f.vx * f.vx + f.vy * f.vy; + const speedSq = f.velocityX * f.velocityX + f.velocityY * f.velocityY; const maxSpeed = now < f.panicUntil ? PANIC_MAX_SPEED : MAX_SPEED; if (speedSq > maxSpeed * maxSpeed) { const speed = Math.sqrt(speedSq); - f.vx = (f.vx / speed) * maxSpeed; - f.vy = (f.vy / speed) * maxSpeed; + f.velocityX = (f.velocityX / speed) * maxSpeed; + f.velocityY = (f.velocityY / speed) * maxSpeed; } - f.x += f.vx * dt; - f.y += f.vy * dt; + f.positionX += f.velocityX * dt; + f.positionY += f.velocityY * dt; // Hard clamp safety net. - f.x = clamp(f.x, -f.size * 0.25, bounds.width - f.size * 0.75); - f.y = clamp(f.y, -f.size * 0.25, bounds.height - f.size * 0.75); + f.positionX = clamp(f.positionX, -f.size * 0.25, bounds.width - f.size * 0.75); + f.positionY = clamp(f.positionY, -f.size * 0.25, bounds.height - f.size * 0.75); f.applyTransform(dt); } diff --git a/src/vs/sessions/contrib/aquarium/browser/fish.ts b/src/vs/sessions/contrib/aquarium/browser/fish.ts index 5c67dcbc94fd5..df536cbce8360 100644 --- a/src/vs/sessions/contrib/aquarium/browser/fish.ts +++ b/src/vs/sessions/contrib/aquarium/browser/fish.ts @@ -27,11 +27,11 @@ const SPECIES_COLOR: Record = { /** Pick a random species, weighted Stable > Insiders > Exploration. */ export function pickRandomSpecies(): FishSpecies { - const r = Math.random(); - if (r < 0.5) { + const roll = Math.random(); + if (roll < 0.5) { return FishSpecies.Stable; } - if (r < 0.8) { + if (roll < 0.8) { return FishSpecies.Insiders; } return FishSpecies.Exploration; @@ -52,10 +52,10 @@ export function disposeSharedFishDefs(targetDocument: Document): void { export interface IFishOptions { readonly species: FishSpecies; readonly size: number; - readonly x: number; - readonly y: number; - readonly vx: number; - readonly vy: number; + readonly positionX: number; + readonly positionY: number; + readonly velocityX: number; + readonly velocityY: number; } /** @@ -67,10 +67,10 @@ export class Fish { readonly element: HTMLDivElement; private readonly innerElement: HTMLDivElement; - x: number; - y: number; - vx: number; - vy: number; + positionX: number; + positionY: number; + velocityX: number; + velocityY: number; readonly size: number; /** Timestamp until which this fish is in "panic" mode (faster, scattering). */ @@ -85,18 +85,18 @@ export class Fish { /** * Smoothed facing in [-1, 1] (1 = right, -1 = left). Eased toward - * sign(vx) each frame so direction changes look like a turn instead of + * sign(velocityX) each frame so direction changes look like a turn instead of * a snap-flip. */ private facing = 1; constructor(opts: IFishOptions, targetDocument: Document) { - this.x = opts.x; - this.y = opts.y; - this.vx = opts.vx; - this.vy = opts.vy; + this.positionX = opts.positionX; + this.positionY = opts.positionY; + this.velocityX = opts.velocityX; + this.velocityY = opts.velocityY; this.size = opts.size; - this.wanderAngle = Math.atan2(opts.vy, opts.vx); + this.wanderAngle = Math.atan2(opts.velocityY, opts.velocityX); this.element = targetDocument.createElement('div'); this.element.className = 'agents-aquarium-fish'; @@ -117,28 +117,28 @@ export class Fish { /** * Write the current position/facing to the DOM. * - * @param dt seconds since last frame, used to ease facing toward velocity - * direction. Pass 0 for the initial paint. + * @param deltaSeconds seconds since last frame, used to ease facing toward + * velocity direction. Pass 0 for the initial paint. */ - applyTransform(dt: number = 0): void { + applyTransform(deltaSeconds: number = 0): void { // Translate is on the outer element. Sub-pixel precision (2 decimals) // avoids visible 0.1 px stepping when fish move slowly. - this.element.style.transform = `translate(${this.x.toFixed(2)}px, ${this.y.toFixed(2)}px)`; + this.element.style.transform = `translate(${this.positionX.toFixed(2)}px, ${this.positionY.toFixed(2)}px)`; - // Ease `facing` toward sign(vx) so the flip looks like a turn instead - // of an instant mirror. Time-constant ~120 ms (turnRate = 8/s). - const target = this.vx >= 0 ? 1 : -1; - if (dt > 0) { + // Ease `facing` toward sign(velocityX) so the flip looks like a turn + // instead of an instant mirror. Time-constant ~120 ms (turnRate = 8/s). + const targetFacing = this.velocityX >= 0 ? 1 : -1; + if (deltaSeconds > 0) { const turnRate = 8; - const k = 1 - Math.exp(-turnRate * dt); - this.facing += (target - this.facing) * k; + const easeFactor = 1 - Math.exp(-turnRate * deltaSeconds); + this.facing += (targetFacing - this.facing) * easeFactor; } else { - this.facing = target; + this.facing = targetFacing; } // scaleX through 0 in the middle of a turn flattens the fish for one // frame, mimicking a body roll. Floor at 0.05 to avoid zero-width. - const scaleX = Math.sign(this.facing) * Math.max(Math.abs(this.facing), 0.05); - this.innerElement.style.transform = `scaleX(${scaleX.toFixed(3)})`; + const flipScaleX = Math.sign(this.facing) * Math.max(Math.abs(this.facing), 0.05); + this.innerElement.style.transform = `scaleX(${flipScaleX.toFixed(3)})`; } } From 89425e65c1ae30b064ab6eaa55720de07c5a13e1 Mon Sep 17 00:00:00 2001 From: justschen Date: Wed, 29 Apr 2026 23:06:46 -0700 Subject: [PATCH 8/8] better comments, addresssome feedback --- .../aquarium/browser/aquariumOverlay.ts | 22 ++++++++-- .../sessions/contrib/aquarium/browser/fish.ts | 44 ++++++++++--------- .../aquarium/browser/media/aquarium.css | 4 +- .../aquarium/browser/vscodeLogoPath.ts | 13 ++++++ 4 files changed, 56 insertions(+), 27 deletions(-) create mode 100644 src/vs/sessions/contrib/aquarium/browser/vscodeLogoPath.ts diff --git a/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts b/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts index 61865920e664b..ea4208c18e417 100644 --- a/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts +++ b/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts @@ -26,18 +26,25 @@ const FISH_MIN_SIZE = 22; const FISH_MAX_SIZE = 48; const SCATTER_RADIUS = 145; +const SCATTER_RADIUS_SQ = SCATTER_RADIUS * SCATTER_RADIUS; const EAT_RADIUS = 14; const FOOD_DETECT_RADIUS = 160; +const FOOD_DETECT_RADIUS_SQ = FOOD_DETECT_RADIUS * FOOD_DETECT_RADIUS; const MAX_FOOD = 12; /** Soft margin where fish start to turn back. */ const WALL_MARGIN = 36; const BASE_SPEED = 24; const MAX_SPEED = 50; +const MAX_SPEED_SQ = MAX_SPEED * MAX_SPEED; const PANIC_MAX_SPEED = 240; +const PANIC_MAX_SPEED_SQ = PANIC_MAX_SPEED * PANIC_MAX_SPEED; const PANIC_DURATION_MS = 600; const EXIT_DURATION_MS = 900; +/** Decorative effect: 30Hz keeps motion smooth enough while halving JS work. */ +const ACTIVE_FRAME_INTERVAL_MS = 1000 / 30; + /** Per-fish per-second probability of starting a spontaneous burst. */ const DART_RATE_PER_SECOND = 0.04; const DART_IMPULSE = 150; @@ -475,7 +482,13 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe const tick = () => { rafDisposable = undefined; const now = performance.now(); - const dtMs = Math.min(now - lastFrame, 100); // clamp big stalls + const elapsedMs = now - lastFrame; + if (elapsedMs < ACTIVE_FRAME_INTERVAL_MS) { + rafDisposable = scheduleAtNextAnimationFrame(targetWindow, tick); + return; + } + + const dtMs = Math.min(elapsedMs, 100); // clamp big stalls const dt = dtMs / 1000; lastFrame = now; @@ -554,7 +567,7 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe const mouseDeltaX = centerX - mouseX; const mouseDeltaY = centerY - mouseY; const mouseDistSq = mouseDeltaX * mouseDeltaX + mouseDeltaY * mouseDeltaY; - if (mouseDistSq < SCATTER_RADIUS * SCATTER_RADIUS) { + if (mouseDistSq < SCATTER_RADIUS_SQ) { const mouseDist = Math.max(Math.sqrt(mouseDistSq), 1); const force = (1 - mouseDist / SCATTER_RADIUS) * 1100; accelX += (mouseDeltaX / mouseDist) * force; @@ -564,7 +577,7 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe // Seek nearest food within FOOD_DETECT_RADIUS let nearestPellet: IFoodPellet | undefined; - let nearestDistSq = FOOD_DETECT_RADIUS * FOOD_DETECT_RADIUS; + let nearestDistSq = FOOD_DETECT_RADIUS_SQ; for (const pellet of food) { const foodDeltaX = pellet.positionX - centerX; const foodDeltaY = pellet.positionY - centerY; @@ -589,7 +602,8 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe const speedSq = f.velocityX * f.velocityX + f.velocityY * f.velocityY; const maxSpeed = now < f.panicUntil ? PANIC_MAX_SPEED : MAX_SPEED; - if (speedSq > maxSpeed * maxSpeed) { + const maxSpeedSq = now < f.panicUntil ? PANIC_MAX_SPEED_SQ : MAX_SPEED_SQ; + if (speedSq > maxSpeedSq) { const speed = Math.sqrt(speedSq); f.velocityX = (f.velocityX / speed) * maxSpeed; f.velocityY = (f.velocityY / speed) * maxSpeed; diff --git a/src/vs/sessions/contrib/aquarium/browser/fish.ts b/src/vs/sessions/contrib/aquarium/browser/fish.ts index df536cbce8360..d1389749f0ac6 100644 --- a/src/vs/sessions/contrib/aquarium/browser/fish.ts +++ b/src/vs/sessions/contrib/aquarium/browser/fish.ts @@ -3,15 +3,14 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { VSCODE_LOGO_PATH } from './vscodeLogoPath.js'; + /** * VS Code logo "fish" used by the Agents window aquarium. Each fish is a small * SVG element styled with `color:` so the silhouette inherits via `currentColor`, * with animated body strips providing the swimming motion. */ -/** VS Code logo silhouette path (extracted from sessions/contrib/chat/browser/media/vscode-icon.svg). */ -const VSCODE_LOGO_PATH = 'M65.566 89.4264C66.889 89.9418 68.3976 89.9087 69.7329 89.2662L87.0271 80.9446C88.8444 80.0701 90 78.231 90 76.2132V19.7872C90 17.7695 88.8444 15.9303 87.0271 15.0559L69.7329 6.73395C67.9804 5.89069 65.9295 6.09724 64.3914 7.21543C64.1716 7.37517 63.9624 7.55352 63.7659 7.75007L30.6583 37.9548L16.2372 27.0081C14.8948 25.9891 13.0171 26.0726 11.7702 27.2067L7.14495 31.4141C5.61986 32.8014 5.61811 35.2007 7.14117 36.5902L19.6476 48.0001L7.14117 59.4099C5.61811 60.7995 5.61986 63.1988 7.14495 64.5861L11.7702 68.7934C13.0171 69.9276 14.8948 70.0111 16.2372 68.9921L30.6583 58.0453L63.7659 88.2501C64.2897 88.7741 64.9046 89.1688 65.566 89.4264ZM69.0128 28.9311L43.8917 48.0001L69.0128 67.069V28.9311Z'; - /** The three VS Code release channel colors used as fish "species". */ export const enum FishSpecies { Stable = 'stable', @@ -118,7 +117,7 @@ export class Fish { * Write the current position/facing to the DOM. * * @param deltaSeconds seconds since last frame, used to ease facing toward - * velocity direction. Pass 0 for the initial paint. + * velocity direction. Pass 0 for the initial paint. */ applyTransform(deltaSeconds: number = 0): void { // Translate is on the outer element. Sub-pixel precision (2 decimals) @@ -146,11 +145,9 @@ const SVG_NS = 'http://www.w3.org/2000/svg'; /** * Number of vertical strips the body is sliced into. More strips = smoother - * wave (smaller per-strip phase delta), fewer visible seams. Kept moderate - * because each strip = one path + one CSS animation per fish; with 50 fish - * this contributes meaningfully to layer/animation work. + * wave, but each strip is one `` node and one CSS animation per fish. */ -const NUM_BODY_STRIPS = 10; +const NUM_BODY_STRIPS = 8; /** The body's bounding range in the original logo's user units. */ const BODY_X_START = 5; @@ -188,19 +185,9 @@ function ensureSharedDefs(targetDocument: Document): void { container.style.overflow = 'hidden'; container.style.pointerEvents = 'none'; - // One `` containing the VS Code logo path. All strips reference - // this via ``, so the path data - // is parsed exactly ONCE per session instead of FISH_COUNT * NUM_STRIPS. - const symbol = targetDocument.createElementNS(SVG_NS, 'symbol'); - symbol.setAttribute('id', SHARED_LOGO_SYMBOL_ID); - symbol.setAttribute('viewBox', '0 0 96 96'); - symbol.setAttribute('overflow', 'visible'); - const logoPath = targetDocument.createElementNS(SVG_NS, 'path'); - logoPath.setAttribute('d', VSCODE_LOGO_PATH); - logoPath.setAttribute('fill', 'currentColor'); - logoPath.setAttribute('fill-rule', 'evenodd'); - symbol.appendChild(logoPath); - container.appendChild(symbol); + // All strips reference this symbol via ``, + // so the path data is parsed exactly ONCE per session instead of FISH_COUNT * NUM_STRIPS. + container.appendChild(createVSCodeLogoSymbol(targetDocument)); const defs = targetDocument.createElementNS(SVG_NS, 'defs'); for (let i = 0; i < NUM_BODY_STRIPS; i++) { @@ -222,6 +209,21 @@ function ensureSharedDefs(targetDocument: Document): void { sharedDefsByDocument.set(targetDocument, container); } +function createVSCodeLogoSymbol(targetDocument: Document): SVGSymbolElement { + const symbol = targetDocument.createElementNS(SVG_NS, 'symbol'); + symbol.setAttribute('id', SHARED_LOGO_SYMBOL_ID); + symbol.setAttribute('viewBox', '0 0 96 96'); + symbol.setAttribute('overflow', 'visible'); + + const logoPath = targetDocument.createElementNS(SVG_NS, 'path'); + logoPath.setAttribute('d', VSCODE_LOGO_PATH); + logoPath.setAttribute('fill', 'currentColor'); + logoPath.setAttribute('fill-rule', 'evenodd'); + symbol.appendChild(logoPath); + + return symbol; +} + /** * Build the inline SVG element tree for a fish: * - VS Code logo body, sliced into N vertical strips that each oscillate in diff --git a/src/vs/sessions/contrib/aquarium/browser/media/aquarium.css b/src/vs/sessions/contrib/aquarium/browser/media/aquarium.css index 1428f361ed75e..80183b514f912 100644 --- a/src/vs/sessions/contrib/aquarium/browser/media/aquarium.css +++ b/src/vs/sessions/contrib/aquarium/browser/media/aquarium.css @@ -139,12 +139,12 @@ /* Each strip is a clipped vertical slice of the VS Code logo; staggered * translateY produces a sine wave traveling along the body (the swim). * Front strips (--agents-aquarium-strip-index ~ 0) keep the species color; - * back strips mix toward white for depth shading. 720ms / 10 strips approximately 72ms + * back strips mix toward white for depth shading. 720ms / 8 strips = 90ms * phase delay; negative delay starts each strip mid-cycle. */ .agents-aquarium-fish-strip { color: color-mix(in srgb, currentColor, white calc(var(--agents-aquarium-strip-index, 0) * 4%)); animation: agents-aquarium-body-wave 720ms linear infinite; - animation-delay: calc(var(--agents-aquarium-strip-index, 0) * -72ms); + animation-delay: calc(var(--agents-aquarium-strip-index, 0) * -90ms); } /* 5-keyframe sine approximation; linear timing lands closer to a true diff --git a/src/vs/sessions/contrib/aquarium/browser/vscodeLogoPath.ts b/src/vs/sessions/contrib/aquarium/browser/vscodeLogoPath.ts new file mode 100644 index 0000000000000..5a9a8562b5140 --- /dev/null +++ b/src/vs/sessions/contrib/aquarium/browser/vscodeLogoPath.ts @@ -0,0 +1,13 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// VS Code logo silhouette path, extracted from sessions/contrib/chat/browser/media/vscode-icon.svg. +// The aquarium cannot use that SVG file directly because each fish renders the +// logo as live, same-document SVG geometry: fish.ts stores this path in a +// shared , then renders clipped slices with staggered CSS +// animations. That keeps the swimming-strip effect, currentColor species +// tinting, and auxiliary-window support while avoiding duplicate path parsing +// per fish. +export const VSCODE_LOGO_PATH = 'M65.566 89.4264C66.889 89.9418 68.3976 89.9087 69.7329 89.2662L87.0271 80.9446C88.8444 80.0701 90 78.231 90 76.2132V19.7872C90 17.7695 88.8444 15.9303 87.0271 15.0559L69.7329 6.73395C67.9804 5.89069 65.9295 6.09724 64.3914 7.21543C64.1716 7.37517 63.9624 7.55352 63.7659 7.75007L30.6583 37.9548L16.2372 27.0081C14.8948 25.9891 13.0171 26.0726 11.7702 27.2067L7.14495 31.4141C5.61986 32.8014 5.61811 35.2007 7.14117 36.5902L19.6476 48.0001L7.14117 59.4099C5.61811 60.7995 5.61986 63.1988 7.14495 64.5861L11.7702 68.7934C13.0171 69.9276 14.8948 70.0111 16.2372 68.9921L30.6583 58.0453L63.7659 88.2501C64.2897 88.7741 64.9046 89.1688 65.566 89.4264ZM69.0128 28.9311L43.8917 48.0001L69.0128 67.069V28.9311Z'; \ No newline at end of file