diff --git a/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts index 49ab0c27c15fa..f0235d523947f 100644 --- a/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -1279,10 +1279,18 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // Open chat widget — getOrCreateChatSession will wait for the session // handler to become available via canResolveChatSession internally. + // In background mode the caller keeps its own UI in front (e.g. the + // aquarium-as-sessions new-chat view), so skip the chat-widget reveal: + // `viewsService.openView(ChatViewId)` would return null while the + // new-chat view's when-clause is true and the send would otherwise + // fail outright. The session model is still loaded below via + // `acquireOrLoadSession`, which is all `sendRequest` actually needs. await this._chatSessionsService.getOrCreateChatSession(sessionResource, CancellationToken.None); - const chatWidget = await this._chatWidgetService.openSession(sessionResource, ChatViewPaneTarget); - if (!chatWidget) { - throw new Error(`[${this.id}] Failed to open chat widget`); + if (!options.background) { + const chatWidget = await this._chatWidgetService.openSession(sessionResource, ChatViewPaneTarget); + if (!chatWidget) { + throw new Error(`[${this.id}] Failed to open chat widget`); + } } // Load session model and apply selected model diff --git a/src/vs/sessions/contrib/aquarium/browser/aquarium.contribution.ts b/src/vs/sessions/contrib/aquarium/browser/aquarium.contribution.ts index 22bbff401e809..c87790b0637a6 100644 --- a/src/vs/sessions/contrib/aquarium/browser/aquarium.contribution.ts +++ b/src/vs/sessions/contrib/aquarium/browser/aquarium.contribution.ts @@ -10,6 +10,7 @@ import { InstantiationType, registerSingleton } from '../../../../platform/insta 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'; +import { AquariumSubmitIntentService, IAquariumSubmitIntentService } from './aquariumSubmitIntentService.js'; Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ id: 'sessions', @@ -20,7 +21,13 @@ Registry.as(ConfigurationExtensions.Configuration).regis description: localize('sessions.developerJoy.enabled', "Adds an easter egg to the Agents application."), tags: ['experimental'], }, + // NOTE: `SESSIONS_DEVELOPER_JOY_AQUARIUM_AS_SESSIONS_SETTING` + // (`sessions.developerJoy.aquariumAsSessions`) is intentionally NOT + // registered here. It is a hidden developer-opt-in gate that must not + // appear in Settings UI, IntelliSense, or default settings exports. + // See the constant's JSDoc in `aquariumOverlay.ts` for details. }, }); registerSingleton(IAquariumService, AquariumService, InstantiationType.Delayed); +registerSingleton(IAquariumSubmitIntentService, AquariumSubmitIntentService, InstantiationType.Delayed); diff --git a/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts b/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts index 7d2063f36433a..c429253d9a604 100644 --- a/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts +++ b/src/vs/sessions/contrib/aquarium/browser/aquariumOverlay.ts @@ -17,10 +17,35 @@ import { createDecorator } from '../../../../platform/instantiation/common/insta 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'; +import { Bubble } from './bubble.js'; +import { disposeSharedFishDefs, Fish, FishSpecies, pickRandomSpecies } from './fish.js'; export const SESSIONS_DEVELOPER_JOY_ENABLED_SETTING = 'sessions.developerJoy.enabled'; +/** + * Hidden, **unregistered** configuration key that activates the + * sessions-aware aquarium experience (1:1 fish ↔ session, activity bubbles, + * collapse-mode chat input, submit-grows-a-fish). + * + * Intentionally NOT registered with the configuration schema: it must not + * appear in Settings UI, IntelliSense for `settings.json`, default settings + * exports, or product docs. To opt in, hand-edit `settings.json` and set the + * key to `true`. + * + * **Do not add this to `aquarium.contribution.ts`'s `registerConfiguration` + * block.** The combined gate (this + {@link SESSIONS_DEVELOPER_JOY_ENABLED_SETTING} + * + agent-host scope) is what flips the feature on. + */ +export const SESSIONS_DEVELOPER_JOY_AQUARIUM_AS_SESSIONS_SETTING = 'sessions.developerJoy.aquariumAsSessions'; + +/** + * Read the hidden gate. Strictly compares to `true` so a misformatted value + * (e.g. the string `"true"`) does not silently activate the experience. + */ +export function isAquariumAsSessionsEnabled(configurationService: IConfigurationService): boolean { + return configurationService.getValue(SESSIONS_DEVELOPER_JOY_AQUARIUM_AS_SESSIONS_SETTING) === true; +} + const FISH_COUNT = 50; const FISH_MIN_SIZE = 22; const FISH_MAX_SIZE = 48; @@ -51,6 +76,9 @@ const DART_IMPULSE = 150; const ENABLED_STORAGE_KEY = 'sessions.developerJoy.enabled'; +/** Soft cap on simultaneously visible activity bubbles to keep the scene legible. */ +const MAX_BUBBLES = 6; + interface IFoodPellet { readonly element: HTMLDivElement; positionX: number; @@ -58,6 +86,92 @@ interface IFoodPellet { fallSpeed: number; } +// #region --- Population driver API ------------------------------------------------------- + +/** + * Options accepted by {@link IAquariumHost.addFish}. Any field left undefined + * falls back to the random-crowd defaults the engine has always used (random + * size in [{@link FISH_MIN_SIZE}, {@link FISH_MAX_SIZE}], random heading, + * weighted random species, random position inside the water bounds). + */ +export interface IAddFishOptions { + readonly species?: FishSpecies; + readonly size?: number; + readonly positionX?: number; + readonly positionY?: number; + readonly velocityX?: number; + readonly velocityY?: number; + /** Stagger delay applied as `transition-delay` before adding `.visible`. */ + readonly fadeInDelayMs?: number; +} + +/** + * Handle returned to drivers for each fish they add. Owns the underlying + * {@link Fish} and provides a fade-out path that respects the engine's exit + * timing. Disposing the handle removes the fish synchronously without a fade. + */ +export interface IFishHandle extends IDisposable { + readonly fish: Fish; + /** + * Start a swim-out fade and dispose this handle when it completes. + * Idempotent: subsequent calls (or a direct dispose) are no-ops. + */ + fadeOut(delayMs?: number): void; +} + +/** + * Driver-facing surface for an active aquarium. The engine owns the water, + * the food/RAF loop, mouse handling, and exit animation; drivers only decide + * which fish exist. + */ +export interface IAquariumHost { + readonly targetWindow: Window; + readonly mainContainer: HTMLElement; + /** Reflects the current size of the water in pixels. Mutated in place by the engine. */ + readonly bounds: { readonly width: number; readonly height: number }; + addFish(opts?: IAddFishOptions): IFishHandle; + /** + * Show (or update) an activity bubble above the given fish. When a bubble + * already exists for this fish, the text is replaced and the dwell timer + * is reset; otherwise a fresh bubble is created (subject to the engine's + * global cap, which evicts the oldest non-hovered bubble when exceeded). + * + * No-op if the handle is unknown to the engine (already disposed) or the + * aquarium is exiting. + */ + showBubble(handle: IFishHandle, text: string): void; +} + +/** + * Strategy supplied to the engine to populate an active aquarium. The engine + * calls {@link IAquariumPopulationDriver.attach} once during activation; the + * driver typically registers reactive listeners and calls + * {@link IAquariumHost.addFish} as data changes. Disposing the driver runs + * when the aquarium is being torn down or the driver is being swapped. + */ +export interface IAquariumPopulationDriver extends IDisposable { + attach(host: IAquariumHost): void; +} + +/** + * Optional mount configuration. Today the only knob is the population + * driver factory; future per-mount knobs (e.g. opt out of the toggle button) + * land here. + */ +export interface IMountToggleOptions { + /** + * If supplied, the active aquarium will use a driver instantiated from + * this factory instead of the default random-crowd driver. Pass + * `undefined` (or omit) to keep the default. + * + * Equivalent to {@link IMountedToggleHandle.setDriverFactory} called + * with the same value at mount time. + */ + readonly driverFactory?: () => IAquariumPopulationDriver; +} + +// #endregion + /** * Owns the toggle button(s), the persisted on/off preference, and the active * aquarium. Hosts call {@link IAquariumService.mountToggle} to attach a button @@ -75,8 +189,12 @@ export interface IAquariumService { * aquarium tied to their own visibility (e.g. a view pane). Disposing the * handle removes the button and tears down the active aquarium if it was * the last mount. + * + * `opts.driverFactory` selects a custom population driver. The most + * recently set factory across all mounts wins; this is fine because today + * there is only one consumer (the new chat view). */ - mountToggle(parent: HTMLElement): IMountedToggleHandle; + mountToggle(parent: HTMLElement, opts?: IMountToggleOptions): IMountedToggleHandle; } export interface IMountedToggleHandle extends IDisposable { @@ -88,11 +206,24 @@ export interface IMountedToggleHandle extends IDisposable { * Hosts that don't care can leave this alone — mounts default to visible. */ setHostVisible(visible: boolean): void; + + /** + * Replace the population driver. Pass `undefined` to revert to the + * default random-crowd driver. + * + * If the aquarium is currently active, the running driver is disposed, + * all existing fish are removed synchronously (no fade — used for an + * internal swap, not a user-visible exit), and the new driver is + * attached. If no aquarium is active, the factory is just stored for + * the next activation. + */ + setDriverFactory(factory: (() => IAquariumPopulationDriver) | undefined): void; } interface IMountedToggle { readonly button: HTMLButtonElement; hostVisible: boolean; + driverFactory: (() => IAquariumPopulationDriver) | undefined; } export class AquariumService extends Disposable implements IAquariumService { @@ -126,7 +257,7 @@ export class AquariumService extends Disposable implements IAquariumService { })); } - mountToggle(parent: HTMLElement): IMountedToggleHandle { + mountToggle(parent: HTMLElement, opts?: IMountToggleOptions): IMountedToggleHandle { const doc = parent.ownerDocument; const button = doc.createElement('button'); button.className = 'agents-aquarium-toggle'; @@ -149,9 +280,10 @@ export class AquariumService extends Disposable implements IAquariumService { parent.appendChild(button); - const mount: IMountedToggle = { button, hostVisible: true }; + const mount: IMountedToggle = { button, hostVisible: true, driverFactory: opts?.driverFactory }; this.mounts.add(mount); - this.applyFeatureEnabledStateForButton(button); + this.applyButtonVisibility(mount); + this.applyDriverFactoryToActive(); this.reconcileActivation(); return { @@ -162,25 +294,75 @@ export class AquariumService extends Disposable implements IAquariumService { mount.hostVisible = visible; this.reconcileActivation(); }, + setDriverFactory: (factory: (() => IAquariumPopulationDriver) | undefined) => { + if (mount.driverFactory === factory) { + return; + } + const wasActive = !!this.activeRef.value; + mount.driverFactory = factory; + // A non-undefined factory is a developer-joy gate forcing the + // aquarium on, so hide the manual toggle button to avoid + // conflict; setting it back to undefined restores the button. + this.applyButtonVisibility(mount); + this.reconcileActivation(); + if (wasActive && this.activeRef.value) { + // Stayed active across the change — propagate the new + // factory by swapping drivers in place. (When newly + // activated we skip this: activate() already created the + // aquarium with the right factory. When newly deactivated + // we also skip: there's nothing to swap into.) + this.applyDriverFactoryToActive(); + } + }, dispose: () => { store.dispose(); button.remove(); this.mounts.delete(mount); + this.applyDriverFactoryToActive(); this.reconcileActivation(); }, }; } /** - * Activate when at least one mount is host-visible and the user has it on; - * otherwise deactivate synchronously (no fade) so the aquarium can't flash - * behind a sibling view during a view swap. + * Compute the most-recent driver factory across mounts (most recently + * set wins) and push it to the active aquarium so it can swap drivers + * in place. No-op if no aquarium is active or the factory is unchanged. + */ + private applyDriverFactoryToActive(): void { + const factory = this.selectedDriverFactory(); + if (!this.activeRef.value) { + return; + } + this.activeRef.value.swapDriver(factory); + } + + /** + * The most recently set driver factory across all mounts (insertion order + * for `Set`). With a single consumer today this collapses to "the chat + * view's factory if any". + */ + private selectedDriverFactory(): (() => IAquariumPopulationDriver) | undefined { + let factory: (() => IAquariumPopulationDriver) | undefined; + for (const mount of this.mounts) { + if (mount.driverFactory) { + factory = mount.driverFactory; + } + } + return factory; + } + + /** + * Activate when at least one mount is host-visible and either the user + * has the aquarium toggled on or a mount has supplied a driver factory + * (developer-joy gate forces the aquarium on). When the host disappears, + * tear down synchronously so the aquarium can't flash behind a sibling + * view during a swap. When the activation reason goes away (factory + * unset and toggle off), fade out without persisting. */ private reconcileActivation(): void { const anyHostVisible = this.hasVisibleMount(); - if (anyHostVisible && this.isFeatureEnabled() && this.isStoredEnabled() && !this.activeRef.value) { - this.activate(/* persist */ false); - } else if (!anyHostVisible) { + if (!anyHostVisible) { // Host hide: dispose any active aquarium synchronously AND cancel // any in-flight animated exit (from a prior user toggle-off) so it // can't keep painting fish behind whatever view took our place. @@ -188,6 +370,19 @@ export class AquariumService extends Disposable implements IAquariumService { if (this.activeRef.value) { this.deactivate(/* persist */ false, /* animate */ false); } + return; + } + if (!this.isFeatureEnabled()) { + return; + } + const shouldBeActive = this.isStoredEnabled() || this.hasAnyDriverFactory(); + if (shouldBeActive && !this.activeRef.value) { + this.activate(/* persist */ false); + } else if (!shouldBeActive && this.activeRef.value) { + // Reason for being active is gone (gate flipped off, user hadn't + // toggled the legacy aquarium on) — fade out without persisting + // so a future toggle still reflects user preference. + this.deactivate(/* persist */ false); } } @@ -200,6 +395,15 @@ export class AquariumService extends Disposable implements IAquariumService { return false; } + private hasAnyDriverFactory(): boolean { + for (const m of this.mounts) { + if (m.driverFactory) { + return true; + } + } + return false; + } + private isFeatureEnabled(): boolean { return this.configurationService.getValue(SESSIONS_DEVELOPER_JOY_ENABLED_SETTING) === true; } @@ -214,7 +418,7 @@ export class AquariumService extends Disposable implements IAquariumService { private applyFeatureEnabledState(): void { for (const mount of this.mounts) { - this.applyFeatureEnabledStateForButton(mount.button); + this.applyButtonVisibility(mount); } if (!this.isFeatureEnabled() && this.activeRef.value) { // Setting turned off — don't persist so the prior preference survives a re-enable. @@ -224,8 +428,13 @@ export class AquariumService extends Disposable implements IAquariumService { } } - private applyFeatureEnabledStateForButton(button: HTMLButtonElement): void { - button.style.display = this.isFeatureEnabled() ? '' : 'none'; + private applyButtonVisibility(mount: IMountedToggle): void { + // Hide the button when the public feature flag is off, or when this + // mount is being externally driven by a population driver factory — + // in that case the host is forcing the aquarium on and a manual + // hide-toggle would just create a confusing visual conflict. + const shouldShow = this.isFeatureEnabled() && !mount.driverFactory; + mount.button.style.display = shouldShow ? '' : 'none'; } private updateToggleButtonVisual(button: HTMLButtonElement, active: boolean): void { @@ -275,7 +484,12 @@ export class AquariumService extends Disposable implements IAquariumService { this.pendingExit.clear(); let active: IActiveAquarium | undefined; try { - active = createActiveAquarium(this.mainContainer, this.layoutService, this.accessibilityService); + active = createActiveAquarium( + this.mainContainer, + this.layoutService, + this.accessibilityService, + this.selectedDriverFactory(), + ); } catch (e) { console.error('[aquarium] failed to activate', e); return; @@ -338,6 +552,16 @@ interface IActiveAquarium extends IDisposable { * returned handle before the animation finishes disposes immediately. */ exit(onDidComplete: () => void): IDisposable; + + /** + * Replace the active population driver in place. The previous driver is + * disposed, all current fish are removed synchronously (no fade — used + * for an internal swap, not a user-visible exit), and the new driver is + * attached. Pass `undefined` to revert to the default random crowd. + * + * No-op if the aquarium is already exiting. + */ + swapDriver(factory: (() => IAquariumPopulationDriver) | undefined): void; } /** @@ -345,7 +569,12 @@ interface IActiveAquarium extends IDisposable { * 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 { +function createActiveAquarium( + mainContainer: HTMLElement, + layoutService: IWorkbenchLayoutService, + accessibilityService: IAccessibilityService, + populationDriverFactory: (() => IAquariumPopulationDriver) | undefined, +): IActiveAquarium | undefined { const targetWindow = getWindow(mainContainer); // Host inside the chat bar so chat input UI naturally paints on top — @@ -373,6 +602,13 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe foodLayer.className = 'agents-aquarium-food-layer'; water.appendChild(foodLayer); + // Bubble layer is ABOVE fish/food so activity bubbles paint on top and + // remain readable even when fish school over each other. + const bubbleLayer = doc.createElement('div'); + bubbleLayer.className = 'agents-aquarium-bubble-layer'; + bubbleLayer.setAttribute('aria-hidden', 'true'); + water.appendChild(bubbleLayer); + const bounds = { width: 0, height: 0 }; // Cached so the per-mousemove handler doesn't trigger a layout flush. const waterScreenOffset = { left: 0, top: 0 }; @@ -384,12 +620,20 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe waterScreenOffset.top = rect.top; }; - const fish: Fish[] = []; + // Live fish keyed by allocation id. Insertion order is preserved by the + // `Map` spec, which the staggered exit animation leans on. + const fishHandles = new Map(); + /** Active bubbles keyed by their parent fish id. */ + const bubblesByFishId = new Map(); + let nextFishId = 0; + let exiting = false; + let currentDriver: IAquariumPopulationDriver | undefined; updateBounds(); const resizeObserver = new ResizeObserver(() => { updateBounds(); - for (const f of fish) { + for (const handle of fishHandles.values()) { + const f = handle.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)); } @@ -397,62 +641,164 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe 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) { + // Local impl class so closure-captured state (fishHandles) is reachable + // without making the public IFishHandle interface leak engine internals. + class FishHandle implements IFishHandle { + readonly id = nextFishId++; + private _fadeOutTimer: ReturnType | undefined; + private _disposed = false; + constructor(readonly fish: Fish) { } + fadeOut(delayMs: number = 0): void { + if (this._disposed || this._fadeOutTimer !== undefined) { + return; + } + const adjustedDelay = Math.max(0, delayMs); + this.fish.element.style.transitionDelay = `${adjustedDelay}ms`; + this.fish.element.classList.remove('visible'); + // The bubble (if any) should fade alongside the fish; don't leave + // orphan markup parented to a disappearing creature. + bubblesByFishId.get(this.id)?.fadeOut(); + this._fadeOutTimer = setTimeout(() => { + this._fadeOutTimer = undefined; + this.dispose(); + }, adjustedDelay + EXIT_DURATION_MS); + } + dispose(): void { + if (this._disposed) { return; } - const restBatch = targetWindow.document.createDocumentFragment(); - for (let i = SYNC_BATCH; i < fish.length; i++) { - restBatch.appendChild(fish[i].element); + this._disposed = true; + if (this._fadeOutTimer !== undefined) { + clearTimeout(this._fadeOutTimer); + this._fadeOutTimer = undefined; + } + // Synchronously drop any associated bubble so a sync `dispose()` + // (e.g. swapDriver) doesn't leak DOM. + const bubble = bubblesByFishId.get(this.id); + if (bubble) { + bubblesByFishId.delete(this.id); + bubble.dispose(); + } + this.fish.element.remove(); + fishHandles.delete(this.id); + } + } + + function evictOldestEvictableBubble(): void { + if (bubblesByFishId.size < MAX_BUBBLES) { + return; + } + // Prefer the oldest non-hovered, non-already-fading bubble. + let oldestId: number | undefined; + let oldestCreatedAt = Infinity; + for (const [fishId, bubble] of bubblesByFishId) { + if (bubble.isHovered || bubble.isFading) { + continue; + } + if (bubble.createdAt < oldestCreatedAt) { + oldestCreatedAt = bubble.createdAt; + oldestId = fishId; } - fishLayer.appendChild(restBatch); + } + if (oldestId !== undefined) { + // Trigger a fade-out; the bubble's onExpired handler will clean up. + bubblesByFishId.get(oldestId)?.fadeOut(); + } + } + + const host: IAquariumHost = { + targetWindow, + mainContainer, + bounds, + addFish: (opts: IAddFishOptions = {}): IFishHandle => { + const size = opts.size ?? randomBetween(FISH_MIN_SIZE, FISH_MAX_SIZE); + const positionX = opts.positionX ?? randomBetween(0, Math.max(1, bounds.width - size)); + const positionY = opts.positionY ?? randomBetween(0, Math.max(1, bounds.height - size)); + let velocityX = opts.velocityX; + let velocityY = opts.velocityY; + if (velocityX === undefined || velocityY === undefined) { + const angle = Math.random() * Math.PI * 2; + const speed = randomBetween(BASE_SPEED * 0.6, BASE_SPEED * 1.2); + velocityX = Math.cos(angle) * speed; + velocityY = Math.sin(angle) * speed; + } + const species = opts.species ?? pickRandomSpecies(); + const fish = new Fish({ + species, + size, + positionX, + positionY, + velocityX, + velocityY, + }, doc); + fishLayer.appendChild(fish.element); + const handle = new FishHandle(fish); + fishHandles.set(handle.id, handle); // Add `.visible` on the NEXT frame so a paint at opacity:0 happens // first — guarantees the CSS transition fires. + const fadeInDelayMs = Math.max(0, opts.fadeInDelayMs ?? 0); const fadeIn = scheduleAtNextAnimationFrame(targetWindow, () => { - if (exiting) { + // Bail if we're exiting or the handle was disposed before the + // frame ran (a `Map.has` lookup avoids touching private state). + if (exiting || !fishHandles.has(handle.id)) { 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'); - } + fish.element.style.transitionDelay = `${fadeInDelayMs}ms`; + fish.element.classList.add('visible'); }); store.add(fadeIn); - }); - store.add(deferred); - } + return handle; + }, + showBubble: (handle: IFishHandle, text: string): void => { + if (exiting) { + return; + } + // Reject handles we no longer own. A driver could be holding a + // stale reference after a swapDriver or fadeOut. + const fishId = (handle as FishHandle).id; + if (typeof fishId !== 'number' || !fishHandles.has(fishId)) { + return; + } + const trimmed = text.trim(); + if (!trimmed) { + // Clearing activity removes any active bubble. + bubblesByFishId.get(fishId)?.fadeOut(); + return; + } + const existing = bubblesByFishId.get(fishId); + if (existing) { + existing.setText(trimmed); + return; + } + evictOldestEvictableBubble(); + const bubble = new Bubble(doc, bubbleLayer, trimmed, () => { + bubblesByFishId.delete(fishId); + }); + bubblesByFishId.set(fishId, bubble); + }, + }; + + // Instantiate the population driver and let it populate the water. + // Default to RandomPopulationDriver when no factory is supplied so the + // long-standing "decorative crowd" behavior is preserved unchanged. + const initFactory = populationDriverFactory ?? (() => new RandomPopulationDriver(targetWindow)); + currentDriver = initFactory(); + currentDriver.attach(host); + + // Final cleanup: dispose the driver, remove any remaining fish + bubbles, + // and tear down the shared SVG defs so we don't leak across reloads. + // Driver first so its bookkeeping doesn't try to touch fish that are + // about to be removed. store.add(toDisposable(() => { - for (const f of fish) { - f.element.remove(); + currentDriver?.dispose(); + currentDriver = undefined; + for (const bubble of bubblesByFishId.values()) { + bubble.dispose(); + } + bubblesByFishId.clear(); + for (const handle of [...fishHandles.values()]) { + handle.dispose(); } - // Tear down shared SVG defs so we don't leak across reloads. disposeSharedFishDefs(targetWindow.document); })); @@ -580,7 +926,8 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe function updateFish(dt: number): void { const now = performance.now(); - for (const f of fish) { + for (const handle of fishHandles.values()) { + const f = handle.fish; const centerX = f.positionX + f.size / 2; const centerY = f.positionY + f.size / 2; @@ -675,8 +1022,23 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe 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.tickSize(now); f.applyTransform(dt); } + + // Position any active bubbles directly above their fish. Cheap because + // the bubble count is small (capped by MAX_BUBBLES) and we already + // have all the fish positions locally. + if (bubblesByFishId.size > 0) { + for (const [fishId, bubble] of bubblesByFishId) { + const handle = fishHandles.get(fishId); + if (!handle) { + continue; + } + const f = handle.fish; + bubble.setPosition(f.positionX, f.positionY, f.size); + } + } } store.add(accessibilityService.onDidChangeReducedMotion(() => { @@ -689,19 +1051,12 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe store.add(toDisposable(() => stopAnimation())); startAnimation(); - // First-batch fade-in (the deferred batch fades in when it mounts). + // Water fade-in (per-fish fade-in is handled inside `host.addFish`). 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); @@ -712,17 +1067,45 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe this._register(store); } + swapDriver(factory: (() => IAquariumPopulationDriver) | undefined): void { + if (exiting) { + return; + } + currentDriver?.dispose(); + currentDriver = undefined; + // Sync clear (no fade): this is an internal swap, not a + // user-visible exit. A staggered fade would let the previous + // crowd briefly mix with the incoming one. + for (const handle of [...fishHandles.values()]) { + handle.dispose(); + } + const nextFactory = factory ?? (() => new RandomPopulationDriver(targetWindow)); + currentDriver = nextFactory(); + currentDriver.attach(host); + } + exit(onDidComplete: () => void): IDisposable { if (exiting) { return toDisposable(() => this.dispose()); } exiting = true; - for (let i = 0; i < fish.length; i++) { - const f = fish[i]; + // Stop the driver from spawning more fish during the fade-out; + // the engine remains alive until the animation completes. + currentDriver?.dispose(); + currentDriver = undefined; + + // Fade bubbles alongside the fish so they don't pop out. + for (const bubble of bubblesByFishId.values()) { + bubble.fadeOut(); + } + + let i = 0; + for (const handle of fishHandles.values()) { const delay = Math.min(i * 12, 400); - f.element.style.transitionDelay = `${delay}ms`; - f.element.classList.remove('visible'); + handle.fish.element.style.transitionDelay = `${delay}ms`; + handle.fish.element.classList.remove('visible'); + i++; } water.classList.remove('visible'); @@ -744,6 +1127,36 @@ function createActiveAquarium(mainContainer: HTMLElement, layoutService: IWorkbe return result; } +/** + * Default population driver: spawns the long-standing decorative crowd of + * {@link FISH_COUNT} fish. Split into two batches — first half synchronous, + * rest deferred to the next animation frame — so a toggle click stays snappy. + */ +class RandomPopulationDriver extends Disposable implements IAquariumPopulationDriver { + + constructor(private readonly _targetWindow: Window) { + super(); + } + + attach(host: IAquariumHost): void { + const SYNC_BATCH = Math.ceil(FISH_COUNT / 2); + for (let i = 0; i < SYNC_BATCH; i++) { + const delay = Math.min(i * 12, 400); + host.addFish({ fadeInDelayMs: delay }); + } + if (SYNC_BATCH < FISH_COUNT) { + const deferred = scheduleAtNextAnimationFrame(this._targetWindow, () => { + for (let i = SYNC_BATCH; i < FISH_COUNT; i++) { + const localIndex = i - SYNC_BATCH; + const delay = Math.min(localIndex * 12, 400); + host.addFish({ fadeInDelayMs: delay }); + } + }); + this._register(deferred); + } + } +} + /** True for clicks not on a control — i.e. safe targets for spawning food. */ function isBackgroundClick(target: HTMLElement | null): boolean { if (!target) { diff --git a/src/vs/sessions/contrib/aquarium/browser/aquariumSubmitIntentService.ts b/src/vs/sessions/contrib/aquarium/browser/aquariumSubmitIntentService.ts new file mode 100644 index 0000000000000..8a2bef19150c6 --- /dev/null +++ b/src/vs/sessions/contrib/aquarium/browser/aquariumSubmitIntentService.ts @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; + +/** + * Maximum age, in milliseconds, of a submit intent that the session-population + * driver will still associate with an incoming session. Beyond this window the + * intent is considered stale (the user likely cancelled or the underlying + * provider failed) and the next session add is treated as ambient. + */ +export const SUBMIT_INTENT_WINDOW_MS = 5000; + +export const IAquariumSubmitIntentService = createDecorator('aquariumSubmitIntentService'); + +/** + * Lightweight bus that bridges the chat input's send button to the + * sessions-aware aquarium driver. The chat input calls + * {@link recordIntent} synchronously when the user clicks send; the driver + * calls {@link consumeIntent} when it observes a freshly-added agent-host + * session and uses the result to opt the fish into the "grow from spawn" + * tween. + * + * Decoupled via a service rather than a direct reference because the chat + * input doesn't know whether the aquarium experience is active — it just + * always reports intents and the driver chooses whether to consume them. + */ +export interface IAquariumSubmitIntentService { + readonly _serviceBrand: undefined; + + /** + * Record that the user has just submitted a new chat. Subsequent calls + * within {@link SUBMIT_INTENT_WINDOW_MS} overwrite the previous timestamp + * (latest intent wins). + */ + recordIntent(): void; + + /** + * Consume the most recent intent if it was recorded within + * {@link SUBMIT_INTENT_WINDOW_MS}. Returns `true` exactly once per + * recorded intent; the timestamp is cleared on consumption. + */ + consumeIntent(): boolean; +} + +export class AquariumSubmitIntentService implements IAquariumSubmitIntentService { + declare readonly _serviceBrand: undefined; + + private _lastIntentAt: number | undefined; + + recordIntent(): void { + this._lastIntentAt = Date.now(); + } + + consumeIntent(): boolean { + const at = this._lastIntentAt; + if (at === undefined) { + return false; + } + this._lastIntentAt = undefined; + return Date.now() - at <= SUBMIT_INTENT_WINDOW_MS; + } +} diff --git a/src/vs/sessions/contrib/aquarium/browser/bubble.ts b/src/vs/sessions/contrib/aquarium/browser/bubble.ts new file mode 100644 index 0000000000000..e9d78c3863c77 --- /dev/null +++ b/src/vs/sessions/contrib/aquarium/browser/bubble.ts @@ -0,0 +1,188 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; + +/** Maximum number of glyphs we render in a single bubble. */ +const BUBBLE_MAX_TEXT_LENGTH = 80; + +/** Default time the bubble stays at full opacity before fading out, in ms. */ +export const BUBBLE_DEFAULT_DWELL_MS = 3500; + +/** CSS-driven fade-out duration. Must match the `.agents-aquarium-bubble.fade-out` rule. */ +export const BUBBLE_FADE_OUT_MS = 300; + +/** Vertical offset above the fish where the bubble renders, in pixels. */ +const BUBBLE_VERTICAL_OFFSET_PX = 8; + +/** + * A small chat-bubble that hovers above a fish, used by the sessions-aware + * aquarium to surface the session's current activity. + * + * The bubble owns its DOM element; the engine is responsible for positioning + * it each frame via {@link Bubble.setPosition}. Lifecycle is otherwise + * self-contained: a dwell timer, an explicit hover lock, and an idempotent + * fade-out path. + */ +export class Bubble extends Disposable { + + readonly element: HTMLDivElement; + + private readonly _textEl: HTMLSpanElement; + + private _dwellTimer: ReturnType | undefined; + private _fadeTimer: ReturnType | undefined; + private _hovered = false; + private _fading = false; + private _disposed = false; + + /** Wall-clock time the bubble was created. Used by the engine's eviction policy. */ + readonly createdAt: number; + + constructor( + targetDocument: Document, + container: HTMLElement, + text: string, + private readonly _onExpired: () => void, + ) { + super(); + + this.createdAt = Date.now(); + + this.element = targetDocument.createElement('div'); + this.element.className = 'agents-aquarium-bubble'; + this.element.setAttribute('aria-hidden', 'true'); + + this._textEl = targetDocument.createElement('span'); + this._textEl.className = 'agents-aquarium-bubble-text'; + this.element.appendChild(this._textEl); + + this._textEl.textContent = truncate(text); + + container.appendChild(this.element); + this._restartDwell(); + } + + /** Replace the text and reset the dwell timer; visually a "still here" pulse. */ + setText(text: string): void { + if (this._disposed) { + return; + } + const truncated = truncate(text); + if (this._textEl.textContent === truncated && !this._fading) { + // Same text, still showing — no-op so we don't restart the dwell on + // every observable tick. + return; + } + this._textEl.textContent = truncated; + // If we were already fading out, cancel and re-show. + if (this._fading) { + this._fading = false; + this.element.classList.remove('fade-out'); + if (this._fadeTimer !== undefined) { + clearTimeout(this._fadeTimer); + this._fadeTimer = undefined; + } + } + this._restartDwell(); + } + + /** Position the bubble centered above a fish at (fishX, fishY) of size {@linkcode fishSize}. */ + setPosition(fishX: number, fishY: number, fishSize: number): void { + const cx = fishX + fishSize / 2; + const top = fishY - BUBBLE_VERTICAL_OFFSET_PX; + this.element.style.transform = `translate(${cx.toFixed(2)}px, ${top.toFixed(2)}px) translate(-50%, -100%)`; + } + + setHovered(hovered: boolean): void { + if (this._hovered === hovered || this._disposed) { + return; + } + this._hovered = hovered; + if (hovered) { + // Pause both timers; the bubble stays until the user moves away. + if (this._dwellTimer !== undefined) { + clearTimeout(this._dwellTimer); + this._dwellTimer = undefined; + } + if (this._fadeTimer !== undefined) { + clearTimeout(this._fadeTimer); + this._fadeTimer = undefined; + } + if (this._fading) { + this._fading = false; + this.element.classList.remove('fade-out'); + } + } else { + // Re-arm the dwell so we don't linger forever after the user's gone. + this._restartDwell(); + } + } + + /** Begin a fade-out and dispose when it completes. Idempotent. */ + fadeOut(): void { + if (this._fading || this._disposed) { + return; + } + this._fading = true; + if (this._dwellTimer !== undefined) { + clearTimeout(this._dwellTimer); + this._dwellTimer = undefined; + } + this.element.classList.add('fade-out'); + this._fadeTimer = setTimeout(() => { + this._fadeTimer = undefined; + this.dispose(); + this._onExpired(); + }, BUBBLE_FADE_OUT_MS); + } + + override dispose(): void { + if (this._disposed) { + return; + } + this._disposed = true; + if (this._dwellTimer !== undefined) { + clearTimeout(this._dwellTimer); + this._dwellTimer = undefined; + } + if (this._fadeTimer !== undefined) { + clearTimeout(this._fadeTimer); + this._fadeTimer = undefined; + } + this.element.remove(); + super.dispose(); + } + + get isFading(): boolean { + return this._fading; + } + + get isHovered(): boolean { + return this._hovered; + } + + private _restartDwell(): void { + if (this._dwellTimer !== undefined) { + clearTimeout(this._dwellTimer); + } + this._dwellTimer = setTimeout(() => { + this._dwellTimer = undefined; + if (!this._hovered) { + this.fadeOut(); + } + }, BUBBLE_DEFAULT_DWELL_MS); + } +} + +function truncate(text: string): string { + const trimmed = text.trim(); + if (trimmed.length <= BUBBLE_MAX_TEXT_LENGTH) { + return trimmed; + } + // Trim mid-word but keep visual rhythm; the trailing ellipsis is a single + // glyph so the visible width stays bounded. + return trimmed.slice(0, BUBBLE_MAX_TEXT_LENGTH - 1) + '…'; +} diff --git a/src/vs/sessions/contrib/aquarium/browser/fish.ts b/src/vs/sessions/contrib/aquarium/browser/fish.ts index d1389749f0ac6..245c161c057fe 100644 --- a/src/vs/sessions/contrib/aquarium/browser/fish.ts +++ b/src/vs/sessions/contrib/aquarium/browser/fish.ts @@ -24,6 +24,16 @@ const SPECIES_COLOR: Record = { [FishSpecies.Exploration]: '#E04F00', }; +/** + * Visual status used by the sessions-aware aquarium. When set, the fish's + * color is taken over by a status-specific CSS class + * (`agents-aquarium-fish--`); when cleared, the species color is + * restored. + */ +export type FishStatusVariant = 'running' | 'needs-input' | 'error'; + +const ALL_STATUS_VARIANTS: readonly FishStatusVariant[] = ['running', 'needs-input', 'error']; + /** Pick a random species, weighted Stable > Insiders > Exploration. */ export function pickRandomSpecies(): FishSpecies { const roll = Math.random(); @@ -70,7 +80,7 @@ export class Fish { positionY: number; velocityX: number; velocityY: number; - readonly size: number; + size: number; /** Timestamp until which this fish is in "panic" mode (faster, scattering). */ panicUntil = 0; @@ -89,6 +99,17 @@ export class Fish { */ private facing = 1; + /** Inline `style.color` for this fish's species. Restored when a status variant is cleared. */ + private readonly _speciesColor: string; + + private _activeStatusVariant: FishStatusVariant | undefined; + + /** + * Active size tween, if any. `undefined` once the tween settles or when no + * tween is active. Read by {@link tickSize} each frame. + */ + private _sizeTween: { startSize: number; targetSize: number; startTime: number; durationMs: number } | undefined; + constructor(opts: IFishOptions, targetDocument: Document) { this.positionX = opts.positionX; this.positionY = opts.positionY; @@ -97,11 +118,13 @@ export class Fish { this.size = opts.size; this.wanderAngle = Math.atan2(opts.velocityY, opts.velocityX); + this._speciesColor = SPECIES_COLOR[opts.species]; + 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]; + this.element.style.color = this._speciesColor; // Inner element receives the directional flip so the body strip animations // (driven by --agents-aquarium-strip-index) are unaffected by direction changes. @@ -113,6 +136,73 @@ export class Fish { this.applyTransform(); } + /** + * Begin (or instantly apply) a tween from the current size to {@linkcode size} + * over {@linkcode durationMs} milliseconds. Subsequent calls override any + * in-flight tween. Pass `0` (default) to set the size immediately and clear + * any active tween. + */ + setTargetSize(size: number, durationMs: number = 0): void { + const clamped = Math.max(1, size); + if (durationMs <= 0) { + this._sizeTween = undefined; + this.size = clamped; + this.element.style.width = `${clamped}px`; + this.element.style.height = `${clamped}px`; + return; + } + this._sizeTween = { + startSize: this.size, + targetSize: clamped, + startTime: performance.now(), + durationMs, + }; + } + + /** + * Advance any in-flight size tween to {@linkcode now}. Cheap no-op when no + * tween is active. Call once per frame from the aquarium tick. + */ + tickSize(now: number): void { + const tween = this._sizeTween; + if (!tween) { + return; + } + const elapsed = now - tween.startTime; + if (elapsed >= tween.durationMs) { + this.size = tween.targetSize; + this._sizeTween = undefined; + } else { + // easeOutCubic — fast at the start, soft landing. + const t = elapsed / tween.durationMs; + const eased = 1 - Math.pow(1 - t, 3); + this.size = tween.startSize + (tween.targetSize - tween.startSize) * eased; + } + this.element.style.width = `${this.size.toFixed(2)}px`; + this.element.style.height = `${this.size.toFixed(2)}px`; + } + + /** + * Apply a status-driven appearance override (color via class). Pass + * `undefined` to revert to the species color. + * + * Only the inline color is touched: the species color is re-applied when + * the variant clears, so toggling between variant and species is + * idempotent. + */ + setStatusVariant(variant: FishStatusVariant | undefined): void { + if (variant === this._activeStatusVariant) { + return; + } + this._activeStatusVariant = variant; + for (const v of ALL_STATUS_VARIANTS) { + this.element.classList.toggle(`agents-aquarium-fish--${v}`, v === variant); + } + // Inline `color` wins over class rules at equal specificity, so we + // clear it while a variant is active and restore it when none is. + this.element.style.color = variant === undefined ? this._speciesColor : ''; + } + /** * Write the current position/facing to the DOM. * diff --git a/src/vs/sessions/contrib/aquarium/browser/media/aquarium.css b/src/vs/sessions/contrib/aquarium/browser/media/aquarium.css index 80183b514f912..bb053d7584876 100644 --- a/src/vs/sessions/contrib/aquarium/browser/media/aquarium.css +++ b/src/vs/sessions/contrib/aquarium/browser/media/aquarium.css @@ -103,6 +103,14 @@ pointer-events: none; } +/* Bubble layer paints above fish so activity bubbles stay legible when + * fish school over each other. Decorative — `aria-hidden` set in JS. */ +.agents-aquarium-bubble-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 { @@ -167,6 +175,59 @@ } } +/* --- Status variants (sessions-aware aquarium) --- */ + +/* Each variant overrides the species color. The status-driven appearance + * intentionally stays subtle: the same VS Code logo silhouette, recolored. + * Inline `style.color` is cleared by Fish.setStatusVariant when a variant is + * active, so these class rules apply at the natural cascade order. */ +.agents-aquarium-fish--running { + color: var(--vscode-charts-blue, #3794ff); +} + +.agents-aquarium-fish--needs-input { + color: var(--vscode-charts-orange, #d18616); +} + +.agents-aquarium-fish--error { + color: var(--vscode-charts-red, #f14c4c); +} + +/* --- Activity bubbles (sessions-aware aquarium) --- */ + +.agents-aquarium-bubble { + position: absolute; + top: 0; + left: 0; + max-width: 220px; + padding: 4px 8px; + border-radius: 10px; + font-size: 11px; + line-height: 14px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + background: var(--vscode-editorHoverWidget-background, rgba(40, 40, 40, 0.92)); + color: var(--vscode-editorHoverWidget-foreground, #d4d4d4); + border: 1px solid var(--vscode-editorHoverWidget-border, rgba(255, 255, 255, 0.1)); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25); + pointer-events: none; + will-change: transform, opacity; + opacity: 1; + transition: opacity 200ms ease-out; +} + +.agents-aquarium-bubble.fade-out { + opacity: 0; +} + +.agents-aquarium-bubble-text { + display: inline-block; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; +} + /* --- Food pellets --- */ .agents-aquarium-food { @@ -288,7 +349,8 @@ } .agents-aquarium-water, - .agents-aquarium-fish { + .agents-aquarium-fish, + .agents-aquarium-bubble { transition: none; } } diff --git a/src/vs/sessions/contrib/aquarium/browser/sessionPopulationDriver.ts b/src/vs/sessions/contrib/aquarium/browser/sessionPopulationDriver.ts new file mode 100644 index 0000000000000..7369a86c4e9bc --- /dev/null +++ b/src/vs/sessions/contrib/aquarium/browser/sessionPopulationDriver.ts @@ -0,0 +1,199 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; +import { ISession, SessionStatus } from '../../../services/sessions/common/session.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { IAquariumHost, IAquariumPopulationDriver, IFishHandle } from './aquariumOverlay.js'; +import { IAquariumSubmitIntentService } from './aquariumSubmitIntentService.js'; +import { FishStatusVariant } from './fish.js'; + +/** Spawn size of a fish that's growing from a freshly-submitted chat. */ +const GROWTH_START_SIZE = 18; +/** Final size the fish reaches if its session keeps running long enough. */ +const GROWTH_TARGET_SIZE = 56; +/** Wall-clock tween length from {@link GROWTH_START_SIZE} to {@link GROWTH_TARGET_SIZE}. */ +const GROWTH_DURATION_MS = 18000; + +interface ISessionFishEntry { + readonly handle: IFishHandle; + readonly perSession: DisposableStore; + /** Latest status observed via the per-session autorun. */ + lastStatus: SessionStatus; + /** + * True when the fish was spawned via a freshly-consumed submit intent and + * is currently in the grow-from-spawn tween. Cleared the moment the + * session leaves `InProgress` so the tween settles at whatever size the + * fish reached. + */ + isGrowing: boolean; +} + +/** + * Population driver that maps the live, non-archived set of sessions 1:1 to + * fish in the aquarium — mirroring what the sessions sidebar shows by + * default. Status drives color via {@link FishStatusVariant}; add / remove + * animations are handled by the engine via {@link IFishHandle.fadeOut}. + * + * Activity bubbles and submit-grow tweens are layered on top in later phases + * (this driver only deals with population + status). + */ +export class SessionPopulationDriver extends Disposable implements IAquariumPopulationDriver { + + private _host: IAquariumHost | undefined; + + private readonly _entries = new Map(); + /** Per-session disposable that observes archived state so we add/remove fish in sync with the sidebar. */ + private readonly _watchers = new Map(); + + constructor( + @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, + @IAquariumSubmitIntentService private readonly _submitIntentService: IAquariumSubmitIntentService, + @IAccessibilityService private readonly _accessibilityService: IAccessibilityService, + ) { + super(); + } + + attach(host: IAquariumHost): void { + this._host = host; + + for (const session of this._sessionsManagementService.getSessions()) { + this._watchSession(session); + } + + this._register(this._sessionsManagementService.onDidChangeSessions(e => { + for (const removed of e.removed) { + this._unwatchSession(removed.sessionId); + this._removeSession(removed.sessionId); + } + for (const added of e.added) { + this._watchSession(added); + } + // `changed` is reactively handled by per-session autoruns wired in `_addSession`. + })); + } + + override dispose(): void { + for (const watcher of this._watchers.values()) { + watcher.dispose(); + } + this._watchers.clear(); + for (const entry of this._entries.values()) { + entry.perSession.dispose(); + entry.handle.dispose(); + } + this._entries.clear(); + this._host = undefined; + super.dispose(); + } + + /** + * Watch a session's archived state. Archived sessions are hidden from the + * sidebar's default list and so are hidden from the aquarium too; if the + * user unarchives a session its fish reappears. + */ + private _watchSession(session: ISession): void { + if (this._watchers.has(session.sessionId)) { + return; + } + const store = new DisposableStore(); + this._watchers.set(session.sessionId, store); + store.add(autorun(reader => { + const archived = session.isArchived.read(reader); + if (archived) { + this._removeSession(session.sessionId); + } else { + this._addSession(session); + } + })); + } + + private _unwatchSession(sessionId: string): void { + this._watchers.get(sessionId)?.dispose(); + this._watchers.delete(sessionId); + } + + private _addSession(session: ISession): void { + if (!this._host || this._entries.has(session.sessionId)) { + return; + } + // If the user just submitted, opt this fish into the growth tween. + // `consumeIntent` clears the timestamp so a single intent only grows + // one fish even if multiple sessions register in quick succession. + const isGrowing = this._submitIntentService.consumeIntent(); + const motionReduced = this._accessibilityService.isMotionReduced(); + + const handle = this._host.addFish(isGrowing + ? { size: motionReduced ? (GROWTH_START_SIZE + GROWTH_TARGET_SIZE) / 2 : GROWTH_START_SIZE } + : undefined); + if (isGrowing && !motionReduced) { + handle.fish.setTargetSize(GROWTH_TARGET_SIZE, GROWTH_DURATION_MS); + } + + const perSession = new DisposableStore(); + const entry: ISessionFishEntry = { + handle, + perSession, + lastStatus: SessionStatus.Untitled, + isGrowing, + }; + this._entries.set(session.sessionId, entry); + + perSession.add(autorun(reader => { + const status = session.status.read(reader); + const previousStatus = entry.lastStatus; + entry.lastStatus = status; + handle.fish.setStatusVariant(statusToVariant(status)); + // Settle the growth tween the moment the session leaves InProgress + // so a fish that "finished early" doesn't keep growing into a + // completed-state size that doesn't match its semantics. + if (entry.isGrowing && previousStatus === SessionStatus.InProgress && status !== SessionStatus.InProgress) { + entry.isGrowing = false; + handle.fish.setTargetSize(handle.fish.size, 0); + } + })); + + // Activity → bubble. Coalesced via Bubble.setText so identical + // consecutive observable ticks don't reset the dwell on every emit. + perSession.add(autorun(reader => { + const status = session.status.read(reader); + const description = session.description.read(reader); + if (!this._host) { + return; + } + const isLive = status === SessionStatus.InProgress || status === SessionStatus.NeedsInput; + const text = isLive ? (description?.value ?? '').trim() : ''; + this._host.showBubble(handle, text); + })); + } + + private _removeSession(sessionId: string): IDisposable | undefined { + const entry = this._entries.get(sessionId); + if (!entry) { + return undefined; + } + entry.perSession.dispose(); + entry.handle.fadeOut(); + this._entries.delete(sessionId); + return undefined; + } +} + +function statusToVariant(status: SessionStatus): FishStatusVariant | undefined { + switch (status) { + case SessionStatus.InProgress: + return 'running'; + case SessionStatus.NeedsInput: + return 'needs-input'; + case SessionStatus.Error: + return 'error'; + default: + // Untitled / Completed fall through to the species color so the + // aquarium stays colorful — only live or errored fish stand out. + return undefined; + } +} diff --git a/src/vs/sessions/contrib/aquarium/test/browser/aquariumConfiguration.test.ts b/src/vs/sessions/contrib/aquarium/test/browser/aquariumConfiguration.test.ts new file mode 100644 index 0000000000000..9f94a40cd9f88 --- /dev/null +++ b/src/vs/sessions/contrib/aquarium/test/browser/aquariumConfiguration.test.ts @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../../platform/configuration/common/configurationRegistry.js'; +import { Registry } from '../../../../../platform/registry/common/platform.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; + +// Side-effect import: ensures `registerConfiguration` has run before we +// inspect the registry. +import '../../browser/aquarium.contribution.js'; +import { + SESSIONS_DEVELOPER_JOY_AQUARIUM_AS_SESSIONS_SETTING, + SESSIONS_DEVELOPER_JOY_ENABLED_SETTING, +} from '../../browser/aquariumOverlay.js'; + +suite('Aquarium configuration schema', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('public developerJoy.enabled is registered, hidden aquariumAsSessions is NOT', () => { + const registry = Registry.as(ConfigurationExtensions.Configuration); + const properties = registry.getConfigurationProperties(); + assert.deepStrictEqual( + { + publicEnabledRegistered: properties[SESSIONS_DEVELOPER_JOY_ENABLED_SETTING] !== undefined, + hiddenAquariumAsSessionsRegistered: properties[SESSIONS_DEVELOPER_JOY_AQUARIUM_AS_SESSIONS_SETTING] !== undefined, + }, + { + publicEnabledRegistered: true, + hiddenAquariumAsSessionsRegistered: false, + } + ); + }); +}); diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css index 05baa4e1ca12f..97fa5cb117142 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css @@ -64,6 +64,52 @@ } } +/* --- Aquarium-as-sessions collapse mode --- + * When `.aquarium-mode-collapsed` is set without `.revealed`, the chat input + * and workspace picker are already hidden by the existing `.revealed`-gated + * rules above. We just need to surface the transparent click-catcher hotspot + * that fills the empty space so users can summon the chat input back. */ + +.aquarium-compose-hotspot { + display: none; +} + +.new-chat-widget-container.aquarium-mode-collapsed:not(.revealed) > .aquarium-compose-hotspot { + display: flex; + position: absolute; + inset: 0; + margin: 0; + padding: 0; + background: transparent; + border: none; + cursor: pointer; + align-items: center; + justify-content: center; + color: var(--vscode-descriptionForeground); + font-style: italic; + font-size: 12px; + letter-spacing: 0.02em; + opacity: 0.55; + transition: opacity 200ms ease; + z-index: 50; +} + +.new-chat-widget-container.aquarium-mode-collapsed:not(.revealed) > .aquarium-compose-hotspot:hover, +.new-chat-widget-container.aquarium-mode-collapsed:not(.revealed) > .aquarium-compose-hotspot:focus-visible { + opacity: 1; +} + +.new-chat-widget-container.aquarium-mode-collapsed:not(.revealed) > .aquarium-compose-hotspot:focus-visible { + outline: 1px dashed var(--vscode-focusBorder); + outline-offset: -8px; +} + +@media (prefers-reduced-motion: reduce) { + .new-chat-widget-container.aquarium-mode-collapsed:not(.revealed) > .aquarium-compose-hotspot { + transition: none; + } +} + .new-session-workspace-picker-container:empty { display: none; margin-bottom: 0; diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index 22d6bf49e5b65..6af650fd88641 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -600,6 +600,10 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation this._editor?.focus(); } + isInputEmpty(): boolean { + return !this._editor?.getModel()?.getValue(); + } + prefillInput(text: string): void { const editor = this._editor; const model = editor?.getModel(); diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index e866cff5067b1..747ebdb04d5b2 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -9,6 +9,7 @@ import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../ import { derived } from '../../../../base/common/observable.js'; import { isWeb } from '../../../../base/common/platform.js'; import { URI } from '../../../../base/common/uri.js'; +import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; @@ -16,12 +17,15 @@ import { IInstantiationService } from '../../../../platform/instantiation/common import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; 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, IMountedToggleHandle } from '../../aquarium/browser/aquariumOverlay.js'; +import { IAquariumService, IMountedToggleHandle, isAquariumAsSessionsEnabled, SESSIONS_DEVELOPER_JOY_AQUARIUM_AS_SESSIONS_SETTING, SESSIONS_DEVELOPER_JOY_ENABLED_SETTING } from '../../aquarium/browser/aquariumOverlay.js'; +import { IAquariumSubmitIntentService } from '../../aquarium/browser/aquariumSubmitIntentService.js'; +import { SessionPopulationDriver } from '../../aquarium/browser/sessionPopulationDriver.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'; @@ -32,6 +36,8 @@ import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/co // #region --- New Chat Widget --- +type AquariumChatInputTrigger = 'click' | 'submit' | 'outside' | 'esc' | 'scopeChanged'; + class NewChatWidget extends Disposable { private readonly _workspacePicker: WorkspacePicker; @@ -41,6 +47,15 @@ class NewChatWidget extends Disposable { /** Tracks an in-flight wait for a provider's session types to become available. */ private readonly _pendingSessionTypeWait = new MutableDisposable(); + private _chatWidgetContainer: HTMLElement | undefined; + private _aquariumModeActive = false; + private _revealed = true; + private _sessionDriverFactoryApplied = false; + private _hotspotButton: HTMLButtonElement | undefined; + private readonly _hotspotStore = this._register(new MutableDisposable()); + private readonly _aquariumDismissStore = this._register(new MutableDisposable()); + private _autoDismissTimer = this._register(new MutableDisposable()); + constructor( @IInstantiationService private readonly instantiationService: IInstantiationService, @ILogService private readonly logService: ILogService, @@ -48,6 +63,10 @@ class NewChatWidget extends Disposable { @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService, @IAquariumService private readonly aquariumService: IAquariumService, + @IAquariumSubmitIntentService private readonly aquariumSubmitIntentService: IAquariumSubmitIntentService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IAccessibilityService private readonly accessibilityService: IAccessibilityService, + @ITelemetryService private readonly telemetryService: ITelemetryService, ) { super(); // On web (vscode.dev / insiders.vscode.dev), use {@link WebWorkspacePicker} @@ -101,6 +120,7 @@ class NewChatWidget extends Disposable { const element = dom.append(parent, dom.$('.sessions-chat-widget')); const chatWidgetContainer = dom.append(element, dom.$('.new-chat-widget-container')); const chatWidgetContent = dom.append(chatWidgetContainer, dom.$('.new-chat-widget-content')); + this._chatWidgetContainer = chatWidgetContainer; this._aquariumToggle = this._register(this.aquariumService.mountToggle(element)); @@ -119,7 +139,206 @@ class NewChatWidget extends Disposable { this._createNewSession(restoredProject, this._newChatInput.sessionTypePicker.selectedType); } + // Default to the legacy "input visible" experience. The aquarium-mode + // controller below collapses it on first evaluation when the combined + // gate (developerJoy + aquariumAsSessions + agent-host scope) is on. chatWidgetContainer.classList.add('revealed'); + + this._setupAquariumModeController(chatWidgetContainer); + } + + /** + * When the aquarium-as-sessions gate is on, hide the new-chat input + + * workspace picker and surface a transparent click-catcher hotspot so the + * user sees the aquarium first. Reveal on click / focus / keyboard, + * dismiss on submit / outside-click / Esc. + */ + private _setupAquariumModeController(chatWidgetContainer: HTMLElement): void { + const recompute = () => this._recomputeAquariumMode(chatWidgetContainer); + + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(SESSIONS_DEVELOPER_JOY_ENABLED_SETTING) + || e.affectsConfiguration(SESSIONS_DEVELOPER_JOY_AQUARIUM_AS_SESSIONS_SETTING)) { + recompute(); + } + })); + + recompute(); + } + + private _isAquariumModeActive(): boolean { + if (this.configurationService.getValue(SESSIONS_DEVELOPER_JOY_ENABLED_SETTING) !== true) { + return false; + } + return isAquariumAsSessionsEnabled(this.configurationService); + } + + private _recomputeAquariumMode(chatWidgetContainer: HTMLElement): void { + const wasActive = this._aquariumModeActive; + const nowActive = this._isAquariumModeActive(); + this._aquariumModeActive = nowActive; + + chatWidgetContainer.classList.toggle('aquarium-mode-collapsed', nowActive); + + if (nowActive && !this._sessionDriverFactoryApplied) { + this._aquariumToggle?.setDriverFactory(() => this.instantiationService.createInstance(SessionPopulationDriver)); + this._sessionDriverFactoryApplied = true; + this._logPopulationMode('sessions'); + } else if (!nowActive && this._sessionDriverFactoryApplied) { + this._aquariumToggle?.setDriverFactory(undefined); + this._sessionDriverFactoryApplied = false; + this._logPopulationMode('random'); + } + + if (nowActive) { + this._installHotspot(chatWidgetContainer); + this._installAquariumDismissHandlers(chatWidgetContainer); + // On entering aquarium mode (or first eval) start collapsed. + if (!wasActive) { + this._setRevealed(false, chatWidgetContainer, /*focus*/ false, 'scopeChanged'); + } + } else { + this._teardownHotspot(); + this._aquariumDismissStore.clear(); + this._autoDismissTimer.clear(); + // Force the legacy always-visible experience when out of scope. + this._setRevealed(true, chatWidgetContainer, /*focus*/ false, 'scopeChanged'); + } + } + + private _setRevealed(revealed: boolean, chatWidgetContainer: HTMLElement, focus: boolean, trigger: AquariumChatInputTrigger): void { + if (this._revealed === revealed) { + return; + } + this._revealed = revealed; + chatWidgetContainer.classList.toggle('revealed', revealed); + this._logChatInputAction(revealed ? 'reveal' : 'dismiss', trigger); + if (!focus) { + return; + } + if (revealed) { + this._newChatInput.focus(); + } else { + this._hotspotButton?.focus(); + } + } + + private _installHotspot(chatWidgetContainer: HTMLElement): void { + if (this._hotspotStore.value) { + return; + } + const store = new DisposableStore(); + this._hotspotStore.value = store; + const button = dom.$('button.aquarium-compose-hotspot') as HTMLButtonElement; + button.type = 'button'; + // Keep the hotspot above the aquarium toggle's stacking context so + // pointer events on empty space reach the button rather than the + // (decorative, pointer-events:none) aquarium layers. + chatWidgetContainer.appendChild(button); + store.add({ dispose: () => button.remove() }); + store.add(dom.addDisposableListener(button, dom.EventType.CLICK, () => { + if (this._aquariumModeActive) { + this._setRevealed(true, chatWidgetContainer, /*focus*/ true, 'click'); + } + })); + this._hotspotButton = button; + } + + private _teardownHotspot(): void { + this._hotspotStore.clear(); + this._hotspotButton = undefined; + } + + private _installAquariumDismissHandlers(chatWidgetContainer: HTMLElement): void { + if (this._aquariumDismissStore.value) { + return; + } + const store = new DisposableStore(); + this._aquariumDismissStore.value = store; + const targetWindow = dom.getWindow(chatWidgetContainer); + + // Outside-input click → dismiss. The chat widget container fills the + // whole pane, so "outside the container" is unreachable; instead + // dismiss whenever the click target isn't inside the actual input, + // picker, footer, or any floating popup. Use the generic mouse/touch + // helper so taps on iOS / touch surfaces also dismiss. + store.add(dom.addDisposableGenericMouseDownListener(targetWindow.document, (e: MouseEvent) => { + if (!this._aquariumModeActive || !this._revealed) { + return; + } + const target = e.target as HTMLElement | null; + if (!target) { + return; + } + if (target.closest('.new-chat-input-container, .new-session-workspace-picker-container, .new-chat-bottom-container, .monaco-list, .monaco-action-bar, .context-view, .monaco-menu, .monaco-hover, .monaco-quick-input-widget, .agents-aquarium-toggle')) { + return; + } + this._setRevealed(false, chatWidgetContainer, /*focus*/ false, 'outside'); + }, true)); + + // Esc → dismiss. Falls through to Monaco's native handler when the + // input has partial text so the first press still clears it; a + // second press (or a press while empty) backs out to the aquarium. + store.add(dom.addDisposableListener(chatWidgetContainer, dom.EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (e.key !== 'Escape') { + return; + } + if (!this._aquariumModeActive || !this._revealed) { + return; + } + if (!this._newChatInput.isInputEmpty()) { + return; + } + e.stopPropagation(); + this._setRevealed(false, chatWidgetContainer, /*focus*/ true, 'esc'); + }, true)); + } + + private _scheduleAquariumAutoDismiss(): void { + if (!this._aquariumModeActive || !this._chatWidgetContainer) { + return; + } + const container = this._chatWidgetContainer; + const targetWindow = dom.getWindow(container); + this._autoDismissTimer.clear(); + // Brief delay so the user sees the submit-fish spawn before the chat + // input slides away. + const delayMs = this.accessibilityService.isMotionReduced() ? 0 : 250; + const handle = targetWindow.setTimeout(() => { + if (this._aquariumModeActive) { + this._setRevealed(false, container, /*focus*/ false, 'submit'); + } + }, delayMs); + this._autoDismissTimer.value = { dispose: () => targetWindow.clearTimeout(handle) }; + } + + private _logPopulationMode(mode: 'random' | 'sessions'): void { + type AquariumPopulationModeEvent = { + mode: string; + }; + type AquariumPopulationModeClassification = { + mode: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Which population driver is active for the aquarium overlay (random crowd vs 1:1 session mapping).' }; + owner: 'osortega'; + comment: 'Tracks adoption of the hidden "aquarium-as-sessions" developer-joy mode in the Agents new-chat view.'; + }; + this.telemetryService.publicLog2('aquarium.populationMode', { mode }); + } + + private _logChatInputAction(action: 'reveal' | 'dismiss', trigger: AquariumChatInputTrigger): void { + // Only meaningful when the aquarium-mode controller is in scope (the + // non-aquarium force-reveal still flows through here on first eval to + // keep state coherent, but its trigger is `'scopeChanged'`). + type AquariumChatInputEvent = { + action: string; + trigger: string; + }; + type AquariumChatInputClassification = { + action: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the chat input was revealed or dismissed.' }; + trigger: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'What caused the reveal or dismiss (click on hotspot, submit success, outside click, escape, scope change).' }; + owner: 'osortega'; + comment: 'Tracks reveal/dismiss interactions on the aquarium-as-sessions collapsed chat input.'; + }; + this.telemetryService.publicLog2('aquarium.chatInput', { action, trigger }); } /** @@ -217,8 +436,46 @@ class NewChatWidget extends Disposable { this._workspacePicker.showPicker(); return; } + // Record the submit intent BEFORE awaiting so the + // SessionPopulationDriver can consume it the moment the new agent-host + // session is registered. The service is a no-op when the + // sessions-aware aquarium isn't active. + this.aquariumSubmitIntentService.recordIntent(); + + if (this._aquariumModeActive) { + // Aquarium-mode submit: stay on the new-chat / aquarium view rather + // than routing to the chat view. The management service's + // sendAndCreateChat synchronously flips isNewChatSessionContext to + // false (which would briefly swap to the chat view before we could + // swap it back). Talk to the provider directly with `background: + // true` so it sends the request without revealing the chat view, + // then mint a fresh pending new session for the same workspace so + // the next submit creates another chat. + const selectedProject = this._workspacePicker.selectedProject; + const provider = this.sessionsProvidersService.getProvider(session.providerId); + if (provider && selectedProject) { + // Slide the chat input away immediately — the submission + // proceeds in the background and the new session/fish + // appears via the aquarium population driver. Without this + // the input would stay open until the full commit cycle + // (worktree creation, model load, etc.) finishes. + this._scheduleAquariumAutoDismiss(); + try { + await provider.sendAndCreateChat(session.sessionId, { query, attachedContext, background: true }); + this._createNewSession(selectedProject, this._newChatInput.sessionTypePicker.selectedType); + } catch (e) { + this.logService.error('Failed to send request:', e); + } + return; + } + // Fall through to the regular path if we can't determine the workspace. + } + try { await this.sessionsManagementService.sendAndCreateChat(session, { query, attachedContext }); + // In aquarium-mode, slide the chat input back away so the user can + // see the new fish appear from the input area. + this._scheduleAquariumAutoDismiss(); } catch (e) { this.logService.error('Failed to send request:', e); } diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts index ee94a2676fba4..c87e1d596e450 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -1656,82 +1656,102 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions // Claude sessions use the ChatSessionItemController API which creates // real session URIs upfront, bypassing the untitled→commit→swap flow. if (session instanceof ClaudeCodeNewSession) { - return this._sendFirstChatViaController(session, query, sendOptions); + return this._sendFirstChatViaController(session, query, sendOptions, options.background); } await this.chatSessionsService.getOrCreateChatSession(session.resource, CancellationToken.None); - const disposable = await this._applySessionModelState(session.resource, session, permissionLevel); - const chatWidget = await this.chatWidgetService.openSession(session.resource, ChatViewPaneTarget); - disposable.dispose(); - if (!chatWidget) { - throw new Error('[DefaultCopilotProvider] Failed to open chat widget'); - } - - // Send request - this.logService.debug(`[CopilotChatSessionsProvider] Sending first chat for session ${session.id} with options:`, { - userSelectedModelId: sendOptions.userSelectedModelId, - }); - const result = await this.chatService.sendRequest(session.resource, query, sendOptions); - if (result.kind === 'rejected') { - throw new Error(`[DefaultCopilotProvider] sendRequest rejected: ${result.reason}`); - } - - // Extract promises to detect cancellation vs normal completion - const responseCompletePromise = result.kind === 'sent' - ? result.data.responseCompletePromise - : undefined; - const responseCreatedPromise = result.kind === 'sent' - ? result.data.responseCreatedPromise - : undefined; - - // Add the new session to the sessions model immediately so it appears in the sessions list - session.setTitle(localize('new session', "New Session")); - session.setStatus(SessionStatus.InProgress); - const key = session.resource.toString(); - this._sessionCache.set(key, session); - this._invalidateGroupingCaches(); - const newSession = this._chatToSession(session); - this._onDidChangeSessions.fire({ added: [newSession], removed: [], changed: [] }); - + const modelStateRef = await this._applySessionModelState(session.resource, session, permissionLevel); + let modelStateRefDisposed = false; try { + // In background mode the caller keeps its own UI in front (e.g. the + // aquarium-as-sessions new-chat view), so skip the chat-view reveal: + // `viewsService.openView(ChatViewId)` would return null while the + // new-chat view's when-clause is true and the send would otherwise + // fail outright. We also keep our model ref alive across sendRequest + // and the commit wait so the (often untitled) session model isn't + // evicted from ChatModelStore while tools fire during + // createNewChatSessionItem — in normal flow the chat widget holds + // that ref, but here there's no widget to hold it. + if (!options.background) { + const chatWidget = await this.chatWidgetService.openSession(session.resource, ChatViewPaneTarget); + if (!chatWidget) { + throw new Error('[DefaultCopilotProvider] Failed to open chat widget'); + } + // The chat widget acquired its own model ref — release ours. + modelStateRef.dispose(); + modelStateRefDisposed = true; + } - // Wait for the session to be committed (URI swapped from untitled to real) - const committedResource = await this._waitForCommittedSession(session.resource, responseCompletePromise, responseCreatedPromise); + // Send request + this.logService.debug(`[CopilotChatSessionsProvider] Sending first chat for session ${session.id} with options:`, { + userSelectedModelId: sendOptions.userSelectedModelId, + }); + const result = await this.chatService.sendRequest(session.resource, query, sendOptions); + if (result.kind === 'rejected') { + throw new Error(`[DefaultCopilotProvider] sendRequest rejected: ${result.reason}`); + } - // Wait for _refreshSessionCache to populate the committed adapter - const committedChat = await this._waitForSessionInCache(committedResource); + // Extract promises to detect cancellation vs normal completion + const responseCompletePromise = result.kind === 'sent' + ? result.data.responseCompletePromise + : undefined; + const responseCreatedPromise = result.kind === 'sent' + ? result.data.responseCreatedPromise + : undefined; - // Remove the temp from the cache (the adapter now owns the committed key) - this._sessionCache.delete(key); - this._currentNewSession = undefined; - session.dispose(); + // Add the new session to the sessions model immediately so it appears in the sessions list + session.setTitle(localize('new session', "New Session")); + session.setStatus(SessionStatus.InProgress); + const key = session.resource.toString(); + this._sessionCache.set(key, session); + this._invalidateGroupingCaches(); + const newSession = this._chatToSession(session); + this._onDidChangeSessions.fire({ added: [newSession], removed: [], changed: [] }); - const committedSession = this._chatToSession(committedChat); + try { - // Notify listeners that the temp session was replaced by the committed one - this._sessionGroupCache.delete(session.id); - this._onDidReplaceSession.fire({ from: newSession, to: committedSession }); + // Wait for the session to be committed (URI swapped from untitled to real) + const committedResource = await this._waitForCommittedSession(session.resource, responseCompletePromise, responseCreatedPromise); - return committedSession; - } catch (error) { - this._currentNewSession = undefined; + // Wait for _refreshSessionCache to populate the committed adapter + const committedChat = await this._waitForSessionInCache(committedResource); - if (error instanceof CancellationError) { - // Session was stopped before the agent created a worktree. - // Keep the temp session in the list so the user can review - // whatever content the agent produced before cancellation. - session.setStatus(SessionStatus.Completed); - this._onDidChangeSessions.fire({ added: [], removed: [], changed: [newSession] }); - return newSession; - } + // Remove the temp from the cache (the adapter now owns the committed key) + this._sessionCache.delete(key); + this._currentNewSession = undefined; + session.dispose(); + + const committedSession = this._chatToSession(committedChat); + + // Notify listeners that the temp session was replaced by the committed one + this._sessionGroupCache.delete(session.id); + this._onDidReplaceSession.fire({ from: newSession, to: committedSession }); + + return committedSession; + } catch (error) { + this._currentNewSession = undefined; + + if (error instanceof CancellationError) { + // Session was stopped before the agent created a worktree. + // Keep the temp session in the list so the user can review + // whatever content the agent produced before cancellation. + session.setStatus(SessionStatus.Completed); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [newSession] }); + return newSession; + } - // Unexpected error — clean up the temp session entirely - this._sessionCache.delete(key); - this._invalidateGroupingCaches(); - this._sessionGroupCache.delete(session.id); - this._onDidChangeSessions.fire({ added: [], removed: [this._chatToSession(session)], changed: [] }); - session.dispose(); - throw error; + // Unexpected error — clean up the temp session entirely + this._sessionCache.delete(key); + this._invalidateGroupingCaches(); + this._sessionGroupCache.delete(session.id); + this._onDidChangeSessions.fire({ added: [], removed: [this._chatToSession(session)], changed: [] }); + session.dispose(); + throw error; + } + } finally { + if (!modelStateRefDisposed) { + modelStateRef.dispose(); + } } } @@ -1748,6 +1768,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions session: ClaudeCodeNewSession, query: string, sendOptions: IChatSendRequestOptions, + background?: boolean, ): Promise { // Create the real session item via the controller's newChatSessionItemHandler. // This returns a session with a real (non-untitled) URI. @@ -1762,80 +1783,95 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions const realResource = newItem.resource; - // Open chat session and widget with the real URI + // Open chat session and widget with the real URI. In background mode + // the caller keeps its own UI in front, so skip the chat-view reveal + // (see `_sendFirstChat` for the full rationale). Also keep our model + // ref alive across sendRequest and the cache wait so the model isn't + // evicted from ChatModelStore while tools fire during the first turn. await this.chatSessionsService.getOrCreateChatSession(realResource, CancellationToken.None); - const disposable = await this._applySessionModelState(realResource, session, sendOptions.modeInfo?.permissionLevel); - const chatWidget = await this.chatWidgetService.openSession(realResource, ChatViewPaneTarget); - disposable.dispose(); - if (!chatWidget) { - throw new Error('[CopilotChatSessionsProvider] Failed to open chat widget'); - } - - // Send request to the real URI — sendRequest skips the - // createNewChatSessionItem block since the URI is not untitled. - this.logService.debug(`[CopilotChatSessionsProvider] Sending first Claude chat to ${realResource.toString()} with options:`, { - userSelectedModelId: sendOptions.userSelectedModelId, - }); - const result = await this.chatService.sendRequest(realResource, query, sendOptions); - if (result.kind === 'rejected') { - throw new Error(`[CopilotChatSessionsProvider] sendRequest rejected: ${result.reason}`); - } - - // Add the temp session to the cache immediately so it appears in the sessions list - session.setTitle(newItem.label); - session.setStatus(SessionStatus.InProgress); - const tempKey = session.resource.toString(); - this._sessionCache.set(tempKey, session); - const tempSession = this._chatToSession(session); - this._onDidChangeSessions.fire({ added: [tempSession], removed: [], changed: [] }); - - // Extract response promises for cancellation detection - const responseCreatedPromise = result.kind === 'sent' - ? result.data.responseCreatedPromise - : undefined; - const cts = new CancellationTokenSource(); - // TODO: Understand why we are not awaiting this an only handling the cancellation - responseCreatedPromise?.then(r => { - if (r?.isCanceled) { - cts.cancel(); - } - }); - + const modelStateRef = await this._applySessionModelState(realResource, session, sendOptions.modeInfo?.permissionLevel); + let modelStateRefDisposed = false; try { - // Wait for the agent sessions model to pick up the real session, - // racing against cancellation so we don't timeout when the user - // stops the request before the agent creates a worktree. - const committedChat = await this._waitForSessionInCache(realResource, cts.token); + if (!background) { + const chatWidget = await this.chatWidgetService.openSession(realResource, ChatViewPaneTarget); + if (!chatWidget) { + throw new Error('[CopilotChatSessionsProvider] Failed to open chat widget'); + } + // The chat widget acquired its own model ref — release ours. + modelStateRef.dispose(); + modelStateRefDisposed = true; + } - // Clean up temp session and replace with the real adapter - this._sessionCache.delete(tempKey); - this._currentNewSession = undefined; - session.dispose(); + // Send request to the real URI — sendRequest skips the + // createNewChatSessionItem block since the URI is not untitled. + this.logService.debug(`[CopilotChatSessionsProvider] Sending first Claude chat to ${realResource.toString()} with options:`, { + userSelectedModelId: sendOptions.userSelectedModelId, + }); + const result = await this.chatService.sendRequest(realResource, query, sendOptions); + if (result.kind === 'rejected') { + throw new Error(`[CopilotChatSessionsProvider] sendRequest rejected: ${result.reason}`); + } - const committedSession = this._chatToSession(committedChat); - this._sessionGroupCache.delete(session.id); - this._onDidReplaceSession.fire({ from: tempSession, to: committedSession }); + // Add the temp session to the cache immediately so it appears in the sessions list + session.setTitle(newItem.label); + session.setStatus(SessionStatus.InProgress); + const tempKey = session.resource.toString(); + this._sessionCache.set(tempKey, session); + const tempSession = this._chatToSession(session); + this._onDidChangeSessions.fire({ added: [tempSession], removed: [], changed: [] }); + + // Extract response promises for cancellation detection + const responseCreatedPromise = result.kind === 'sent' + ? result.data.responseCreatedPromise + : undefined; + const cts = new CancellationTokenSource(); + // TODO: Understand why we are not awaiting this an only handling the cancellation + responseCreatedPromise?.then(r => { + if (r?.isCanceled) { + cts.cancel(); + } + }); - return committedSession; - } catch (error) { - this._currentNewSession = undefined; + try { + // Wait for the agent sessions model to pick up the real session, + // racing against cancellation so we don't timeout when the user + // stops the request before the agent creates a worktree. + const committedChat = await this._waitForSessionInCache(realResource, cts.token); + + // Clean up temp session and replace with the real adapter + this._sessionCache.delete(tempKey); + this._currentNewSession = undefined; + session.dispose(); + + const committedSession = this._chatToSession(committedChat); + this._sessionGroupCache.delete(session.id); + this._onDidReplaceSession.fire({ from: tempSession, to: committedSession }); + + return committedSession; + } catch (error) { + this._currentNewSession = undefined; + + if (error instanceof CancellationError) { + // Keep the temp session visible so the user can review + // whatever content the agent produced before the cancellation. + session.setStatus(SessionStatus.Completed); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [tempSession] }); + return tempSession; + } - if (error instanceof CancellationError) { - // Keep the temp session visible so the user can review - // whatever content the agent produced before the cancellation. - session.setStatus(SessionStatus.Completed); - this._onDidChangeSessions.fire({ added: [], removed: [], changed: [tempSession] }); - return tempSession; + // Unexpected error — clean up the temp session entirely + this._sessionCache.delete(tempKey); + this._sessionGroupCache.delete(session.id); + this._onDidChangeSessions.fire({ added: [], removed: [tempSession], changed: [] }); + session.dispose(); + throw error; + } finally { + cts.dispose(); } - - // Unexpected error — clean up the temp session entirely - this._sessionCache.delete(tempKey); - this._sessionGroupCache.delete(session.id); - this._onDidChangeSessions.fire({ added: [], removed: [tempSession], changed: [] }); - session.dispose(); - throw error; } finally { - cts.dispose(); + if (!modelStateRefDisposed) { + modelStateRef.dispose(); + } } } diff --git a/src/vs/sessions/services/sessions/common/sessionsProvider.ts b/src/vs/sessions/services/sessions/common/sessionsProvider.ts index e78e5cb3da698..b29eb502f8b13 100644 --- a/src/vs/sessions/services/sessions/common/sessionsProvider.ts +++ b/src/vs/sessions/services/sessions/common/sessionsProvider.ts @@ -26,6 +26,13 @@ export interface ISendRequestOptions { readonly query: string; /** Optional attached context entries. */ readonly attachedContext?: IChatRequestVariableEntry[]; + /** + * When true, the provider should send the request without revealing any + * UI — in particular, without opening the chat view. Used by callers that + * want to keep their own view in front (e.g. the aquarium-as-sessions + * new-chat view). Providers that can't honor this MAY still open UI. + */ + readonly background?: boolean; } /**