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 2cd44d10d6628..05260f8b4bc7b 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -959,6 +959,7 @@ "--notebook-editor-font-size", "--notebook-editor-font-weight", "--outline-element-color", + "--agents-aquarium-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..22bbff401e809 --- /dev/null +++ b/src/vs/sessions/contrib/aquarium/browser/aquarium.contribution.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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'; + +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'], + }, + }, +}); + +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 new file mode 100644 index 0000000000000..ea4208c18e417 --- /dev/null +++ b/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts @@ -0,0 +1,745 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +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'; +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'; +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 { 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; + +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; + +const ENABLED_STORAGE_KEY = 'sessions.developerJoy.enabled'; + +interface IFoodPellet { + readonly element: HTMLDivElement; + positionX: number; + positionY: number; + fallSpeed: number; +} + +/** + * 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 const IAquariumService = createDecorator('aquariumService'); + +export interface IAquariumService { + readonly _serviceBrand: undefined; + + /** + * 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; +} + +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 pendingExit = this._register(new MutableDisposable()); + private readonly activeContextKey: IContextKey; + + constructor( + @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + @IContextKeyService contextKeyService: IContextKeyService, + @IHoverService private readonly hoverService: IHoverService, + @IStorageService private readonly storageService: IStorageService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IAccessibilityService private readonly accessibilityService: IAccessibilityService, + ) { + super(); + + this.mainContainer = layoutService.mainContainer; + this.activeContextKey = SessionsAquariumActiveContext.bindTo(contextKeyService); + + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(SESSIONS_DEVELOPER_JOY_ENABLED_SETTING)) { + this.applyFeatureEnabledState(); + } + })); + } + + 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.getToggleLabel(!!this.activeRef.value), + )); + + 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 { + return this.storageService.getBoolean(ENABLED_STORAGE_KEY, StorageScope.APPLICATION, false); + } + + private setStoredEnabled(enabled: boolean): void { + this.storageService.store(ENABLED_STORAGE_KEY, enabled, StorageScope.APPLICATION, StorageTarget.USER); + } + + private applyFeatureEnabledState(): void { + for (const mount of this.mounts) { + this.applyFeatureEnabledStateForButton(mount.button); + } + 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); + } + } + + 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 Trusted Types. + button.replaceChildren(); + const iconSpan = button.ownerDocument.createElement('span'); + 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); + const label = this.getToggleLabel(active); + button.setAttribute('aria-pressed', String(active)); + 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 { + if (this.activeRef.value) { + this.deactivate(/* persist */ true); + } else { + this.activate(/* persist */ true); + } + } + + 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 so its delayed dispose can't tear down + // the new aquarium's shared SVG defs. + this.pendingExit.clear(); + let active: IActiveAquarium | undefined; + try { + 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); + if (persist) { + this.setStoredEnabled(true); + } + } + + /** @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; + } + this.activeContextKey.set(false); + this.updateAllToggleButtonsVisual(false); + const pending = active.exit(() => { + if (this.pendingExit.value === pending) { + this.pendingExit.clear(); + } + }); + this.pendingExit.value = pending; + if (persist) { + this.setStoredEnabled(false); + } + } +} + +interface IActiveAquarium extends IDisposable { + /** + * Trigger the exit animation and dispose when it completes. Disposing the + * returned handle before the animation finishes disposes immediately. + */ + exit(onDidComplete: () => void): IDisposable; +} + +/** + * 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 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())); + + const fishLayer = doc.createElement('div'); + fishLayer.className = 'agents-aquarium-fish-layer'; + water.appendChild(fishLayer); + + const foodLayer = doc.createElement('div'); + foodLayer.className = 'agents-aquarium-food-layer'; + water.appendChild(foodLayer); + + const bounds = { width: 0, height: 0 }; + // Cached so the per-mousemove handler doesn't trigger a layout flush. + const waterScreenOffset = { left: 0, top: 0 }; + const updateBounds = () => { + bounds.width = water.clientWidth; + bounds.height = water.clientHeight; + const rect = water.getBoundingClientRect(); + waterScreenOffset.left = rect.left; + waterScreenOffset.top = rect.top; + }; + + const fish: Fish[] = []; + + updateBounds(); + const resizeObserver = new ResizeObserver(() => { + updateBounds(); + for (const f of fish) { + 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); + 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, + 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); + } + // 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++) { + 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); + } + fishLayer.appendChild(restBatch); + // 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); + 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) { + f.element.remove(); + } + // Tear down shared SVG defs so we don't leak across reloads. + disposeSharedFishDefs(targetWindow.document); + })); + + const food: IFoodPellet[] = []; + const removeFood = (pellet: IFoodPellet) => { + const idx = food.indexOf(pellet); + if (idx !== -1) { + food.splice(idx, 1); + pellet.element.remove(); + } + }; + + // Listen on the main container so we always know cursor position even + // when over the chat input (water has pointer-events:none). + // + // 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; + const resetMousePosition = () => { + mouseX = -1e6; + mouseY = -1e6; + }; + // 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; + })); + // 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 })); + + store.add(addDisposableGenericMouseDownListener(mainContainer, (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 dropX = e.clientX - waterScreenOffset.left; + const dropY = e.clientY - waterScreenOffset.top; + if (dropX < 0 || dropY < 0 || dropX > bounds.width || dropY > bounds.height) { + return; + } + spawnFood(dropX, dropY); + })); + + 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]; + removeFood(oldest); + } + const el = doc.createElement('div'); + el.className = 'agents-aquarium-food'; + el.style.transform = `translate(${dropX}px, ${dropY}px)`; + foodLayer.appendChild(el); + food.push({ element: el, positionX: dropX, positionY: dropY, fallSpeed: randomBetween(20, 35) }); + } + + 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 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; + + if (boundsDirty) { + boundsDirty = false; + updateBounds(); + } + + // Skip work when window is hidden (RAF stays alive lazily). + if (!accessibilityService.isMotionReduced() && targetWindow.document.visibilityState !== 'hidden') { + updateFood(dt); + updateFish(dt); + } + + if (!accessibilityService.isMotionReduced()) { + rafDisposable = scheduleAtNextAnimationFrame(targetWindow, tick); + } + }; + + function updateFood(dt: number): void { + for (let i = food.length - 1; i >= 0; i--) { + const pellet = food[i]; + 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); + } + } + } + + function updateFish(dt: number): void { + const now = performance.now(); + for (const f of fish) { + 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 + // 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 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; + } + + const thrust = 32; + let accelX = Math.cos(f.wanderAngle) * thrust; + let accelY = Math.sin(f.wanderAngle) * thrust; + + // 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.velocityX += Math.cos(dartAngle) * DART_IMPULSE; + f.velocityY += Math.sin(dartAngle) * DART_IMPULSE; + f.panicUntil = now + PANIC_DURATION_MS; + } + + // 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 (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 mouseDeltaX = centerX - mouseX; + const mouseDeltaY = centerY - mouseY; + const mouseDistSq = mouseDeltaX * mouseDeltaX + mouseDeltaY * mouseDeltaY; + if (mouseDistSq < SCATTER_RADIUS_SQ) { + 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 within FOOD_DETECT_RADIUS + let nearestPellet: IFoodPellet | undefined; + let nearestDistSq = FOOD_DETECT_RADIUS_SQ; + for (const pellet of food) { + const foodDeltaX = pellet.positionX - centerX; + const foodDeltaY = pellet.positionY - centerY; + const distSq = foodDeltaX * foodDeltaX + foodDeltaY * foodDeltaY; + if (distSq < nearestDistSq) { + nearestDistSq = distSq; + nearestPellet = pellet; + } + } + if (nearestPellet) { + const nearestDist = Math.max(Math.sqrt(nearestDistSq), 1); + if (nearestDist < EAT_RADIUS) { + removeFood(nearestPellet); + } else { + accelX += (nearestPellet.positionX - centerX) / nearestDist * 200; + accelY += (nearestPellet.positionY - centerY) / nearestDist * 200; + } + } + + f.velocityX += accelX * dt; + f.velocityY += accelY * dt; + + const speedSq = f.velocityX * f.velocityX + f.velocityY * f.velocityY; + const maxSpeed = now < f.panicUntil ? PANIC_MAX_SPEED : MAX_SPEED; + 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; + } + + f.positionX += f.velocityX * dt; + f.positionY += f.velocityY * dt; + + // Hard clamp safety net. + 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); + } + } + + 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). + 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]; + // 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'); + } + }); + store.add(fadeIn); + + const result = new class extends Disposable implements IActiveAquarium { + + constructor() { + super(); + this._register(store); + } + + exit(onDidComplete: () => void): IDisposable { + if (exiting) { + return toDisposable(() => this.dispose()); + } + exiting = true; + + 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'); + + let timer: ReturnType | undefined = setTimeout(() => { + timer = undefined; + this.dispose(); + onDidComplete(); + }, EXIT_DURATION_MS); + return toDisposable(() => { + if (timer !== undefined) { + clearTimeout(timer); + timer = undefined; + } + this.dispose(); + }); + } + }; + + return result; +} + +/** True for clicks not on a control — i.e. safe targets for spawning food. */ +function isBackgroundClick(target: HTMLElement | null): boolean { + if (!target) { + return false; + } + 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) { + return min; + } + return Math.min(Math.max(value, min), max); +} + +/** + * 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(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 (centerY < WALL_MARGIN) { + escapeY += (WALL_MARGIN - centerY) / WALL_MARGIN; + } else if (centerY > height - WALL_MARGIN) { + escapeY -= (centerY - (height - WALL_MARGIN)) / WALL_MARGIN; + } + if (escapeX === 0 && escapeY === 0) { + return undefined; + } + return Math.atan2(escapeY, escapeX) + (Math.random() - 0.5) * 0.4; +} + +/** Smallest signed angular delta from `from` to `to`, in [-PI, PI]. */ +function shortestAngleDelta(from: number, to: number): number { + 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 delta; +} 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..d1389749f0ac6 --- /dev/null +++ b/src/vs/sessions/contrib/aquarium/browser/fish.ts @@ -0,0 +1,269 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * 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. + */ + +/** 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 roll = Math.random(); + if (roll < 0.5) { + return FishSpecies.Stable; + } + if (roll < 0.8) { + return FishSpecies.Insiders; + } + return FishSpecies.Exploration; +} + +/** + * Tear down the shared SVG defs container for the given document. Call when + * no fish are active in that document. + */ +export function disposeSharedFishDefs(targetDocument: Document): void { + const container = sharedDefsByDocument.get(targetDocument); + if (container) { + container.remove(); + sharedDefsByDocument.delete(targetDocument); + } +} + +export interface IFishOptions { + readonly species: FishSpecies; + readonly size: number; + readonly positionX: number; + readonly positionY: number; + readonly velocityX: number; + readonly velocityY: 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; + + positionX: number; + positionY: number; + velocityX: number; + velocityY: number; + readonly size: number; + + /** Timestamp until which this fish is in "panic" mode (faster, scattering). */ + panicUntil = 0; + + /** + * 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(velocityX) each frame so direction changes look like a turn instead of + * a snap-flip. + */ + private facing = 1; + + constructor(opts: IFishOptions, targetDocument: Document) { + 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.velocityY, opts.velocityX); + + 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]; + + // Inner element receives the directional flip so the body strip animations + // (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)); + this.element.appendChild(this.innerElement); + + this.applyTransform(); + } + + /** + * 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. + */ + 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.positionX.toFixed(2)}px, ${this.positionY.toFixed(2)}px)`; + + // 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 easeFactor = 1 - Math.exp(-turnRate * deltaSeconds); + this.facing += (targetFacing - this.facing) * easeFactor; + } else { + 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 flipScaleX = Math.sign(this.facing) * Math.max(Math.abs(this.facing), 0.05); + this.innerElement.style.transform = `scaleX(${flipScaleX.toFixed(3)})`; + } +} + +const SVG_NS = 'http://www.w3.org/2000/svg'; + +/** + * Number of vertical strips the body is sliced into. More strips = smoother + * wave, but each strip is one `` node and one CSS animation per fish. + */ +const NUM_BODY_STRIPS = 8; + +/** 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 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). + * + * 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. + */ +const sharedDefsByDocument = new WeakMap(); + +const SHARED_LOGO_SYMBOL_ID = 'agents-aquarium-fish-logo'; + +function ensureSharedDefs(targetDocument: Document): void { + if (sharedDefsByDocument.has(targetDocument)) { + 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'; + + // 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++) { + 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); + 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 + * 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 and the logo symbol 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); + 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'); + // 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 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 --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('--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})`); + stripG.appendChild(stripUse); + 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..80183b514f912 --- /dev/null +++ b/src/vs/sessions/contrib/aquarium/browser/media/aquarium.css @@ -0,0 +1,294 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* 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; +} +.monaco-workbench .part.chatbar > .title, +.monaco-workbench .part.chatbar > .content { + position: relative; + z-index: 1; +} + +.agents-aquarium-water { + position: absolute; + inset: 0; + pointer-events: none; + overflow: hidden; + z-index: 0; + border-radius: 12px; + opacity: 0; + transition: opacity 320ms ease-out; + /* 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%, + rgba(188, 63, 188, 0.05) 100%); +} + +.agents-aquarium-water.visible { + opacity: 1; +} + +/* 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: ''; + position: absolute; + width: 140%; + aspect-ratio: 1; + border-radius: 50%; + pointer-events: none; + 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; +} + +.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; +} + +/* translate3d so the GPU composites instead of re-rasterizing each frame. */ +@keyframes agents-aquarium-float-a { + 0% { + transform: translate3d(0, 0, 0) scale(1); + } + 50% { + transform: translate3d(8%, 6%, 0) scale(1.05); + } + 100% { + 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); + } +} + +/* --- Fish --- */ + +.agents-aquarium-fish-layer, +.agents-aquarium-food-layer { + position: absolute; + inset: 0; + 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; + left: 0; + will-change: transform, opacity; + pointer-events: none; + opacity: 0; + transition: opacity 360ms ease-out; +} + +/* 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; +} + +.agents-aquarium-fish svg { + width: 100%; + height: 100%; + display: block; + overflow: visible; +} + +/* 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 / 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) * -90ms); +} + +/* 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); + } + 25% { + transform: translateY(3.5px); + } + 50% { + transform: translateY(0); + } + 75% { + transform: translateY(-3.5px); + } + 100% { + transform: translateY(0); + } +} + +/* --- Food pellets --- */ + +.agents-aquarium-food { + position: absolute; + top: 0; + left: 0; + width: 6px; + height: 6px; + 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); + pointer-events: none; + will-change: transform; +} + +/* --- Toggle button (anchored to the chat bar) --- */ + +.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)); +} + +/* `.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); + outline-offset: 2px; +} + +.agents-aquarium-toggle.active { + opacity: 1; + color: var(--vscode-icon-foreground, var(--vscode-foreground, #cccccc)); +} +.agents-aquarium-toggle .codicon { + font-size: 14px; +} + +/* 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-toggle-logo { + animation: none; + } + + .agents-aquarium-water, + .agents-aquarium-fish { + transition: none; + } +} 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 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)); diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts index b7a05fe4bd05f..dfac2aba851f4 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';