From 87790aaafce7ed853970d9188e84309ce3b1dc6f Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Sun, 23 Aug 2026 11:29:55 +0200 Subject: [PATCH 1/7] be more specific --- src/vs/workbench/browser/media/chatPills.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/browser/media/chatPills.css b/src/vs/workbench/browser/media/chatPills.css index 6eb1d1e4a1deb5..c1a76b32a8d1ac 100644 --- a/src/vs/workbench/browser/media/chatPills.css +++ b/src/vs/workbench/browser/media/chatPills.css @@ -56,7 +56,7 @@ min-width: 0; } -.monaco-workbench .chat-pill-icon.codicon { +.monaco-workbench .chat-pill-icon.codicon[class*=codicon-] { display: inline-flex; align-items: center; justify-content: center; From b3a7dc40ed7065f2b14d66ada45c917e1329ae03 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Sun, 23 Aug 2026 09:44:24 -0700 Subject: [PATCH 2/7] Update Copilot instructions to use task tools when available (#332142) --- .github/copilot-instructions.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2d0ce28af58825..eef524185c79f1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -52,7 +52,9 @@ Each extension follows the standard VS Code extension structure with `package.js Choose validation based on the scope and risk of the change. Large-scale builds and typechecking can be slow, and consume significant resources, so minimize their use. Prefer existing editor or watch-task diagnostics and the smallest targeted tests that cover the changed behavior. Do not start build or watch tasks, run broad type checks, or make type checking a prerequisite for targeted tests solely as a completion ritual. -Run a targeted type check or build when you are not fully confident in the change, and the change is broad or cross-cutting, it affects build or type configuration, or another validation step reports a compilation problem. Useful commands include: +When running in a VS Code editor window with a workspace folder, use the VS Code task tools for build and watch workflows: inspect the existing task output first, and run an existing task instead of invoking its equivalent shell command. Do not start a duplicate build or watch process when the workspace task already provides current diagnostics. Agents window chats and isolated worktree sessions may not have access to the editor's workspace tasks; use the repository commands directly in those contexts. + +Run a targeted type check or build when you are not fully confident in the change, and the change is broad or cross-cutting, it affects build or type configuration, or another validation step reports a compilation problem. When task tools are unavailable or no suitable task exists, useful commands include: - `npm run typecheck-client` for the main sources under `src/` - `npm run gulp compile-extensions` for built-in extensions From 47c287090a4f2432f67876b9861666cd7c5eafc1 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:58:55 +0200 Subject: [PATCH 3/7] sessions: hide external Recent sessions superseded by newer local ones (#332174) * sessions: hide external Recent sessions superseded by newer local ones In `Recent` mode the two most recently updated external sessions from the last 7 days are shown. An external session the user has clearly moved on from stayed pinned there regardless of how much local work followed it. Hide an external session once RECENT_EXTERNAL_SUPERSEDING_LOCAL_LIMIT (2) locally created sessions started after its last update, by comparing its modifiedTime against the start time of the 2nd-newest local session. The cutoff is snapshotted rather than derived per listing: sending a first message materializes a local session, so a live cutoff would rotate an external row out of the list mid-use. It is taken on the first Recent listing and refreshed only on an external-sessions mode change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Derive the Recent superseding cutoff from the registry, not hydrated metadata The snapshot was taken from the hydrated `combined` list, which omits sessions whose provider is unavailable or whose metadata read failed, so it could permanently undercount local sessions and leave superseded external rows visible until the mode changed. Derive it from non-external registry entries instead, and commit the snapshot only while the registry epoch still holds so a discarded pass cannot freeze a wrong value. Idle provisional sessions are excluded: they are the composer's placeholder, not sessions the user started. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../platform/agentHost/node/agentService.ts | 75 +++++++-- .../agentHost/test/node/agentService.test.ts | 147 ++++++++++++++++++ .../chat/browser/externalSessionBanner.ts | 4 +- .../browser/externalSessionBanner.test.ts | 2 +- .../chat/browser/chat.shared.contribution.ts | 2 +- 5 files changed, 217 insertions(+), 13 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index f5ae74d46c4dd8..5ea7a6105eb79c 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -97,6 +97,11 @@ const SESSION_GC_GRACE_MS = 30_000; const DAY_MS = 24 * 60 * 60 * 1000; const EXTERNAL_SESSION_MAX_AGE_MS = 30 * DAY_MS; const RECENT_EXTERNAL_SESSION_LIMIT = 2; +/** + * How many locally created sessions must postdate an external session's last + * update before {@link AgentHostExternalSessionsMode.Recent} stops surfacing it. + */ +const RECENT_EXTERNAL_SUPERSEDING_LOCAL_LIMIT = 2; /** A catalog pass slower than this is logged at info, since it delays every session-list refresh. */ const SLOW_LIST_SESSIONS_THRESHOLD_MS = 1_000; @@ -683,6 +688,9 @@ export class AgentService extends Disposable implements IAgentService { if (nextMode !== externalSessionsMode) { const previousMode = externalSessionsMode; externalSessionsMode = nextMode; + // The only point past startup where `Recent` re-measures the + // superseding local sessions. + this._invalidateRecentSupersedingCutoff(); this._logService.info(`[AgentService] ${AgentHostShowExternalSessionsConfigKey} changed '${previousMode}' -> '${nextMode}'; queueing session list reconciliation`); this._queueSessionListReconciliation(previousMode); } @@ -2090,7 +2098,7 @@ export class AgentService extends Disposable implements IAgentService { const combined = additions.length > 0 ? [...withStatus, ...additions] : withStatus; const now = Date.now(); const recentSessionKeys = mode === AgentHostExternalSessionsMode.Recent - ? this._getRecentSessionKeys(combined, now) + ? this._getRecentSessionKeys(combined, now, this._resolveRecentSupersedingCutoff(allRegistered, epoch)) : undefined; const visible: IAgentSessionMetadata[] = []; // Adoptable-legacy rows are withheld by migrate-legacy, not by the external mode. @@ -2153,11 +2161,12 @@ export class AgentService extends Disposable implements IAgentService { return modifiedTime < now - EXTERNAL_SESSION_MAX_AGE_MS; } - private _getRecentSessionKeys(sessions: readonly IAgentSessionMetadata[], now: number): ReadonlySet { + private _getRecentSessionKeys(sessions: readonly IAgentSessionMetadata[], now: number, supersededBefore: number | undefined): ReadonlySet { const recentExternalSessions = sessions .filter(session => readSessionExternal(session._meta) && !readSessionEhcliAdoptable(session._meta) - && session.modifiedTime >= now - 7 * DAY_MS) + && session.modifiedTime >= now - 7 * DAY_MS + && (supersededBefore === undefined || session.modifiedTime >= supersededBefore)) .sort((a, b) => { const timeDifference = b.modifiedTime - a.modifiedTime; if (timeDifference !== 0) { @@ -2171,6 +2180,49 @@ export class AgentService extends Disposable implements IAgentService { return new Set(recentExternalSessions.map(session => session.session.toString())); } + /** + * Start time of the {@link RECENT_EXTERNAL_SUPERSEDING_LOCAL_LIMIT}-th most + * recently created local session, or `undefined` while fewer exist. `Recent` + * drops external sessions last updated before it. + */ + private _recentSupersedingCutoff: number | undefined; + private _hasRecentSupersedingCutoff = false; + + /** + * Snapshots the cutoff from the registry, which — unlike the hydrated + * metadata — never drops a local session because its provider is + * unavailable or its metadata read failed. Sending a first message + * materializes a local session, so a per-listing cutoff would rotate an + * external row out of the list mid-use. Committed only while `epoch` still + * holds, so a discarded pass cannot freeze an undercounted value. + */ + private _resolveRecentSupersedingCutoff(registered: readonly IRegisteredSession[], epoch: number): number | undefined { + if (this._hasRecentSupersedingCutoff) { + return this._recentSupersedingCutoff; + } + // Idle provisional sessions are the composer's eagerly-created + // placeholder, not sessions the user started. + const localStartTimes = registered + .filter(entry => !entry.external + && Number.isFinite(entry.startTime) + && !this._stateManager.isIdleProvisionalSession(entry.session.toString())) + .map(entry => entry.startTime) + .sort((a, b) => b - a); + const cutoff = localStartTimes.length >= RECENT_EXTERNAL_SUPERSEDING_LOCAL_LIMIT + ? localStartTimes[RECENT_EXTERNAL_SUPERSEDING_LOCAL_LIMIT - 1] + : undefined; + if (epoch === this._registryEpoch) { + this._recentSupersedingCutoff = cutoff; + this._hasRecentSupersedingCutoff = true; + } + return cutoff; + } + + private _invalidateRecentSupersedingCutoff(): void { + this._hasRecentSupersedingCutoff = false; + this._recentSupersedingCutoff = undefined; + } + private _shouldIncludeSession( session: IAgentSessionMetadata, mode = this._getExternalSessionsMode(), @@ -2328,7 +2380,7 @@ export class AgentService extends Disposable implements IAgentService { previouslyExposed.add(session); } const listed = previousMode !== undefined - ? this._resolveModeChangeVisibility(await this.listSessions(AgentHostExternalSessionsMode.Last30Days), previousMode, previouslyExposed) + ? await this._resolveModeChangeVisibility(await this.listSessions(AgentHostExternalSessionsMode.Last30Days), previousMode, previouslyExposed) : await this.listSessions(); const visible = new Set(); let published = 0; @@ -2382,14 +2434,20 @@ export class AgentService extends Disposable implements IAgentService { * mode and the mode is just a parameter to {@link _shouldIncludeSession}. * Adds what `previousMode` had exposed into `previouslyExposed`. */ - private _resolveModeChangeVisibility( + private async _resolveModeChangeVisibility( superset: readonly IAgentSessionMetadata[], previousMode: AgentHostExternalSessionsMode, previouslyExposed: Set, - ): IAgentSessionMetadata[] { + ): Promise { const now = Date.now(); - const recentKeysFor = (mode: AgentHostExternalSessionsMode) => mode === AgentHostExternalSessionsMode.Recent - ? this._getRecentSessionKeys(superset, now) + const mode = this._getExternalSessionsMode(); + // The pass above ran as `Last30Days`, so it never snapshotted the cutoff. + const epoch = this._registryEpoch; + const supersededBefore = previousMode === AgentHostExternalSessionsMode.Recent || mode === AgentHostExternalSessionsMode.Recent + ? this._resolveRecentSupersedingCutoff(await this._listRegisteredSessions(), epoch) + : undefined; + const recentKeysFor = (candidate: AgentHostExternalSessionsMode) => candidate === AgentHostExternalSessionsMode.Recent + ? this._getRecentSessionKeys(superset, now, supersededBefore) : undefined; const previousRecentKeys = recentKeysFor(previousMode); @@ -2399,7 +2457,6 @@ export class AgentService extends Disposable implements IAgentService { } } - const mode = this._getExternalSessionsMode(); const recentKeys = recentKeysFor(mode); const visible = superset.filter(session => this._shouldIncludeSession(session, mode, now, recentKeys)); // The pass ran as `Last30Days`, so report the mode actually in effect instead. diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 41673173e367a9..a2cb4864e707f1 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -3130,6 +3130,153 @@ suite('AgentService (node dispatcher)', () => { }); }); + /** An external session two newer local sessions postdate is no longer recent. */ + test('recent drops external sessions that two newer local sessions superseded', () => { + const hour = 60 * 60 * 1000; + const at = (hourOfDay: number) => Date.UTC(2026, 0, 1) + hourOfDay * hour; + const now = at(18); + const external = (id: string, modifiedTime: number): IAgentSessionMetadata => ({ + session: AgentSession.uri('copilot', id), + startTime: modifiedTime, + modifiedTime, + _meta: withSessionExternal(undefined, true), + }); + const local = (id: string, startTime: number): IRegisteredSession => ({ + session: AgentSession.uri('copilot', id), + provider: 'copilot', + startTime, + external: false, + source: 'restore', + }); + const catalog = [external('external-morning', at(10)), external('external-afternoon', at(16))]; + // The cutoff is snapshotted per service, so each case needs its own. + const recentIds = (...locals: IRegisteredSession[]) => { + const svc = createExternalSessionService() as unknown as { + _resolveRecentSupersedingCutoff(registered: readonly IRegisteredSession[], epoch: number): number | undefined; + _getRecentSessionKeys(sessions: readonly IAgentSessionMetadata[], now: number, supersededBefore: number | undefined): ReadonlySet; + _registryEpoch: number; + }; + const cutoff = svc._resolveRecentSupersedingCutoff(locals, svc._registryEpoch); + return [...svc._getRecentSessionKeys(catalog, now, cutoff)].map(key => AgentSession.id(URI.parse(key))).sort(); + }; + + assert.deepStrictEqual({ + noLocalSessionsAfter: recentIds(local('local-8am', at(8)), local('local-9am', at(9))), + oneLocalSessionAfter: recentIds(local('local-11am', at(11))), + twoLocalSessionsAfterTheMorningOne: recentIds(local('local-11am', at(11)), local('local-5pm', at(17))), + twoLocalSessionsAfterBoth: recentIds(local('local-5pm', at(17)), local('local-5pm-2', at(17))), + }, { + noLocalSessionsAfter: ['external-afternoon', 'external-morning'], + oneLocalSessionAfter: ['external-afternoon', 'external-morning'], + twoLocalSessionsAfterTheMorningOne: ['external-afternoon'], + twoLocalSessionsAfterBoth: [], + }); + }); + + /** + * The cutoff reads the registry, not the hydrated listing: a local session + * whose provider is unavailable is dropped from the latter, which would + * undercount and leave a superseded external row visible. + */ + testWithExternalSessionClock('recent counts local sessions the provider cannot hydrate', async () => { + const hour = 60 * 60 * 1000; + const now = Date.now(); + const at = (hourOfDay: number) => now - (18 - hourOfDay) * hour; + const database = new TransientRegistryWriteDatabase(); + for (const [id, startTime] of [['external-morning', at(10)], ['external-afternoon', at(16)]] as const) { + await database.registerSession(AgentSession.uri('copilot', id).toString(), { provider: 'copilot', startTime, source: 'discovery' }, { checkTombstone: true }); + } + // Registered under a provider that is never registered with the service. + for (const [id, startTime] of [['local-11am', at(11)], ['local-5pm', at(17)]] as const) { + await database.registerSession(AgentSession.uri('claude', id).toString(), { provider: 'claude', startTime, source: 'restore' }, { checkTombstone: true }); + } + await database.markProviderBackfilled('copilot'); + + const svc = createExternalSessionService(createSessionDataService(), database); + setExternalSessionsMode(svc, AgentHostExternalSessionsMode.Recent, 1); + await waitForSessionListReconciliation(svc); + const agent = disposables.add(new TimedExternalAgent('copilot')); + agent.addSession('external-morning', at(10)); + agent.addSession('external-afternoon', at(16)); + svc.registerProvider(agent); + + const listed = await svc.listSessions(); + + assert.deepStrictEqual({ + visible: listed.map(session => AgentSession.id(session.session)).sort(), + cutoffCountedUnhydratedLocals: (svc as unknown as { _recentSupersedingCutoff: number | undefined })._recentSupersedingCutoff === at(11), + }, { + visible: ['external-afternoon'], + cutoffCountedUnhydratedLocals: true, + }); + }); + + /** A stale pass must not freeze its cutoff: the registry changed under it. */ + test('recent does not commit a superseding cutoff computed for a stale registry epoch', () => { + const at = (hourOfDay: number) => Date.UTC(2026, 0, 1) + hourOfDay * 60 * 60 * 1000; + const svc = createExternalSessionService() as unknown as { + _resolveRecentSupersedingCutoff(registered: readonly IRegisteredSession[], epoch: number): number | undefined; + _hasRecentSupersedingCutoff: boolean; + _registryEpoch: number; + }; + const locals: IRegisteredSession[] = [at(11), at(17)].map((startTime, index) => ({ + session: AgentSession.uri('copilot', `local-${index}`), + provider: 'copilot', + startTime, + external: false, + source: 'restore', + })); + + const staleCutoff = svc._resolveRecentSupersedingCutoff(locals, svc._registryEpoch - 1); + const committedAfterStalePass = svc._hasRecentSupersedingCutoff; + const currentCutoff = svc._resolveRecentSupersedingCutoff(locals, svc._registryEpoch); + + assert.deepStrictEqual({ staleCutoff, committedAfterStalePass, currentCutoff, committedAfterCurrentPass: svc._hasRecentSupersedingCutoff }, { + staleCutoff: at(11), + committedAfterStalePass: false, + currentCutoff: at(11), + committedAfterCurrentPass: true, + }); + }); + + /** A first message creates a local session, so the cutoff must not re-measure per listing. */ + testWithExternalSessionClock('recent snapshots the superseding local sessions until the external mode changes', async () => { + const hour = 60 * 60 * 1000; + const at = (hourOfDay: number) => Date.now() + hourOfDay * hour - 18 * hour; + const now = at(18); + const svc = createExternalSessionService(); + const internals = svc as unknown as { + _resolveRecentSupersedingCutoff(registered: readonly IRegisteredSession[], epoch: number): number | undefined; + _getRecentSessionKeys(sessions: readonly IAgentSessionMetadata[], now: number, supersededBefore: number | undefined): ReadonlySet; + _registryEpoch: number; + }; + const catalog: IAgentSessionMetadata[] = [ + { session: AgentSession.uri('copilot', 'external-morning'), startTime: at(10), modifiedTime: at(10), _meta: withSessionExternal(undefined, true) }, + { session: AgentSession.uri('copilot', 'external-afternoon'), startTime: at(16), modifiedTime: at(16), _meta: withSessionExternal(undefined, true) }, + ]; + const locals: IRegisteredSession[] = []; + const recentIds = () => { + const cutoff = internals._resolveRecentSupersedingCutoff(locals, internals._registryEpoch); + return [...internals._getRecentSessionKeys(catalog, now, cutoff)].map(key => AgentSession.id(URI.parse(key))).sort(); + }; + + const initial = recentIds(); + for (const id of ['local-first', 'local-second']) { + locals.push({ session: AgentSession.uri('copilot', id), provider: 'copilot', startTime: at(17), external: false, source: 'restore' }); + } + const afterLocalSessionsCreated = recentIds(); + // Invalidation is synchronous; read before the queued reconciliation re-snapshots. + setExternalSessionsMode(svc, AgentHostExternalSessionsMode.Recent, 1); + const afterModeChange = recentIds(); + await waitForSessionListReconciliation(svc); + + assert.deepStrictEqual({ initial, afterLocalSessionsCreated, afterModeChange }, { + initial: ['external-afternoon', 'external-morning'], + afterLocalSessionsCreated: ['external-afternoon', 'external-morning'], + afterModeChange: [], + }); + }); + testWithExternalSessionClock('filters external sessions in every mode', async () => { const day = 24 * 60 * 60 * 1000; const now = Date.now(); diff --git a/src/vs/sessions/contrib/chat/browser/externalSessionBanner.ts b/src/vs/sessions/contrib/chat/browser/externalSessionBanner.ts index 8280e1178a0a5d..e1cc770ecaa000 100644 --- a/src/vs/sessions/contrib/chat/browser/externalSessionBanner.ts +++ b/src/vs/sessions/contrib/chat/browser/externalSessionBanner.ts @@ -66,7 +66,7 @@ export function getExternalSessionVisibilityConfirmation(mode: ChatExternalSessi return { type: 'warning', message, - detail: localize('externalSessionBanner.confirm.recent.detail', "Only the 2 most recently updated external sessions from the last 7 days will be shown. Are you sure you want to save this change?"), + detail: localize('externalSessionBanner.confirm.recent.detail', "Only up to the 2 most recently updated external sessions from the last 7 days will be shown. Are you sure you want to save this change?"), primaryButton, }; } @@ -229,7 +229,7 @@ export class ExternalSessionBanner extends Disposable { mode: ChatExternalSessionsMode.Recent, item: { text: localize('externalSessionBanner.select.recent', "Recent"), - description: localize('externalSessionBanner.select.recent.description', "Show the 2 most recently updated external sessions from the last 7 days."), + description: localize('externalSessionBanner.select.recent.description', "Show up to the 2 most recently updated external sessions from the last 7 days."), }, }, { diff --git a/src/vs/sessions/contrib/chat/test/browser/externalSessionBanner.test.ts b/src/vs/sessions/contrib/chat/test/browser/externalSessionBanner.test.ts index 10a45a6ea21c7b..fa94640825b9f4 100644 --- a/src/vs/sessions/contrib/chat/test/browser/externalSessionBanner.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/externalSessionBanner.test.ts @@ -59,7 +59,7 @@ suite('Sessions - External Session Banner', () => { { type: 'warning', message: 'This session may no longer appear in Code - OSS', - detail: 'Only the 2 most recently updated external sessions from the last 7 days will be shown. Are you sure you want to save this change?', + detail: 'Only up to the 2 most recently updated external sessions from the last 7 days will be shown. Are you sure you want to save this change?', primaryButton: '&&Save Anyway', } ); diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index 167a1ee5b49a6d..ab4f9da5ccd249 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -405,7 +405,7 @@ configurationRegistry.registerConfiguration({ enum: [AgentHostExternalSessionsMode.None, AgentHostExternalSessionsMode.Recent, AgentHostExternalSessionsMode.Last24Hours, AgentHostExternalSessionsMode.Last7Days, AgentHostExternalSessionsMode.Last30Days], enumDescriptions: [ nls.localize('chat.agentSessions.showExternal.none', "Only shows sessions created by the Agent Host."), - nls.localize('chat.agentSessions.showExternal.recent', "Shows the 2 most recently updated external sessions from the last 7 days."), + nls.localize('chat.agentSessions.showExternal.recent', "Shows up to the 2 most recently updated external sessions from the last 7 days, hiding any that you have started 2 newer sessions after."), nls.localize('chat.agentSessions.showExternal.last24Hours', "Shows external sessions updated in the last 24 hours."), nls.localize('chat.agentSessions.showExternal.last7Days', "Shows external sessions updated in the last 7 days."), nls.localize('chat.agentSessions.showExternal.last30Days', "Shows external sessions updated in the last 30 days."), From 62e4ec989dc0bb317b431d9d23a36019ef3c0d5b Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:48:28 +0200 Subject: [PATCH 4/7] Restore persisted platform root config on agent host startup (#332175) * Restore persisted platform root config on agent host startup `persistRootConfig` writes the whole root value bag to `agent-host-config.json`, but `_loadPersistedRootConfig` only restored the customization, sandbox, copilotCli, agentMerge and proxy schema groups. Every `platformRootSchema` key was dropped on load, and the state manager seeds only `permissions` and `telemetryLevel`, so those keys were genuinely absent from root config until a window connected and re-pushed them. The host reads several of them before that happens. `showExternalSessions` fed the first session catalog pass through `?? AgentHostExternalSessionsMode.None`, so a user configured for `last30Days` saw the workbench UI report 30 days while the host logged `none` and hid external sessions until the mirror landed. The same gap affected `codexAgentEnabled` (read during provider registration), `migrateLegacyCopilotCliEnabled` and `editTelemetryEnabled`, and meant hand-edited values in a remote `agent-host-config.json` were ignored. Restore the platform-owned keys alongside the other schema groups. Root `permissions` stays excluded: it mirrors the connected client's managed settings, so reviving a previous run's value could re-grant an allow rule that has since been revoked. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Do not restore client-owned approval settings on host startup Restoring all of `platformRootSchema` was too broad. The schema also holds approval and policy values that the connected client pushes on every connect: `globalAutoApproveEnabled`, `autoApprovePolicyRestricted`, the terminal auto-approve enabled flag and rule set, `editAutoApprovePatterns` and `autoReplyEnabled`. All are persisted from client `RootConfigChanged` actions, and all gate permission prompts. Reviving them on startup carries the same stale re-grant risk already called out for `permissions`: if a user, workspace, or policy tightens one while the host is stopped, the old permissive value comes back and applies to any session that runs before a client reconnects. `terminalAutoApproveEnabled` is worse than a plain staleness bug, since it is deliberately resolved workspace-aware on the client and a persisted global value ignores a workspace that turned approval off. Group these under `clientOwnedApprovalRootConfigKeys` next to the schema that defines them, and skip them when loading. `_forwardClientConfig` re-pushes every one on connect and reconnect, so nothing is lost by waiting; falling back to the restrictive schema default is the fail-safe direction. Operator-owned values such as `mcpServers`, which no client pushes, still restore so a hand-edited remote `agent-host-config.json` keeps working. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/common/agentHostSchema.ts | 20 ++++++ .../node/agentConfigurationService.ts | 20 +++++- .../node/agentConfigurationService.test.ts | 63 ++++++++++++++++++- 3 files changed, 101 insertions(+), 2 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts index 834ed14151cd8b..9aecd6316e222b 100644 --- a/src/vs/platform/agentHost/common/agentHostSchema.ts +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -859,3 +859,23 @@ export const platformRootSchema = createSchema({ default: {}, }), }); + +/** + * Root config keys the connected client re-pushes on every connect and + * reconnect, and which gate permission prompts or policy restrictions. + * + * These must NOT be restored from `agent-host-config.json` on startup. Their + * persisted value is a snapshot of one client's settings, so reviving it would + * re-grant approvals that a user, workspace, or policy tightened while the host + * was stopped. Falling back to the schema default until the client republishes + * is the fail-safe direction. + */ +export const clientOwnedApprovalRootConfigKeys: ReadonlySet = new Set([ + SessionConfigKey.Permissions, + AgentHostGlobalAutoApproveEnabledConfigKey, + AgentHostAutoApprovePolicyRestrictedConfigKey, + AgentHostTerminalAutoApproveEnabledConfigKey, + AgentHostTerminalAutoApproveRulesConfigKey, + AgentHostEditAutoApprovePatternsConfigKey, + AgentHostAutoReplyEnabledConfigKey, +]); diff --git a/src/vs/platform/agentHost/node/agentConfigurationService.ts b/src/vs/platform/agentHost/node/agentConfigurationService.ts index 291cbd7a775765..ac64d224697b95 100644 --- a/src/vs/platform/agentHost/node/agentConfigurationService.ts +++ b/src/vs/platform/agentHost/node/agentConfigurationService.ts @@ -16,7 +16,7 @@ import { getAgentCustomizationSettingsEntries, getProviderBackedRootConfigKeys, import { copilotCliConfigSchema } from '../common/copilotCliConfig.js'; import { agentMergeRootConfigSchema } from '../common/agentMerge.js'; import { sandboxConfigSchema } from '../common/sandboxConfigSchema.js'; -import { agentHostProxyConfigSchema, type ISchema, type SchemaDefinition, type SchemaValue } from '../common/agentHostSchema.js'; +import { agentHostProxyConfigSchema, clientOwnedApprovalRootConfigKeys, platformRootSchema, type ISchema, type SchemaDefinition, type SchemaValue } from '../common/agentHostSchema.js'; import { ProtocolError } from '../common/state/sessionProtocol.js'; import { ActionType, type ActionOrigin } from '../common/state/sessionActions.js'; import { isAhpChatChannel, parseSubagentSessionUri, ROOT_STATE_URI, type URI as ProtocolURI } from '../common/state/sessionState.js'; @@ -407,6 +407,7 @@ export class AgentConfigurationService extends Disposable implements IAgentConfi const raw = fs.readFileSync(this._rootConfigResource.fsPath, 'utf8'); const parsed = JSON.parse(raw) as Record; return { + ...this._loadPersistedPlatformRootConfig(parsed), ...agentHostCustomizationConfigSchema.validateOrDefault(parsed, defaults), ...sandboxConfigSchema.validateOrDefault(parsed, {}), ...copilotCliConfigSchema.validateOrDefault(parsed, {}), @@ -421,4 +422,21 @@ export class AgentConfigurationService extends Disposable implements IAgentConfi return { ...defaults }; } } + + /** + * Restores the platform-owned half of the persisted bag. The host reads + * some of these before any client connects (`showExternalSessions`, the + * migrate-legacy gate, provider enablement), so without this a restart + * runs its first pass against the schema default. + */ + private _loadPersistedPlatformRootConfig(parsed: Record): Record { + const values: Record = { ...platformRootSchema.validateOrDefault(parsed, {}) }; + // Approval and policy values are a snapshot of one client's settings and + // are re-pushed on every connect, so restoring them could re-grant an + // approval that was tightened while the host was stopped. + for (const key of clientOwnedApprovalRootConfigKeys) { + delete values[key]; + } + return values; + } } diff --git a/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts b/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts index 1402fe5b17f368..53be40936030b6 100644 --- a/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts @@ -11,8 +11,9 @@ import { join } from '../../../../base/common/path.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; -import { AgentHostProxyConfigKey, createSchema, schemaProperty } from '../../common/agentHostSchema.js'; +import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostAutoReplyEnabledConfigKey, AgentHostEditAutoApprovePatternsConfigKey, AgentHostExternalSessionsMode, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostMcpServersConfigKey, AgentHostProxyConfigKey, AgentHostShowExternalSessionsConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, clientOwnedApprovalRootConfigKeys, createSchema, platformRootSchema, schemaProperty } from '../../common/agentHostSchema.js'; import { AGENT_CUSTOMIZATION_SETTINGS_META_KEY, getAgentCustomizationSettingsEntries } from '../../common/agentCustomizationSettings.js'; +import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import type { RootConfigState } from '../../common/state/protocol/state.js'; import { ActionType } from '../../common/state/sessionActions.js'; import { buildChatUri, buildSubagentSessionUri, SessionStatus, type SessionSummary } from '../../common/state/sessionState.js'; @@ -284,6 +285,66 @@ suite('AgentConfigurationService', () => { fs.rmSync(directory, { recursive: true, force: true }); }); + test('restores persisted platform root settings when the host restarts', async () => { + const directory = fs.mkdtempSync(join(os.tmpdir(), 'agent-config-')); + const resource = URI.file(join(directory, 'agent-host-config.json')); + const firstManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const firstService = disposables.add(new AgentConfigurationService(firstManager, new NullLogService(), resource)); + firstService.updateRootConfig({ + [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.Last30Days, + [AgentHostMcpServersConfigKey]: { operatorServer: { command: 'node' } }, + }); + await firstService.whenIdle(); + + const restartedManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const restartedService = disposables.add(new AgentConfigurationService(restartedManager, new NullLogService(), resource)); + + assert.deepStrictEqual({ + showExternalSessions: restartedService.getRootValue(platformRootSchema, AgentHostShowExternalSessionsConfigKey), + mcpServers: restartedService.getRootValue(platformRootSchema, AgentHostMcpServersConfigKey), + }, { + showExternalSessions: AgentHostExternalSessionsMode.Last30Days, + mcpServers: { operatorServer: { command: 'node' } }, + }); + fs.rmSync(directory, { recursive: true, force: true }); + }); + + test('does not restore client-owned approval settings when the host restarts', async () => { + const directory = fs.mkdtempSync(join(os.tmpdir(), 'agent-config-')); + const resource = URI.file(join(directory, 'agent-host-config.json')); + const firstManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const firstService = disposables.add(new AgentConfigurationService(firstManager, new NullLogService(), resource)); + // A permissive snapshot that a user, workspace, or policy could tighten + // while the host is stopped. + firstService.updateRootConfig({ + [SessionConfigKey.Permissions]: { allow: ['revoked-rule'], deny: [] }, + [AgentHostGlobalAutoApproveEnabledConfigKey]: true, + [AgentHostAutoApprovePolicyRestrictedConfigKey]: false, + [AgentHostTerminalAutoApproveEnabledConfigKey]: true, + [AgentHostTerminalAutoApproveRulesConfigKey]: { rm: true }, + [AgentHostEditAutoApprovePatternsConfigKey]: { '**/*': true }, + [AgentHostAutoReplyEnabledConfigKey]: true, + }); + await firstService.whenIdle(); + + const persisted = JSON.parse(fs.readFileSync(resource.fsPath, 'utf8')) as Record; + const restartedManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const restartedService = disposables.add(new AgentConfigurationService(restartedManager, new NullLogService(), resource)); + const restored = restartedService.getRootConfigValues(); + + assert.deepStrictEqual({ + persistedKeys: [...clientOwnedApprovalRootConfigKeys].filter(key => persisted[key] !== undefined).sort(), + // The state manager seeds empty permissions; nothing else survives. + restoredKeys: [...clientOwnedApprovalRootConfigKeys].filter(key => restored[key] !== undefined).sort(), + permissions: restored[SessionConfigKey.Permissions], + }, { + persistedKeys: [...clientOwnedApprovalRootConfigKeys].sort(), + restoredKeys: [SessionConfigKey.Permissions], + permissions: { allow: [], deny: [] }, + }); + fs.rmSync(directory, { recursive: true, force: true }); + }); + test('seeds provider configuration into the initial root snapshot', () => { const localManager = disposables.add(new AgentHostStateManager(new NullLogService())); disposables.add(new AgentConfigurationService(localManager, new NullLogService(), undefined, [{ From 40f27cc166304afa356ab59fea79468e23113fce Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Sun, 23 Aug 2026 10:56:02 -0700 Subject: [PATCH 5/7] reasoning ux: fix fixed scrolling headers (#332143) * reasoning ux: fix fixed scrolling headers * Fix streamed fixed-scrolling title Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6698ab91-2513-4857-a5cc-bd080bf813b6 --------- Copilot-Session: 6698ab91-2513-4857-a5cc-bd080bf813b6 --- .../chatThinkingContentPart.ts | 10 ++- .../chatThinkingContentPart.test.ts | 83 +++++++++++++++++++ 2 files changed, 89 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts index 9bcc5099799422..4fad396ce484e7 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -971,14 +971,13 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen } // Multi-header reasoning summaries render each header section as its own - // row so the dropdown reads as a list. Fixed-scrolling keeps its single - // auto-scrolling block. Sibling rows need an attached container so their + // row so the dropdown reads as a list. Sibling rows need an attached container so their // insertion isn't a no-op, so a detached (lazy) container falls through to // single-block rendering until it is materialized. A block drops its leading // header only when that header is the tracked title owner, so a grouped block // never drops a header that isn't surfaced as the title. const dropLeadingHeader = this.droppedSummaryHeader !== undefined && extractTitleFromThinkingContent(cleanedContent) === this.droppedSummaryHeader; - const summaryRows = this.fixedScrollingMode ? undefined : splitReasoningSummaryRows(cleanedContent, dropLeadingHeader); + const summaryRows = splitReasoningSummaryRows(cleanedContent, dropLeadingHeader); if (summaryRows && this.textContainer?.parentNode) { this.renderSummaryRows(summaryRows); return; @@ -1097,8 +1096,11 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen return; } const trimmed = value.trim(); - if (!this.fixedScrollingMode && splitReasoningSummaryRows(trimmed, true)) { + if (splitReasoningSummaryRows(trimmed, true)) { this.droppedSummaryHeader = extractTitleFromThinkingContent(trimmed); + if (this.fixedScrollingMode && this.droppedSummaryHeader && this.currentTitle !== this.droppedSummaryHeader) { + this.setTitle(this.droppedSummaryHeader); + } } } diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts index 240db5ac402bbc..62332e70f5794a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts @@ -511,6 +511,89 @@ suite('ChatThinkingContentPart', () => { assert.ok(scrollable, 'Should have scrollable container'); }); + test('splits summary headers as they stream without replacing the scroll container', () => { + const markdownRenderer: IMarkdownRenderer = { + render: (markdown, options, target) => renderMarkdown(markdown, options, target), + }; + const firstSummary = '**Evaluating issue and PR status**'; + const content = createThinkingPart('**Evaluating issue and PR sta'); + const part = store.add(instantiationService.createInstance( + ChatThinkingContentPart, + content, + createMockRenderContext(false), + markdownRenderer, + false + )); + + mainWindow.document.body.appendChild(part.domNode); + disposables.add(toDisposable(() => part.domNode.remove())); + const scrollable = part.domNode.querySelector('.monaco-scrollable-element'); + const firstRow = part.domNode.querySelector('.chat-thinking-item.markdown-content'); + const button = part.domNode.querySelector('.monaco-button'); + const initialTitle = button?.textContent?.trim(); + + part.updateThinking(createThinkingPart( + `${firstSummary}\n\n**Analyzing code fix and lifecycle nuances**`, + content.id + )); + part.updateThinking(createThinkingPart( + `${firstSummary}\n\n**Analyzing code fix and lifecycle nuances**\n\n**Evaluating PR merge status**`, + content.id + )); + + const rows = Array.from(part.domNode.querySelectorAll('.chat-thinking-item.markdown-content')); + assert.deepStrictEqual({ + scrollContainerPreserved: part.domNode.querySelector('.monaco-scrollable-element') === scrollable, + firstRowPreserved: rows[0] === firstRow, + rowTexts: rows.map(row => row.textContent?.trim()), + hasLiteralMarkers: part.domNode.textContent?.includes('**') ?? false, + initialTitle, + streamingTitle: button?.textContent?.trim(), + streamingAriaLabel: button?.ariaLabel, + }, { + scrollContainerPreserved: true, + firstRowPreserved: true, + rowTexts: ['Analyzing code fix and lifecycle nuances', 'Evaluating PR merge status'], + hasLiteralMarkers: false, + initialTitle: 'Thinking', + streamingTitle: 'Thinking: Evaluating issue and PR status', + streamingAriaLabel: 'Thinking: Evaluating issue and PR status', + }); + }); + + test('splits restored summary headers when fixed scrolling is expanded', () => { + const markdownRenderer: IMarkdownRenderer = { + render: (markdown, options, target) => renderMarkdown(markdown, options, target), + }; + const content = createThinkingPart([ + '**Evaluating issue and PR status**', + '**Analyzing code fix and lifecycle nuances**', + '**Evaluating PR merge status**', + ].join('\n\n')); + const part = store.add(instantiationService.createInstance( + ChatThinkingContentPart, + content, + createMockRenderContext(true), + markdownRenderer, + true + )); + + mainWindow.document.body.appendChild(part.domNode); + disposables.add(toDisposable(() => part.domNode.remove())); + part.domNode.querySelector('.monaco-button')?.click(); + + const rows = Array.from(part.domNode.querySelectorAll('.chat-thinking-item.markdown-content')); + assert.deepStrictEqual({ + hasScrollContainer: !!part.domNode.querySelector('.monaco-scrollable-element'), + rowTexts: rows.map(row => row.textContent?.trim()), + title: part.domNode.querySelector('.chat-used-context-label .monaco-button')?.textContent?.trim(), + }, { + hasScrollContainer: true, + rowTexts: ['Analyzing code fix and lifecycle nuances', 'Evaluating PR merge status'], + title: 'Evaluating issue and PR status', + }); + }); + test('should collapse without animation when streaming completes', async () => { const content = createThinkingPart('**Content with scrolling**'); const context = createMockRenderContext(false); From 60ef009ffa2c6c2bdc27cdf07e0656eafdd5bc9d Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Sun, 23 Aug 2026 12:38:25 -0700 Subject: [PATCH 6/7] Show Update in Agents and protect active sessions (#332140) * chat: confirm before stopping active agent sessions Show Update in the Agents window during active sessions and guard quit, last-window close, and update restart while local Agent Host work is starting or running. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: track in-flight first requests Keep management-owned first-request state across foreground, background, headless, and quick-chat sends so shutdown protection does not depend on draft visibility or provider catalog publication. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: make update visibility platform neutral Test the shared additional-placement context expression directly so browser suites do not instantiate the Electron-only update contribution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: retain concurrent in-flight requests Reference-count first requests by session resource so one concurrent send cannot clear shutdown protection while another is still starting. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../localAgentHostLifecycle.contribution.ts | 71 ++++++++ .../localAgentHostLifecycle.test.ts | 132 +++++++++++++++ .../browser/sessionsManagementService.ts | 89 +++++++---- .../sessions/common/sessionsManagement.ts | 5 + .../test/browser/sessionNavigation.test.ts | 1 + .../browser/sessionsManagementService.test.ts | 151 +++++++++++++++++- src/vs/sessions/sessions.desktop.main.ts | 1 + .../chat/common/chatService/chatService.ts | 5 + .../common/chatService/chatServiceImpl.ts | 10 ++ .../electron-browser/chat.contribution.ts | 45 ++---- .../chat/electron-browser/chatLifecycle.ts | 36 +++++ .../common/chatService/chatService.test.ts | 22 +++ .../common/chatService/mockChatService.ts | 1 + .../electron-browser/chatLifecycle.test.ts | 74 ++++++++- .../update/browser/updateTitleBarEntry.ts | 8 +- .../test/browser/updateTitleBarEntry.test.ts | 25 ++- 16 files changed, 602 insertions(+), 74 deletions(-) create mode 100644 src/vs/sessions/contrib/providers/agentHost/electron-browser/localAgentHostLifecycle.contribution.ts create mode 100644 src/vs/sessions/contrib/providers/agentHost/test/electron-browser/localAgentHostLifecycle.test.ts diff --git a/src/vs/sessions/contrib/providers/agentHost/electron-browser/localAgentHostLifecycle.contribution.ts b/src/vs/sessions/contrib/providers/agentHost/electron-browser/localAgentHostLifecycle.contribution.ts new file mode 100644 index 00000000000000..70901cfdcc7f76 --- /dev/null +++ b/src/vs/sessions/contrib/providers/agentHost/electron-browser/localAgentHostLifecycle.contribution.ts @@ -0,0 +1,71 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; +import { isMacintosh } from '../../../../../base/common/platform.js'; +import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { INativeHostService } from '../../../../../platform/native/common/native.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../workbench/common/contributions.js'; +import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { confirmSessionShutdown, getEffectiveSessionShutdownReason } from '../../../../../workbench/contrib/chat/electron-browser/chatLifecycle.js'; +import { IChatEntitlementService } from '../../../../../workbench/services/chat/common/chatEntitlementService.js'; +import { INativeWorkbenchEnvironmentService } from '../../../../../workbench/services/environment/electron-browser/environmentService.js'; +import { ILifecycleService, ShutdownReason } from '../../../../../workbench/services/lifecycle/common/lifecycle.js'; +import { LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../common/agentHostSessionsProvider.js'; +import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; +import { isActiveSessionStatus } from '../../../../services/sessions/common/session.js'; + +export class LocalAgentHostLifecycleContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'sessions.contrib.localAgentHostLifecycle'; + + constructor( + @ILifecycleService lifecycleService: ILifecycleService, + @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + @IDialogService private readonly dialogService: IDialogService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @INativeHostService private readonly nativeHostService: INativeHostService, + @INativeWorkbenchEnvironmentService private readonly environmentService: INativeWorkbenchEnvironmentService, + @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, + ) { + super(); + + this._register(lifecycleService.onBeforeShutdown(event => { + event.veto(this.shouldVetoShutdown(event.reason), 'veto.sessions.localAgentHost'); + })); + } + + private hasActiveSession(): boolean { + if (this.sessionsManagementService.getInFlightNewSessionRequests().some(session => session.providerId === LOCAL_AGENT_HOST_PROVIDER_ID)) { + return true; + } + + const provider = this.sessionsProvidersService.getProvider(LOCAL_AGENT_HOST_PROVIDER_ID); + return provider?.getSessions().some(session => !session.isArchived.get() && isActiveSessionStatus(session.status.get())) === true; + } + + private async shouldVetoShutdown(reason: ShutdownReason): Promise { + if (this.environmentService.enableSmokeTestDriver || this.chatEntitlementService.sentiment.hidden) { + return false; + } + + const windowCount = reason === ShutdownReason.CLOSE ? await this.nativeHostService.getWindowCount() : 0; + const effectiveReason = getEffectiveSessionShutdownReason(reason, windowCount, isMacintosh); + if (effectiveReason !== ShutdownReason.QUIT || !this.hasActiveSession()) { + return false; + } + + if (ChatContextKeys.skipChatRequestInProgressMessage.getValue(this.contextKeyService) === true) { + return false; + } + + return !await confirmSessionShutdown(this.dialogService, effectiveReason); + } +} + +registerWorkbenchContribution2(LocalAgentHostLifecycleContribution.ID, LocalAgentHostLifecycleContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/electron-browser/localAgentHostLifecycle.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/electron-browser/localAgentHostLifecycle.test.ts new file mode 100644 index 00000000000000..f20df21a42688b --- /dev/null +++ b/src/vs/sessions/contrib/providers/agentHost/test/electron-browser/localAgentHostLifecycle.test.ts @@ -0,0 +1,132 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { constObservable } from '../../../../../../base/common/observable.js'; +import { isMacintosh } from '../../../../../../base/common/platform.js'; +import { upcastPartial } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { MockContextKeyService } from '../../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { INativeHostService } from '../../../../../../platform/native/common/native.js'; +import { IChatEntitlementService } from '../../../../../../workbench/services/chat/common/chatEntitlementService.js'; +import { INativeWorkbenchEnvironmentService } from '../../../../../../workbench/services/environment/electron-browser/environmentService.js'; +import { ILifecycleService, InternalBeforeShutdownEvent, ShutdownReason } from '../../../../../../workbench/services/lifecycle/common/lifecycle.js'; +import { TestLifecycleService } from '../../../../../../workbench/test/common/workbenchTestServices.js'; +import { LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../../common/agentHostSessionsProvider.js'; +import { ISessionsProvidersService } from '../../../../../services/sessions/browser/sessionsProvidersService.js'; +import { ISession, SessionStatus } from '../../../../../services/sessions/common/session.js'; +import { ISessionsManagementService } from '../../../../../services/sessions/common/sessionsManagement.js'; +import { ISessionsProvider } from '../../../../../services/sessions/common/sessionsProvider.js'; +import { LocalAgentHostLifecycleContribution } from '../../electron-browser/localAgentHostLifecycle.contribution.js'; + +suite('Local Agent Host Lifecycle', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function createSession(status: SessionStatus, archived = false): ISession { + return upcastPartial({ + status: constObservable(status), + isArchived: constObservable(archived), + }); + } + + async function runShutdown( + sessions: readonly ISession[], + confirmed: boolean, + reason = ShutdownReason.QUIT, + windowCount = 1, + inFlightSessions: readonly ISession[] = [], + ): Promise<{ readonly confirmations: number; readonly vetoes: readonly boolean[] }> { + const lifecycleService = store.add(new TestLifecycleService()); + const instantiationService = store.add(new TestInstantiationService()); + const provider = upcastPartial({ + id: LOCAL_AGENT_HOST_PROVIDER_ID, + getSessions: () => [...sessions], + }); + let confirmations = 0; + + instantiationService.stub(ILifecycleService, lifecycleService); + instantiationService.stub(ISessionsProvidersService, upcastPartial({ + getProvider: (providerId: string) => providerId === LOCAL_AGENT_HOST_PROVIDER_ID ? provider as T : undefined, + })); + instantiationService.stub(ISessionsManagementService, upcastPartial({ + getInFlightNewSessionRequests: () => inFlightSessions, + })); + instantiationService.stub(IDialogService, upcastPartial({ + confirm: async () => { + confirmations++; + return { confirmed }; + }, + })); + instantiationService.stub(IContextKeyService, new MockContextKeyService()); + instantiationService.stub(INativeHostService, upcastPartial({ + getWindowCount: async () => windowCount, + })); + instantiationService.stub(INativeWorkbenchEnvironmentService, upcastPartial({ + enableSmokeTestDriver: false, + })); + instantiationService.stub(IChatEntitlementService, upcastPartial({ + sentiment: {}, + })); + store.add(instantiationService.createInstance(LocalAgentHostLifecycleContribution)); + + const vetoes: Promise[] = []; + lifecycleService.fireBeforeShutdown(upcastPartial({ + reason, + veto: value => vetoes.push(Promise.resolve(value)), + })); + + const resolvedVetoes = await Promise.all(vetoes); + return { confirmations, vetoes: resolvedVetoes }; + } + + test('prompts for active local Agent Host sessions', async () => { + assert.deepStrictEqual({ + inProgressConfirmed: await runShutdown([createSession(SessionStatus.InProgress)], true), + needsInputCancelled: await runShutdown([createSession(SessionStatus.NeedsInput)], false), + }, { + inProgressConfirmed: { confirmations: 1, vetoes: [false] }, + needsInputCancelled: { confirmations: 1, vetoes: [true] }, + }); + }); + + test('prompts for an in-flight request before it enters the provider catalog', async () => { + const draft = upcastPartial({ + ...createSession(SessionStatus.InProgress), + providerId: LOCAL_AGENT_HOST_PROVIDER_ID, + }); + + assert.deepStrictEqual(await runShutdown([], true, ShutdownReason.QUIT, 1, [draft]), { + confirmations: 1, + vetoes: [false], + }); + }); + + (isMacintosh ? test.skip : test)('prompts only when closing the last Windows/Linux window', async () => { + const activeSession = createSession(SessionStatus.InProgress); + + assert.deepStrictEqual({ + editorClosesFirst: await runShutdown([activeSession], true, ShutdownReason.CLOSE, 2), + agentsClosesLast: await runShutdown([activeSession], true, ShutdownReason.CLOSE, 1), + }, { + editorClosesFirst: { confirmations: 0, vetoes: [false] }, + agentsClosesLast: { confirmations: 1, vetoes: [false] }, + }); + }); + + test('does not prompt for inactive local Agent Host sessions', async () => { + assert.deepStrictEqual({ + completed: await runShutdown([createSession(SessionStatus.Completed)], true), + archived: await runShutdown([createSession(SessionStatus.InProgress, true)], true), + empty: await runShutdown([], true), + }, { + completed: { confirmations: 0, vetoes: [false] }, + archived: { confirmations: 0, vetoes: [false] }, + empty: { confirmations: 0, vetoes: [false] }, + }); + }); +}); diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index 73dc8bb2be39bb..c4a966c5503862 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -7,7 +7,7 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { raceCancellationError } from '../../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { CancellationError } from '../../../../base/common/errors.js'; -import { Disposable, DisposableMap, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../base/common/map.js'; import { IObservable, observableValue } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; @@ -85,6 +85,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa private readonly _providerListeners = this._register(new DisposableMap()); private readonly _disposeCts = this._register(new CancellationTokenSource()); private readonly _unlistedNewSessions = new ResourceMap(); + private readonly _inFlightNewSessionRequests = new ResourceMap<{ readonly session: ISession; count: number }>(); /** * Chat resources for which this service has just kicked off a @@ -226,6 +227,28 @@ export class SessionsManagementService extends Disposable implements ISessionsMa return this._dedupeMigratedCopilotCliSessions(this._getMergedSessions()); } + getInFlightNewSessionRequests(): readonly ISession[] { + return Array.from(this._inFlightNewSessionRequests.values(), entry => entry.session); + } + + private trackInFlightNewSessionRequest(session: ISession): IDisposable { + const entry = this._inFlightNewSessionRequests.get(session.resource); + if (entry) { + entry.count++; + } else { + this._inFlightNewSessionRequests.set(session.resource, { session, count: 1 }); + } + + return toDisposable(() => { + const current = this._inFlightNewSessionRequests.get(session.resource); + if (current?.count === 1) { + this._inFlightNewSessionRequests.delete(session.resource); + } else if (current) { + current.count--; + } + }); + } + private _getMergedSessions(): ISession[] { const sessions: ISession[] = []; for (const provider of this.sessionsProvidersService.getProviders()) { @@ -692,42 +715,51 @@ export class SessionsManagementService extends Disposable implements ISessionsMa throw new Error(`Sessions provider '${session.providerId}' not found`); } + const isNewSessionRequest = session.status.get() === SessionStatus.Untitled; + const inFlightRequest = isNewSessionRequest ? this.trackInFlightNewSessionRequest(session) : undefined; + if (options.background) { // Fire-and-forget so the composer can reset immediately. On commit // failure the graduating draft is stranded, so dispose it through // its provider (no-op if already graduated/removed). - this._sendNewChatRequestInBackground(provider, session, options).catch(e => { - provider.deleteNewSession(session.sessionId); - this.logService.error('[SessionsManagement] Failed to send background request:', e); - }); + this._sendNewChatRequestInBackground(provider, session, options) + .catch(e => { + provider.deleteNewSession(session.sessionId); + this.logService.error('[SessionsManagement] Failed to send background request:', e); + }) + .finally(() => inFlightRequest?.dispose()); return; } - // Foreground send: notify listeners that a send is starting. Listeners - // (e.g., telemetry) can use this to prewarm caches whose result is - // consumed when `onDidSendRequest` fires below. The background path - // fires this from within `_sendNewChatRequestInBackground`. The view - // service observes the will/did send pair to keep the newest chat - // active in the visible slot while the send materialises. - this._onWillSendRequest.fire(session); - - // Ask the provider to create the new chat, then send the request. - const chat = await provider.createNewChat(session.sessionId, options.query); - - const sendOptions = this._augmentOptionsForTroubleshoot(session, options); - const chatResourceKey = chat.resource.toString(); - this._pendingSendChatResources.add(chatResourceKey); - let updatedSession: ISession; try { - updatedSession = await provider.sendRequest(session.sessionId, chat.resource, sendOptions); + // Foreground send: notify listeners that a send is starting. Listeners + // (e.g., telemetry) can use this to prewarm caches whose result is + // consumed when `onDidSendRequest` fires below. The background path + // fires this from within `_sendNewChatRequestInBackground`. The view + // service observes the will/did send pair to keep the newest chat + // active in the visible slot while the send materialises. + this._onWillSendRequest.fire(session); + + // Ask the provider to create the new chat, then send the request. + const chat = await provider.createNewChat(session.sessionId, options.query); + + const sendOptions = this._augmentOptionsForTroubleshoot(session, options); + const chatResourceKey = chat.resource.toString(); + this._pendingSendChatResources.add(chatResourceKey); + let updatedSession: ISession; + try { + updatedSession = await provider.sendRequest(session.sessionId, chat.resource, sendOptions); + } finally { + this._pendingSendChatResources.delete(chatResourceKey); + } + if (updatedSession.sessionId !== session.sessionId) { + this.logService.info(`[SessionsManagement] sendRequest: active session replaced: ${session.sessionId} -> ${updatedSession.sessionId}`); + } + this._onDidStartSession.fire(updatedSession); + this._onDidSendRequest.fire({ session: updatedSession, chat, isNewSession: true, isNewChat: true, options }); } finally { - this._pendingSendChatResources.delete(chatResourceKey); - } - if (updatedSession.sessionId !== session.sessionId) { - this.logService.info(`[SessionsManagement] sendRequest: active session replaced: ${session.sessionId} -> ${updatedSession.sessionId}`); + inFlightRequest?.dispose(); } - this._onDidStartSession.fire(updatedSession); - this._onDidSendRequest.fire({ session: updatedSession, chat, isNewSession: true, isNewChat: true, options }); } /** @@ -791,6 +823,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa folderUri?: URI, requestActivity?: MutableDisposable, ): Promise { + const inFlightRequest = this.trackInFlightNewSessionRequest(session); try { if (token.isCancellationRequested) { throw new CancellationError(); @@ -814,6 +847,8 @@ export class SessionsManagementService extends Disposable implements ISessionsMa // rethrowing. Safe no-op if the provider already removed it. provider.deleteNewSession(session.sessionId); throw e; + } finally { + inFlightRequest.dispose(); } } diff --git a/src/vs/sessions/services/sessions/common/sessionsManagement.ts b/src/vs/sessions/services/sessions/common/sessionsManagement.ts index 7cb0f4064ba745..3488b625c13ea8 100644 --- a/src/vs/sessions/services/sessions/common/sessionsManagement.ts +++ b/src/vs/sessions/services/sessions/common/sessionsManagement.ts @@ -232,6 +232,11 @@ export interface ISessionsManagementService { */ getSessions(): ISession[]; + /** + * Get new sessions whose first request is still being prepared or sent. + */ + getInFlightNewSessionRequests(): readonly ISession[]; + /** * Get a session by its resource URI. */ diff --git a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts index edff33289170fc..8e7c0ea22be2eb 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts @@ -155,6 +155,7 @@ class MockSessionStore implements ISessionsManagementService { } getSessions(): ISession[] { return [...this._sessions.values()]; } + getInFlightNewSessionRequests(): readonly ISession[] { return []; } getRecentlyOpenedSessions(): IRecentlyOpenedSessions { return { recent: [...this._sessions.values()], other: [] }; } diff --git a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts index 3cd768a782f1f2..df2f3cb16c60b1 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { DeferredPromise } from '../../../../../base/common/async.js'; +import { DeferredPromise, timeout } from '../../../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { autorun, constObservable, observableValue } from '../../../../../base/common/observable.js'; @@ -960,6 +960,120 @@ suite('SessionsManagementService', () => { assert.strictEqual(view.activeSession.get()?.sessionId, 's1'); }); + test('sendNewChatRequest tracks a foreground first request until it settles', async () => { + const session = stubSession({ + sessionId: 's1', + providerId: 'test', + status: constObservable(SessionStatus.Untitled), + }); + const createChatBarrier = new DeferredPromise(); + const provider = new class extends TestSessionsProvider { + override async createNewChat(): Promise { + await createChatBarrier.p; + return session.mainChat.get(); + } + }(session); + const { service } = createSessionsManagementService(session, disposables, provider); + + const send = service.sendNewChatRequest(session, { query: 'hi' }); + await timeout(0); + const duringCreate = service.getInFlightNewSessionRequests().map(session => session.sessionId); + createChatBarrier.complete(); + await send; + + assert.deepStrictEqual({ + duringCreate, + afterSend: service.getInFlightNewSessionRequests(), + }, { + duringCreate: ['s1'], + afterSend: [], + }); + }); + + test('sendNewChatRequest keeps tracking until concurrent first requests settle', async () => { + const session = stubSession({ + sessionId: 's1', + providerId: 'test', + status: constObservable(SessionStatus.Untitled), + }); + const createBarriers = [new DeferredPromise(), new DeferredPromise()]; + const bothCreatesStarted = new DeferredPromise(); + let createCount = 0; + const provider = new class extends TestSessionsProvider { + override async createNewChat(): Promise { + const index = createCount++; + if (createCount === createBarriers.length) { + bothCreatesStarted.complete(); + } + await createBarriers[index].p; + return session.mainChat.get(); + } + }(session); + const { service } = createSessionsManagementService(session, disposables, provider); + + const first = service.sendNewChatRequest(session, { query: 'first' }); + const second = service.sendNewChatRequest(session, { query: 'second' }); + await bothCreatesStarted.p; + const whileBothPending = service.getInFlightNewSessionRequests().map(session => session.sessionId); + createBarriers[0].complete(); + await first; + const afterFirstSettles = service.getInFlightNewSessionRequests().map(session => session.sessionId); + createBarriers[1].complete(); + await second; + + assert.deepStrictEqual({ + whileBothPending, + afterFirstSettles, + afterBothSettle: service.getInFlightNewSessionRequests(), + }, { + whileBothPending: ['s1'], + afterFirstSettles: ['s1'], + afterBothSettle: [], + }); + }); + + test('sendNewChatRequest does not track a request in an existing session', async () => { + const session = stubSession({ + sessionId: 's1', + providerId: 'test', + status: constObservable(SessionStatus.Completed), + }); + const createChatBarrier = new DeferredPromise(); + const provider = new class extends TestSessionsProvider { + override async createNewChat(): Promise { + await createChatBarrier.p; + return session.mainChat.get(); + } + }(session); + const { service } = createSessionsManagementService(session, disposables, provider); + + const send = service.sendNewChatRequest(session, { query: 'hi' }); + await timeout(0); + const duringCreate = service.getInFlightNewSessionRequests(); + createChatBarrier.complete(); + await send; + + assert.deepStrictEqual(duringCreate, []); + }); + + test('sendNewChatRequest clears first-request tracking when chat creation fails', async () => { + const session = stubSession({ + sessionId: 's1', + providerId: 'test', + status: constObservable(SessionStatus.Untitled), + }); + const provider = new class extends TestSessionsProvider { + override async createNewChat(): Promise { + throw new Error('create failed'); + } + }(session); + const { service } = createSessionsManagementService(session, disposables, provider); + + await assert.rejects(service.sendNewChatRequest(session, { query: 'hi' }), /create failed/); + + assert.deepStrictEqual(service.getInFlightNewSessionRequests(), []); + }); + test('sendNewChatRequest with background resolves before provider send commits', async () => { const chat: IChat = { ...stubChat, resource: URI.parse('test:///chat') }; const session = stubSession({ @@ -967,16 +1081,22 @@ suite('SessionsManagementService', () => { providerId: 'test', chats: constObservable([chat]), mainChat: constObservable(chat), + status: constObservable(SessionStatus.Untitled), }); let completeSendRequest: (() => void) | undefined; let sendRequestStarted = false; + const sendRequestFinished = new DeferredPromise(); const provider = new class extends TestSessionsProvider { override async sendRequest(_sessionId: string, _chatResource: URI, _options: ISendRequestOptions): Promise { - sendRequestStarted = true; - await new Promise(resolve => { - completeSendRequest = resolve; - }); - return session; + try { + sendRequestStarted = true; + await new Promise(resolve => { + completeSendRequest = resolve; + }); + return session; + } finally { + sendRequestFinished.complete(); + } } }(session); const { service } = createSessionsManagementService(session, disposables, provider); @@ -986,9 +1106,21 @@ suite('SessionsManagementService', () => { const sendPromise = service.sendNewChatRequest(session, { query: 'hi', background: true }); await sendPromise; - assert.strictEqual(sendRequestStarted, true); + const whileSending = service.getInFlightNewSessionRequests().map(session => session.sessionId); completeSendRequest?.(); + await sendRequestFinished.p; + await timeout(0); + + assert.deepStrictEqual({ + sendRequestStarted, + whileSending, + afterSend: service.getInFlightNewSessionRequests(), + }, { + sendRequestStarted: true, + whileSending: ['s1'], + afterSend: [], + }); }); test('sendRequest with background is fire-and-forget and does not fire onWillSendRequest', async () => { @@ -1191,15 +1323,20 @@ suite('SessionsManagementService', () => { }); await Promise.all([requestPreparationStarted.p, configurationCompleted.p]); const eventsWhilePreparingRequest = [...events]; + const inFlightWhilePreparing = service.getInFlightNewSessionRequests().map(session => session.sessionId); requestOptionsBarrier.complete(); await sendPromise; assert.deepStrictEqual({ eventsWhilePreparingRequest, + inFlightWhilePreparing, + inFlightAfterSend: service.getInFlightNewSessionRequests(), events, createMetadata, }, { eventsWhilePreparingRequest: ['create', 'start:Fetching pull request...', 'show:s1', 'prepare', 'configure'], + inFlightWhilePreparing: ['s1'], + inFlightAfterSend: [], events: ['create', 'start:Fetching pull request...', 'show:s1', 'prepare', 'configure', 'clear', 'send:prepared'], createMetadata: { github: { pullRequestUrl: 'https://github.com/owner/repo/pull/42' } }, }); diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index eb9f6b98aeda65..a667aa0ab864cc 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -245,6 +245,7 @@ import './contrib/chat/electron-browser/chat.contribution.js'; // Local Agent Host import './contrib/providers/agentHost/browser/localAgentHost.contribution.js'; +import './contrib/providers/agentHost/electron-browser/localAgentHostLifecycle.contribution.js'; import './contrib/providers/agentHost/browser/agentSessionSettings.contribution.js'; import './contrib/providers/agentHost/browser/agentHostSettings.contribution.js'; import './contrib/providers/agentHost/browser/agentHostSessionBranchActions.js'; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 424dbf40864519..04c6f5336a0bc2 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -2072,6 +2072,11 @@ export interface IChatService { readonly requestInProgressObs: IObservable; + /** + * Returns the contributed session types with a request that is materializing or in progress. + */ + getPendingRequestSessionTypes(): readonly string[]; + /** * For tests only! */ diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 493c470cfe69aa..1c44824b693203 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -318,6 +318,16 @@ export class ChatService extends Disposable implements IChatService { return [...this._sessionModels.values()].map(v => v.editingSession).filter(isDefined); } + getPendingRequestSessionTypes(): readonly string[] { + const sessionTypes = new Set(Array.from(this._inFlightUntitledMaterializations.keys(), getChatSessionType)); + for (const model of this._sessionModels.values()) { + if (model.requestInProgress.get()) { + sessionTypes.add(getChatSessionType(model.sessionResource)); + } + } + return Array.from(sessionTypes); + } + isEnabled(location: ChatAgentLocation): boolean { return this.chatAgentService.getContributedDefaultAgent(location) !== undefined; } diff --git a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts index b61cf94afd0ede..65d3cb47965a05 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts @@ -46,7 +46,7 @@ import { registerChatDeveloperActions } from './actions/chatDeveloperActions.js' import { registerChatExportZipAction } from './actions/chatExportZip.js'; import { registerExportAgentTracesDbAction } from './actions/exportAgentTracesDb.js'; import { registerInstallDictationModelAction } from './actions/installDictationModelAction.js'; -import { shouldWarnForSessionShutdown } from './chatLifecycle.js'; +import { confirmSessionShutdown, getEffectiveSessionShutdownReason, shouldWarnForInFlightSessionShutdown, shouldWarnForSessionShutdown } from './chatLifecycle.js'; import { HoldToVoiceChatInChatViewAction, InlineVoiceChatAction, KeywordActivationContribution, QuickVoiceChatAction, ReadChatResponseAloud, StartVoiceChatAction, StopListeningAction, StopListeningAndSubmitAction, StopReadAloud, StopReadChatItemAloud, VoiceChatInChatViewAction } from './actions/voiceChatActions.js'; import { OpenWorkspaceInAgentsWindowAction, OpenWorkspaceInAgentsContribution, OpenAgentsWindowAction, OpenChatSessionInAgentsWindowAction, AgentsHandoffInputTipContribution, ToggleOpenInAgentsWindowTitleBarAction, OpenWorkspaceInAgentsWindowChatTitleAction, OpenWorkspaceInAgentsWindowTitleBarAction } from './agentSessions/agentSessionsActions.js'; import { NativeBuiltinToolsContribution } from './builtInTools/tools.js'; @@ -165,6 +165,8 @@ class ChatLifecycleHandler extends Disposable { @IExtensionService extensionService: IExtensionService, @INativeWorkbenchEnvironmentService private readonly environmentService: INativeWorkbenchEnvironmentService, @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, + @INativeHostService private readonly nativeHostService: INativeHostService, + @IChatService private readonly chatService: IChatService, ) { super(); @@ -182,15 +184,21 @@ class ChatLifecycleHandler extends Disposable { return false; // AI features are disabled } + if (shouldWarnForInFlightSessionShutdown(this.chatService.getPendingRequestSessionTypes(), reason)) { + return true; + } + return this.agentSessionsService.model.sessions.some(session => shouldWarnForSessionShutdown(session, reason)); } - private shouldVetoShutdown(reason: ShutdownReason): boolean | Promise { + private async shouldVetoShutdown(reason: ShutdownReason): Promise { if (this.environmentService.enableSmokeTestDriver) { return false; } - if (!this.hasSessionThatWillStop(reason)) { + const windowCount = reason === ShutdownReason.CLOSE ? await this.nativeHostService.getWindowCount() : 0; + const effectiveReason = getEffectiveSessionShutdownReason(reason, windowCount, isMacintosh); + if (!this.hasSessionThatWillStop(effectiveReason)) { return false; } @@ -198,37 +206,8 @@ class ChatLifecycleHandler extends Disposable { return false; } - return this.doShouldVetoShutdown(reason); - } - - private async doShouldVetoShutdown(reason: ShutdownReason): Promise { - this.widgetService.revealWidget(); - - let message: string; - let detail: string; - switch (reason) { - case ShutdownReason.CLOSE: - message = localize('closeTheWindow.message', "A session is in progress. Are you sure you want to close the window?"); - detail = localize('closeTheWindow.detail', "The session will stop if you close the window."); - break; - case ShutdownReason.LOAD: - message = localize('changeWorkspace.message', "A session is in progress. Are you sure you want to change the workspace?"); - detail = localize('changeWorkspace.detail', "The session will stop if you change the workspace."); - break; - case ShutdownReason.RELOAD: - message = localize('reloadTheWindow.message', "A session is in progress. Are you sure you want to reload the window?"); - detail = localize('reloadTheWindow.detail', "The session will stop if you reload the window."); - break; - default: - message = isMacintosh ? localize('quit.message', "A session is in progress. Are you sure you want to quit?") : localize('exit.message', "A session is in progress. Are you sure you want to exit?"); - detail = isMacintosh ? localize('quit.detail', "The session will stop if you quit.") : localize('exit.detail', "The session will stop if you exit."); - break; - } - - const result = await this.dialogService.confirm({ message, detail }); - - return !result.confirmed; + return !await confirmSessionShutdown(this.dialogService, effectiveReason); } } diff --git a/src/vs/workbench/contrib/chat/electron-browser/chatLifecycle.ts b/src/vs/workbench/contrib/chat/electron-browser/chatLifecycle.ts index e297f41c5a5ef0..102a8afd6c3e95 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/chatLifecycle.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/chatLifecycle.ts @@ -4,12 +4,48 @@ *--------------------------------------------------------------------------------------------*/ import { ShutdownReason } from '../../../services/lifecycle/common/lifecycle.js'; +import { isMacintosh } from '../../../../base/common/platform.js'; +import { localize } from '../../../../nls.js'; +import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { AgentSessionProviders } from '../browser/agentSessions/agentSessions.js'; import { type IAgentSession, isSessionInProgressStatus } from '../browser/agentSessions/agentSessionsModel.js'; import { isLocalAgentHostTarget, isRemoteAgentHostTarget } from '../common/chatSessionsService.js'; type ShutdownWarningSession = Pick; +export function getEffectiveSessionShutdownReason(reason: ShutdownReason, windowCount: number, macintosh: boolean): ShutdownReason { + return reason === ShutdownReason.CLOSE && !macintosh && windowCount === 1 ? ShutdownReason.QUIT : reason; +} + +export async function confirmSessionShutdown(dialogService: IDialogService, reason: ShutdownReason): Promise { + let message: string; + let detail: string; + switch (reason) { + case ShutdownReason.CLOSE: + message = localize('closeTheWindow.message', "A session is in progress. Are you sure you want to close the window?"); + detail = localize('closeTheWindow.detail', "The session will stop if you close the window."); + break; + case ShutdownReason.LOAD: + message = localize('changeWorkspace.message', "A session is in progress. Are you sure you want to change the workspace?"); + detail = localize('changeWorkspace.detail', "The session will stop if you change the workspace."); + break; + case ShutdownReason.RELOAD: + message = localize('reloadTheWindow.message', "A session is in progress. Are you sure you want to reload the window?"); + detail = localize('reloadTheWindow.detail', "The session will stop if you reload the window."); + break; + default: + message = isMacintosh ? localize('quit.message', "A session is in progress. Are you sure you want to quit?") : localize('exit.message', "A session is in progress. Are you sure you want to exit?"); + detail = isMacintosh ? localize('quit.detail', "The session will stop if you quit.") : localize('exit.detail', "The session will stop if you exit."); + break; + } + + return (await dialogService.confirm({ message, detail, custom: true })).confirmed; +} + +export function shouldWarnForInFlightSessionShutdown(sessionTypes: readonly string[], reason: ShutdownReason): boolean { + return reason === ShutdownReason.QUIT && sessionTypes.some(isLocalAgentHostTarget); +} + export function shouldWarnForSessionShutdown(session: ShutdownWarningSession, reason: ShutdownReason): boolean { if (!isSessionInProgressStatus(session.status) || session.providerType === AgentSessionProviders.Cloud || session.isArchived()) { return false; diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index 27482ee542a754..643ddba60b6310 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -1793,11 +1793,33 @@ suite('ChatService', () => { assert.strictEqual(createCount, 1, 'createNewChatSessionItem must run exactly once'); assert.deepStrictEqual([r1.kind, r2.kind].sort(), ['rejected', 'sent'], 'one send is accepted, the duplicate is rejected'); assert.ok(service.getSession(realResource), 'exactly one real session is materialized'); + assert.deepStrictEqual(service.getPendingRequestSessionTypes(), [remoteScheme]); agentGate.complete(); const sent = ChatSendResult.isSent(r1) ? r1 : r2; ChatSendResult.assertSent(sent); await sent.data.responseCompletePromise; + assert.deepStrictEqual(service.getPendingRequestSessionTypes(), []); + }); + + test('reports the session type while materializing the first request', async () => { + const realResource = URI.from({ scheme: remoteScheme, path: '/real-pending' }); + const materialization = new DeferredPromise(); + const { service, untitledResource } = setupUntitledRemote({ + createItem: async () => materialization.p, + }); + testDisposables.add((await service.acquireOrLoadSession(untitledResource, ChatAgentLocation.Chat, CancellationToken.None))!); + + const send = service.sendRequest(untitledResource, 'hello', { agentId: remoteScheme }); + assert.deepStrictEqual(service.getPendingRequestSessionTypes(), [remoteScheme]); + + materialization.complete(realItem(realResource)); + const result = await send; + + assert.deepStrictEqual(service.getPendingRequestSessionTypes(), [remoteScheme]); + ChatSendResult.assertSent(result); + await result.data.responseCompletePromise; + assert.deepStrictEqual(service.getPendingRequestSessionTypes(), []); }); test('materialization rejects a send when the real session is read-only', async () => { diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts b/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts index 40b95a386af0e6..9eb9cdea44aee6 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts @@ -17,6 +17,7 @@ export class MockChatService implements IChatService { private readonly _chatModels: ISettableObservable> = observableValue('chatModels', []); readonly chatModels = this._chatModels; requestInProgressObs = observableValue('name', false); + getPendingRequestSessionTypes(): readonly string[] { return []; } _serviceBrand: undefined; editingSessions = []; transferredSessionResource = undefined; diff --git a/src/vs/workbench/contrib/chat/test/electron-browser/chatLifecycle.test.ts b/src/vs/workbench/contrib/chat/test/electron-browser/chatLifecycle.test.ts index c257bb40e653e4..83c78b170a1cd7 100644 --- a/src/vs/workbench/contrib/chat/test/electron-browser/chatLifecycle.test.ts +++ b/src/vs/workbench/contrib/chat/test/electron-browser/chatLifecycle.test.ts @@ -4,11 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IConfirmation, IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { ShutdownReason } from '../../../../services/lifecycle/common/lifecycle.js'; import { AgentSessionProviders } from '../../browser/agentSessions/agentSessions.js'; import { AgentSessionStatus } from '../../browser/agentSessions/agentSessionsModel.js'; -import { shouldWarnForSessionShutdown } from '../../electron-browser/chatLifecycle.js'; +import { confirmSessionShutdown, getEffectiveSessionShutdownReason, shouldWarnForInFlightSessionShutdown, shouldWarnForSessionShutdown } from '../../electron-browser/chatLifecycle.js'; suite('ChatLifecycle', () => { type TestSession = Parameters[0]; @@ -35,7 +37,9 @@ suite('ChatLifecycle', () => { { name: 'local', warnings: warningsByReason(createSession(AgentSessionProviders.Local)) }, { name: 'background', warnings: warningsByReason(createSession(AgentSessionProviders.Background)) }, { name: 'cloud', warnings: warningsByReason(createSession(AgentSessionProviders.Cloud)) }, - { name: 'local agent host', warnings: warningsByReason(createSession(AgentSessionProviders.AgentHostCopilot)) }, + { name: 'local agent host Copilot', warnings: warningsByReason(createSession(AgentSessionProviders.AgentHostCopilot)) }, + { name: 'local agent host Claude', warnings: warningsByReason(createSession(AgentSessionProviders.AgentHostClaude)) }, + { name: 'local agent host Codex', warnings: warningsByReason(createSession(AgentSessionProviders.AgentHostCodex)) }, { name: 'dynamic local agent host', warnings: warningsByReason(createSession('agent-host-foo')) }, { name: 'remote agent host', warnings: warningsByReason(createSession('remote-host-copilotcli')) }, { name: 'dynamic remote agent host', warnings: warningsByReason(createSession('remote-foo')) }, @@ -46,7 +50,9 @@ suite('ChatLifecycle', () => { { name: 'local', warnings: { close: true, load: true, reload: true, quit: true } }, { name: 'background', warnings: { close: true, load: true, reload: true, quit: true } }, { name: 'cloud', warnings: { close: false, load: false, reload: false, quit: false } }, - { name: 'local agent host', warnings: { close: false, load: false, reload: false, quit: true } }, + { name: 'local agent host Copilot', warnings: { close: false, load: false, reload: false, quit: true } }, + { name: 'local agent host Claude', warnings: { close: false, load: false, reload: false, quit: true } }, + { name: 'local agent host Codex', warnings: { close: false, load: false, reload: false, quit: true } }, { name: 'dynamic local agent host', warnings: { close: false, load: false, reload: false, quit: true } }, { name: 'remote agent host', warnings: { close: false, load: false, reload: false, quit: false } }, { name: 'dynamic remote agent host', warnings: { close: false, load: false, reload: false, quit: false } }, @@ -56,5 +62,67 @@ suite('ChatLifecycle', () => { ]); }); + test('treats closing the last non-macOS window as application quit', () => { + const localAgentHostSessions = [ + AgentSessionProviders.AgentHostCopilot, + AgentSessionProviders.AgentHostClaude, + AgentSessionProviders.AgentHostCodex, + ].map(provider => createSession(provider)); + const scenarios = [ + { name: 'last Windows/Linux window', reason: ShutdownReason.CLOSE, windowCount: 1, macintosh: false }, + { name: 'another Windows/Linux window remains', reason: ShutdownReason.CLOSE, windowCount: 2, macintosh: false }, + { name: 'last macOS window', reason: ShutdownReason.CLOSE, windowCount: 1, macintosh: true }, + { name: 'explicit quit', reason: ShutdownReason.QUIT, windowCount: 2, macintosh: false }, + ]; + + assert.deepStrictEqual(scenarios.map(scenario => { + const effectiveReason = getEffectiveSessionShutdownReason(scenario.reason, scenario.windowCount, scenario.macintosh); + return { + name: scenario.name, + effectiveReason, + warnings: localAgentHostSessions.map(session => shouldWarnForSessionShutdown(session, effectiveReason)), + }; + }), [ + { name: 'last Windows/Linux window', effectiveReason: ShutdownReason.QUIT, warnings: [true, true, true] }, + { name: 'another Windows/Linux window remains', effectiveReason: ShutdownReason.CLOSE, warnings: [false, false, false] }, + { name: 'last macOS window', effectiveReason: ShutdownReason.CLOSE, warnings: [false, false, false] }, + { name: 'explicit quit', effectiveReason: ShutdownReason.QUIT, warnings: [true, true, true] }, + ]); + }); + + test('warns for local Agent Host session materialization only on quit', () => { + assert.deepStrictEqual({ + localQuit: shouldWarnForInFlightSessionShutdown([AgentSessionProviders.AgentHostCopilot], ShutdownReason.QUIT), + claudeQuit: shouldWarnForInFlightSessionShutdown([AgentSessionProviders.AgentHostClaude], ShutdownReason.QUIT), + codexQuit: shouldWarnForInFlightSessionShutdown([AgentSessionProviders.AgentHostCodex], ShutdownReason.QUIT), + localClose: shouldWarnForInFlightSessionShutdown([AgentSessionProviders.AgentHostCopilot], ShutdownReason.CLOSE), + cloudQuit: shouldWarnForInFlightSessionShutdown([AgentSessionProviders.Cloud], ShutdownReason.QUIT), + }, { + localQuit: true, + claudeQuit: true, + codexQuit: true, + localClose: false, + cloudQuit: false, + }); + }); + + test('uses a custom shutdown confirmation attached to the closing window', async () => { + let confirmation: IConfirmation | undefined; + const confirmed = await confirmSessionShutdown(upcastPartial({ + confirm: async options => { + confirmation = options; + return { confirmed: true }; + }, + }), ShutdownReason.QUIT); + + assert.deepStrictEqual({ + confirmed, + custom: confirmation?.custom, + }, { + confirmed: true, + custom: true, + }); + }); + ensureNoDisposablesAreLeakedInTestSuite(); }); diff --git a/src/vs/workbench/contrib/update/browser/updateTitleBarEntry.ts b/src/vs/workbench/contrib/update/browser/updateTitleBarEntry.ts index bb7837197a9193..9ee60360dc44c2 100644 --- a/src/vs/workbench/contrib/update/browser/updateTitleBarEntry.ts +++ b/src/vs/workbench/contrib/update/browser/updateTitleBarEntry.ts @@ -16,7 +16,7 @@ import { IActionViewItemService } from '../../../../platform/actions/browser/act import { Action2, IMenuItem, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { ContextKeyExpr, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { ContextKeyExpr, ContextKeyExpression, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; @@ -53,6 +53,10 @@ export function registerUpdateTitleBarMenuPlacement(menuId: MenuId, item: Omit() { private readonly _onDidExecuteCommand = new Emitter(); @@ -141,6 +141,27 @@ suite('UpdateGlobalActivityBadgeVisibleContext', () => { }); }); +suite('UpdateTitleBarVisibleContexts', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('shows an additional placement during an active chat while the editor hides it', () => { + const contextKeyService = new TestContextKeyService(); + UpdateTitleBarContext.bindTo(contextKeyService).set(true); + UpdateTitleBarChatInProgressContext.bindTo(contextKeyService).set(true); + InEditorZenModeContext.bindTo(contextKeyService).set(false); + contextKeyService.createKey('inDebugMode', false); + + assert.deepStrictEqual({ + additional: contextKeyService.contextMatchesRules(getAdditionalUpdateTitleBarMenuWhen()), + editor: contextKeyService.contextMatchesRules(UpdateTitleBarEditorVisibleContext), + }, { + additional: true, + editor: false, + }); + }); +}); + suite('UpdateTooltip', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); From 1cecb4776e201292a6806c18302570e3ac8d4886 Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:46:49 -0700 Subject: [PATCH 7/7] pet: advancement hint, pill detection, better continuity (#332153) * pet: advancement hint, pill detection, better continuity * address comments --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .../sessions/contrib/chat/browser/chatView.ts | 13 +- .../chat/browser/newChatInSessionWidget.ts | 3 +- .../contrib/chat/browser/newChatInput.ts | 15 +- .../contrib/chat/browser/newChatWidget.ts | 3 +- .../chat/browser/sessionChatInputToolbar.ts | 21 +- .../chat/test/browser/chatView.test.ts | 8 +- src/vs/workbench/browser/chatPills.ts | 29 +- .../chat/browser/chat.shared.contribution.ts | 2 + .../chatPetAchievements.contribution.ts | 4 +- .../chat/browser/chatPetAchievements.ts | 21 +- .../chat/browser/chatPetAchievementsWidget.ts | 11 +- .../browser/media/chatPetAchievements.css | 7 +- .../chat/browser/widget/chatPetWidget.ts | 339 ++++++++++++------ .../browser/widget/chatPetWidgetService.ts | 230 ++++++++++++ .../contrib/chat/browser/widget/chatWidget.ts | 29 +- .../browser/widget/input/chatInputPart.ts | 40 ++- .../chat/browser/widget/media/chatPet.css | 4 +- .../browser/chatPetAchievementsEditor.test.ts | 56 +++ .../test/browser/widget/chatPetWidget.test.ts | 253 ++++++++++--- .../widget/chatPetWidgetService.test.ts | 146 ++++++++ .../test/browser/widget/chatTurnPills.test.ts | 8 + .../chat/chatFixtureUtils.ts | 2 + .../blocks-ci-screenshots.md | 4 +- 23 files changed, 1057 insertions(+), 191 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/widget/chatPetWidgetService.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidgetService.test.ts diff --git a/src/vs/sessions/contrib/chat/browser/chatView.ts b/src/vs/sessions/contrib/chat/browser/chatView.ts index 892317ce41ae10..138c52a86779e2 100644 --- a/src/vs/sessions/contrib/chat/browser/chatView.ts +++ b/src/vs/sessions/contrib/chat/browser/chatView.ts @@ -65,6 +65,7 @@ export class NewChatView extends AbstractChatView { override readonly kind: ChatViewKind; private readonly _widget: NewChatWidget | NewChatInSessionWidget; + private readonly _isVisibleObs = observableValue(this, true); constructor( isNewChatInSession: boolean, @@ -75,9 +76,10 @@ export class NewChatView extends AbstractChatView { this.element.classList.add('chat-view-new'); this.kind = isNewChatInSession ? 'newChatInSession' : 'newSession'; + const widgetOptions = { ...options, petHostPreferred: this._isVisibleObs }; this._widget = this._register(isNewChatInSession - ? instantiationService.createInstance(NewChatInSessionWidget, options) - : instantiationService.createInstance(NewChatWidget, options)); + ? instantiationService.createInstance(NewChatInSessionWidget, widgetOptions) + : instantiationService.createInstance(NewChatWidget, widgetOptions)); this._widget.render(this.element); } @@ -121,6 +123,7 @@ export class NewChatView extends AbstractChatView { } override setVisible(visible: boolean): void { + this._isVisibleObs.set(visible, undefined); if (this._widget instanceof NewChatWidget) { this._widget.setHostVisible(visible); } @@ -232,7 +235,7 @@ export class ChatView extends AbstractChatView { }, this._buildStyles(this._isActive) )); - this._widget.render(this._widgetContainer); + this._widget.render(this._widgetContainer, undefined, this._isActiveObs); this._externalSessionBanner = this._register(scopedInstantiationService.createInstance( ExternalSessionBanner, this.element, @@ -260,6 +263,10 @@ export class ChatView extends AbstractChatView { // Floating status pills above the input. this._chatPills = this._register(instantiationService.createInstance(SessionChatInputToolbar)); + this._register(this._widget.inputPart.registerChatPetHorizontalPlatformProvider({ + onDidChange: this._chatPills.onDidChangeChatPetPlatform, + getElements: () => this._chatPills.getChatPetPlatformElements(), + })); this._register(chatPillsDebugService.register(this._chatPills, this._banners, this._isActiveObs)); this._ensureBannersMounted(); diff --git a/src/vs/sessions/contrib/chat/browser/newChatInSessionWidget.ts b/src/vs/sessions/contrib/chat/browser/newChatInSessionWidget.ts index 17f68f96db9dfa..8be5c2335630a2 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInSessionWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInSessionWidget.ts @@ -40,7 +40,7 @@ export class NewChatInSessionWidget extends Disposable { private readonly _session: IObservable; constructor( - _options: IChatViewOptions, + _options: IChatViewOptions & { readonly petHostPreferred?: IObservable }, @IInstantiationService private readonly instantiationService: IInstantiationService, @ILogService private readonly logService: ILogService, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, @@ -73,6 +73,7 @@ export class NewChatInSessionWidget extends Disposable { historyKey: constObservable(undefined), // no persisted history for the new-chat-in-session view minEditorHeight: 64, placeholder: localize('newChatInSessionPlaceholder', 'Ask a follow-up question or start a new topic within this session...'), + petHostPreferred: _options.petHostPreferred, supportsBackground: true, voiceRoutesWhileSessionActive: true, })); diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index ff6c110034c2dc..6c1405545377ba 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -113,7 +113,7 @@ import { combineVoiceInput } from '../../../../workbench/contrib/chat/browser/vo import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { DictationDownloadRing, getDictationDownloadHoverMarkdown, getDictationPreparingLabel } from '../../../../workbench/contrib/chat/browser/speechToText/dictationDownloadRing.js'; import { IVoiceSessionController } from '../../../../workbench/contrib/chat/browser/voiceClient/voiceSessionController.js'; -import { ChatPetWidget } from '../../../../workbench/contrib/chat/browser/widget/chatPetWidget.js'; +import { IChatPetWidgetService } from '../../../../workbench/contrib/chat/browser/widget/chatPetWidgetService.js'; import { IVoiceModeOnboardingService } from '../../../../workbench/contrib/agentsVoice/browser/voiceModeOnboarding.js'; import { AGENTS_VOICE_ENABLED } from '../../../../workbench/contrib/agentsVoice/common/agentsVoice.js'; import { animatePromptTyping, IPromptTypingAnimation } from './promptTypingAnimation.js'; @@ -414,6 +414,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation sessionTypePickerOptions?: ISessionTypePickerOptions; supportsBackground?: boolean; deferredNotificationsEnabled?: IObservable; + petHostPreferred?: IObservable; /** * Keep this composer a valid voice target even while a created session * is active. Used by the in-session "new chat" composer so dictation @@ -447,6 +448,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation @IVoiceModeOnboardingService private readonly voiceModeOnboardingService: IVoiceModeOnboardingService, @INewChatVoiceTargetService private readonly newChatVoiceTargetService: INewChatVoiceTargetService, @IThemeService private readonly themeService: IThemeService, + @IChatPetWidgetService private readonly chatPetWidgetService: IChatPetWidgetService, ) { super(); this._modelSelection = this._register(this.instantiationService.createInstance(SessionModelSelection, this.options.session)); @@ -597,7 +599,16 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation this._createEditor(inputArea, editorOverflowWidgetsDomNode); const inputHasContent = observableFromEvent(this, this._editor.onDidChangeModelContent, () => this._editor.getValue().length > 0); - this._register(this.instantiationService.createInstance(ChatPetWidget, chatInputContainer, inputArea, root, constObservable(undefined), inputHasContent, constObservable(true), this._editor.onDidChangeModelContent)); + this._register(this.chatPetWidgetService.register(this, { + parent: chatInputContainer, + dragBounds: inputArea, + movementBounds: root, + model: constObservable(undefined), + hasInput: inputHasContent, + inputChanged: this._editor.onDidChangeModelContent, + getPlatformTop: () => undefined, + onDidChangePlatform: Event.None, + }, this.options.petHostPreferred, this.onDidFocus)); this._createInputToolbar(inputArea); const newChatBottomContainer = dom.append(parent, dom.$('.new-chat-bottom-container')); diff --git a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts index 89efa459c8a367..4404dc25473a4a 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts @@ -109,7 +109,7 @@ export class NewChatWidget extends Disposable { private readonly _workspacePickerVisibleKey: IContextKey; constructor( - private readonly options: IChatViewOptions, + private readonly options: IChatViewOptions & { readonly petHostPreferred?: IObservable }, @IInstantiationService private readonly instantiationService: IInstantiationService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IContextMenuService private readonly contextMenuService: IContextMenuService, @@ -219,6 +219,7 @@ export class NewChatWidget extends Disposable { renderSessionTypePickerInControls: this._renderHarnessPickerInControls, supportsBackground: true, deferredNotificationsEnabled, + petHostPreferred: this.options.petHostPreferred, }); this._register(toDisposable(() => newChatInput.saveState())); this._newChatInput = this._register(newChatInput); diff --git a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts index 605ca8e4479e1c..758e11de877876 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts @@ -7,6 +7,7 @@ import { $, addDisposableListener, DisposableResizeObserver, EventType, getWindo import { StandardMouseEvent } from '../../../../base/browser/mouseEvent.js'; import { DomScrollableElement } from '../../../../base/browser/ui/scrollbar/scrollableElement.js'; import { toAction, Action, Separator, type IAction } from '../../../../base/common/actions.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { autorun, derived, derivedOpts, IObservable, IReader, observableValue } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; @@ -91,6 +92,9 @@ export class SessionChatInputToolbar extends Disposable { readonly element: HTMLElement; private readonly _content: HTMLElement; private readonly _scrollable: DomScrollableElement; + private readonly _onDidChangeChatPetPlatform = this._register(new Emitter()); + readonly onDidChangeChatPetPlatform: Event = this._onDidChangeChatPetPlatform.event; + private readonly _pills: ChatPillsWidget; /** Sentinel distinguishing "no override" from an explicit `undefined` session. */ private readonly _sessionOverride = observableValue(this, 'unset'); @@ -223,7 +227,7 @@ export class SessionChatInputToolbar extends Disposable { context: this._session, }; const actionRunner = this._register(new SessionActivatingActionRunner(() => this._session.get(), this._sessionsService)); - const pills = this._register(instantiationService.createInstance(ChatPillsWidget, pillsModel, { + const pills = this._pills = this._register(instantiationService.createInstance(ChatPillsWidget, pillsModel, { actionRunner, // The row's visibility menu must be reachable by right-clicking a pill, // not just the empty space beside it. @@ -231,6 +235,7 @@ export class SessionChatInputToolbar extends Disposable { })); pills.element.classList.add('show-file-icons'); this._content.appendChild(pills.element); + this._register(pills.onDidChangePills(() => this._onDidChangeChatPetPlatform.fire())); // Kinds the session reports data for; the others are listed in a separate group. const kindsWithData = derived(reader => { @@ -291,9 +296,17 @@ export class SessionChatInputToolbar extends Disposable { }); })); - const resizeObserver = this._register(new DisposableResizeObserver('SessionChatInputToolbar.content', () => this._scrollable.scanDomNode())); + const resizeObserver = this._register(new DisposableResizeObserver('SessionChatInputToolbar.content', () => { + this._scrollable.scanDomNode(); + this._onDidChangeChatPetPlatform.fire(); + })); this._register(resizeObserver.observe(this._content)); this._register(resizeObserver.observe(pills.element)); + this._register(this._scrollable.onScroll(e => { + if (e.scrollLeftChanged) { + this._onDidChangeChatPetPlatform.fire(); + } + })); this._register(addDisposableListener(this._content, EventType.FOCUS_IN, () => this._scrollable.scanDomNode())); this._register(autorun(reader => { @@ -307,6 +320,10 @@ export class SessionChatInputToolbar extends Disposable { })); } + getChatPetPlatformElements(): readonly HTMLElement[] { + return this._pills.getPillElements(); + } + /** * Track the currently-viewed chat; the toolbar reflects that chat's last-turn * changes and status, resolving the owning session for provider gating and the diff --git a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts index f66fc940ae3f52..7146cfb1773a1a 100644 --- a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts @@ -6,6 +6,7 @@ import assert from 'assert'; import * as dom from '../../../../../base/browser/dom.js'; import { DisposableStore, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { CHAT_WIDGET_VIEW_STATE_CACHE_LIMIT } from '../../../../../workbench/contrib/chat/browser/chat.js'; @@ -28,7 +29,9 @@ suite('Sessions - Chat View', () => { test('forwards new chat visibility to the aquarium host', () => { const forwarded: boolean[] = []; + const isVisible = observableValue(disposables, true); const view: NewChatView = Object.assign(Object.create(NewChatView.prototype), { + _isVisibleObs: isVisible, _widget: Object.assign(Object.create(NewChatWidget.prototype), { setHostVisible: (visible: boolean) => forwarded.push(visible), }), @@ -37,15 +40,18 @@ suite('Sessions - Chat View', () => { view.setVisible(false); view.setVisible(true); - assert.deepStrictEqual(forwarded, [false, true]); + assert.deepStrictEqual({ forwarded, petHostVisible: isVisible.get() }, { forwarded: [false, true], petHostVisible: true }); }); test('does not forward aquarium visibility to the peer chat composer', () => { + const isVisible = observableValue(disposables, true); const view: NewChatView = Object.assign(Object.create(NewChatView.prototype), { + _isVisibleObs: isVisible, _widget: Object.create(NewChatInSessionWidget.prototype), }); assert.doesNotThrow(() => view.setVisible(false)); + assert.strictEqual(isVisible.get(), false); }); test('stores view state independently by chat resource', () => { diff --git a/src/vs/workbench/browser/chatPills.ts b/src/vs/workbench/browser/chatPills.ts index 1cfc303fb0a6d9..58d12f0587ac0a 100644 --- a/src/vs/workbench/browser/chatPills.ts +++ b/src/vs/workbench/browser/chatPills.ts @@ -9,6 +9,7 @@ import { BaseActionViewItem, IActionViewItemOptions } from '../../base/browser/u import { Button } from '../../base/browser/ui/button/button.js'; import { ToolBar } from '../../base/browser/ui/toolbar/toolbar.js'; import { IAction, IActionRunner } from '../../base/common/actions.js'; +import { Emitter, Event } from '../../base/common/event.js'; import { isMacintosh } from '../../base/common/platform.js'; import { Disposable } from '../../base/common/lifecycle.js'; import { autorun, derived, IObservable } from '../../base/common/observable.js'; @@ -82,10 +83,13 @@ export class ChatPillsWidget extends Disposable { readonly element: HTMLElement; readonly isVisible: IObservable; + private readonly _onDidChangePills = this._register(new Emitter()); + readonly onDidChangePills: Event = this._onDidChangePills.event; private readonly _toolbar: ToolBar; private _pillByAction = new Map(); private _pills: readonly IChatPill[] = []; + private _pillViewItems: ChatPillActionViewItemBase[] = []; constructor( model: IChatPillsModel, @@ -99,7 +103,13 @@ export class ChatPillsWidget extends Disposable { ariaLabel: options?.ariaLabel ?? localize('chatPills.ariaLabel', "Chat status"), actionRunner: options?.actionRunner, allowContextMenu: options?.allowContextMenu, - actionViewItemProvider: (action, viewItemOptions) => this._pillByAction.get(action)?.createActionViewItem?.(viewItemOptions) ?? new ChatPillActionViewItem(undefined, action, viewItemOptions), + actionViewItemProvider: (action, viewItemOptions) => { + const viewItem = this._pillByAction.get(action)?.createActionViewItem?.(viewItemOptions) ?? new ChatPillActionViewItem(undefined, action, viewItemOptions); + if (viewItem instanceof ChatPillActionViewItemBase) { + this._pillViewItems.push(viewItem); + } + return viewItem; + }, })); this.isVisible = derived(this, reader => model.pills.read(reader).length > 0); @@ -107,14 +117,24 @@ export class ChatPillsWidget extends Disposable { const pills = model.pills.read(reader); this._pillByAction = new Map(pills.map(pill => [pill.action, pill])); this._toolbar.context = model.context?.read(reader); - if (pills.length !== this._pills.length || pills.some((pill, index) => pill !== this._pills[index])) { + const pillsChanged = pills.length !== this._pills.length || pills.some((pill, index) => pill !== this._pills[index]); + if (pillsChanged) { this._pills = pills; + this._pillViewItems = []; this._toolbar.setActions(pills.map(pill => pill.action)); } this.element.classList.toggle('hidden', pills.length === 0); + if (pillsChanged) { + this._onDidChangePills.fire(); + } })); } + /** Returns the rendered button for each pill. */ + getPillElements(): readonly HTMLElement[] { + return this._pillViewItems.flatMap(viewItem => viewItem.buttonElement ? [viewItem.buttonElement] : []); + } + /** * The pill whose rendered item contains `target`, if any. Toolbar items are * rendered in pill order, so their position identifies them without each @@ -138,6 +158,11 @@ export abstract class ChatPillActionViewItemBase extends BaseActionViewItem { protected button: Button | undefined; + /** The rendered button owned by this pill. */ + get buttonElement(): HTMLElement | undefined { + return this.button?.element; + } + /** * Per-pill modifier classes added alongside the shared `chat-pill-item` and * `chat-pill-button`. A single class each, since these are applied with diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index ab4f9da5ccd249..aa40db37b6058a 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -199,6 +199,7 @@ import { ChatOutputRendererService, IChatOutputRendererService } from './chatOut import { ChatCompatibilityNotifier, ChatExtensionPointHandler } from './chatParticipant.contribution.js'; import { ChatPetAchievementsAccessibilityHelp, ChatPetContextContribution, ChatPetCustomizationAchievementContribution } from './chatPetAchievements.contribution.js'; import { ChatPetService, IChatPetService } from './chatPetService.js'; +import { ChatPetWidgetService, IChatPetWidgetService } from './widget/chatPetWidgetService.js'; import { ChatPromoNotificationContribution } from './chatPromoNotification.js'; import { ChatQuotaNotificationContribution } from './chatQuotaNotification.js'; import { ChatRepoInfoContribution } from './chatRepoInfo.js'; @@ -3130,6 +3131,7 @@ registerSingleton(IChatSideChatService, ChatSideChatService, InstantiationType.D registerSingleton(IChatRequestOriginService, ChatRequestOriginService, InstantiationType.Delayed); registerSingleton(IChatModelFeedbackSurveyService, ChatModelFeedbackSurveyService, InstantiationType.Delayed); registerSingleton(IChatPetService, ChatPetService, InstantiationType.Delayed); +registerSingleton(IChatPetWidgetService, ChatPetWidgetService, InstantiationType.Delayed); registerSingleton(IQuickChatService, QuickChatService, InstantiationType.Delayed); registerSingleton(IChatAccessibilityService, ChatAccessibilityService, InstantiationType.Delayed); registerSingleton(IChatWidgetHistoryService, ChatWidgetHistoryService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/chatPetAchievements.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatPetAchievements.contribution.ts index 92141a973cf2b7..2bd77bc02c6655 100644 --- a/src/vs/workbench/contrib/chat/browser/chatPetAchievements.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatPetAchievements.contribution.ts @@ -270,8 +270,8 @@ export class ChatPetAchievementsAccessibilityHelp implements IAccessibleViewImpl const previouslyFocusedElement = DOM.getActiveElement(); const editorService = _accessor.get(IEditorService); const content = [ - localize('chatPet.achievements.accessibilityHelp.overview', "The Achievements modal lists secret agent-feature achievements and the pet hats rewarded by unlocked achievements."), - localize('chatPet.achievements.accessibilityHelp.cards', "Use Tab and Shift+Tab to move through No Hat and the achievement cards. Press Enter or Space on No Hat or an unlocked achievement to change what the pet wears. Newly unlocked cards are announced as New until you activate them. Locked achievements are announced as locked and cannot be selected."), + localize('chatPet.achievements.accessibilityHelp.overview', "The Achievements modal lists agent-feature achievements and their pet hat rewards. Locked cards reveal a hint and reward while keeping the achievement name and exact unlock requirement hidden."), + localize('chatPet.achievements.accessibilityHelp.cards', "Use Tab and Shift+Tab to move through No Hat and the achievement cards. Press Enter or Space on No Hat or an unlocked achievement to change what the pet wears. Newly unlocked cards are announced as New until you activate them. Locked achievements announce their hint and reward and cannot be selected."), localize('chatPet.achievements.accessibilityHelp.roadmap', "The final TBD card is informational and lists upcoming pet ideas. The VS Code pet and achievements are experimental and may change."), localize('chatPet.achievements.accessibilityHelp.close', "Press Escape to close the Achievements modal."), ].join('\n\n'); diff --git a/src/vs/workbench/contrib/chat/browser/chatPetAchievements.ts b/src/vs/workbench/contrib/chat/browser/chatPetAchievements.ts index b59a888a06dbfa..8ebf39f472ce3c 100644 --- a/src/vs/workbench/contrib/chat/browser/chatPetAchievements.ts +++ b/src/vs/workbench/contrib/chat/browser/chatPetAchievements.ts @@ -53,12 +53,13 @@ export interface IChatPetAchievement { readonly id: ChatPetAchievementId; readonly title: string; readonly description: string; + readonly hint: string; readonly accessories: readonly [IChatPetAccessory, ...IChatPetAccessory[]]; readonly enabled: boolean; } export type ChatPetAchievementPresentation = - | { readonly locked: true; readonly id: ChatPetAchievementId } + | { readonly locked: true; readonly id: ChatPetAchievementId; readonly hint: string; readonly rewardLabels: readonly string[] } | { readonly locked: false; readonly id: ChatPetAchievementId; readonly title: string; readonly description: string; readonly accessories: readonly IChatPetAccessory[] }; const enabledChatPetAchievements: readonly IChatPetAchievement[] = [ @@ -66,6 +67,7 @@ const enabledChatPetAchievements: readonly IChatPetAchievement[] = [ id: ChatPetAchievementIds.RequestRevision, title: localize('chatPet.achievement.requestRevision.title', "Second Draft"), description: localize('chatPet.achievement.requestRevision.description', "You edited and resent an earlier chat request."), + hint: localize('chatPet.achievement.requestRevision.hint', "An earlier request may deserve a second pass."), enabled: true, accessories: [ { @@ -82,6 +84,7 @@ const enabledChatPetAchievements: readonly IChatPetAchievement[] = [ id: ChatPetAchievementIds.FirstChatMessage, title: localize('chatPet.achievement.firstChatMessage.title', "Welcome to the Wild West"), description: localize('chatPet.achievement.firstChatMessage.description', "You sent your first chat message."), + hint: localize('chatPet.achievement.firstChatMessage.hint', "Every collection starts with a first conversation."), enabled: true, accessories: [ { @@ -97,6 +100,7 @@ const enabledChatPetAchievements: readonly IChatPetAchievement[] = [ id: ChatPetAchievementIds.IntegratedBrowserShared, title: localize('chatPet.achievement.integratedBrowserShared.title', "Shared Perspective"), description: localize('chatPet.achievement.integratedBrowserShared.description', "You shared the integrated browser with the agent."), + hint: localize('chatPet.achievement.integratedBrowserShared.hint', "Let the agent see what you see in the integrated browser."), enabled: true, accessories: [ { @@ -112,6 +116,7 @@ const enabledChatPetAchievements: readonly IChatPetAchievement[] = [ id: ChatPetAchievementIds.ModelSwitch, title: localize('chatPet.achievement.modelSwitch.title', "Model Citizen"), description: localize('chatPet.achievement.modelSwitch.description', "You selected a different model from the model picker."), + hint: localize('chatPet.achievement.modelSwitch.hint', "A different model can offer a different perspective."), enabled: true, accessories: [ { @@ -127,6 +132,7 @@ const enabledChatPetAchievements: readonly IChatPetAchievement[] = [ id: ChatPetAchievementIds.McpServerPresent, title: localize('chatPet.achievement.mcpServerPresent.title', "Server Wrangler"), description: localize('chatPet.achievement.mcpServerPresent.description', "You configured an MCP server."), + hint: localize('chatPet.achievement.mcpServerPresent.hint', "Connect Chat to a server beyond the editor."), enabled: true, accessories: [ { @@ -142,6 +148,7 @@ const enabledChatPetAchievements: readonly IChatPetAchievement[] = [ id: ChatPetAchievementIds.CustomSkillPresent, title: localize('chatPet.achievement.customSkillPresent.title', "Skilled Builder"), description: localize('chatPet.achievement.customSkillPresent.description', "You added a custom skill."), + hint: localize('chatPet.achievement.customSkillPresent.hint', "Teach Chat a skill of your own."), enabled: true, accessories: [ { @@ -160,6 +167,7 @@ export const disabledChatPetAchievements: readonly IChatPetAchievement[] = [ id: ChatPetAchievementIds.InstructionPresent, title: localize('chatPet.achievement.instructionPresent.title', "Well Instructed"), description: localize('chatPet.achievement.instructionPresent.description', "You added custom instructions."), + hint: localize('chatPet.achievement.instructionPresent.hint', "Leave Chat some standing guidance of your own."), enabled: false, accessories: [{ id: ChatPetAccessoryIds.SailorHat, @@ -173,6 +181,7 @@ export const disabledChatPetAchievements: readonly IChatPetAchievement[] = [ id: ChatPetAchievementIds.QueueOrSteeringMessage, title: localize('chatPet.achievement.queueOrSteeringMessage.title', "Course Correction"), description: localize('chatPet.achievement.queueOrSteeringMessage.description', "You queued or steered a follow-up message while chat was working."), + hint: localize('chatPet.achievement.queueOrSteeringMessage.hint', "Try changing course before the current response finishes."), enabled: false, accessories: [{ id: ChatPetAccessoryIds.SpinnerHat, @@ -186,6 +195,7 @@ export const disabledChatPetAchievements: readonly IChatPetAchievement[] = [ id: ChatPetAchievementIds.AgentsWindowOpened, title: localize('chatPet.achievement.agentsWindowOpened.title', "Mission Control"), description: localize('chatPet.achievement.agentsWindowOpened.description', "You opened the Agents window."), + hint: localize('chatPet.achievement.agentsWindowOpened.hint', "Some agent work belongs in its own window."), enabled: false, accessories: [{ id: ChatPetAccessoryIds.VikingHelmet, @@ -199,6 +209,7 @@ export const disabledChatPetAchievements: readonly IChatPetAchievement[] = [ id: ChatPetAchievementIds.ChatOutputCopied, title: localize('chatPet.achievement.chatOutputCopied.title', "Copy That"), description: localize('chatPet.achievement.chatOutputCopied.description', "You copied output from chat."), + hint: localize('chatPet.achievement.chatOutputCopied.hint', "Keep something useful from a chat response."), enabled: false, accessories: [{ id: ChatPetAccessoryIds.PartyHat, @@ -212,6 +223,7 @@ export const disabledChatPetAchievements: readonly IChatPetAchievement[] = [ id: ChatPetAchievementIds.ImageRequest, title: localize('chatPet.achievement.imageRequest.title', "Picture This"), description: localize('chatPet.achievement.imageRequest.description', "You sent a chat request with an image attached."), + hint: localize('chatPet.achievement.imageRequest.hint', "Show Chat something instead of only describing it."), enabled: false, accessories: [{ id: ChatPetAccessoryIds.ArtistBeret, @@ -312,7 +324,12 @@ export function getChatPetAchievementPresentation(achievement: IChatPetAchieveme description: achievement.description, accessories: achievement.accessories, } - : { locked: true, id: achievement.id }; + : { + locked: true, + id: achievement.id, + hint: achievement.hint, + rewardLabels: achievement.accessories.map(accessory => accessory.label), + }; } export function getUnlockedChatPetAccessories(unlockedAchievements: readonly ChatPetAchievementId[]): readonly IChatPetAccessory[] { diff --git a/src/vs/workbench/contrib/chat/browser/chatPetAchievementsWidget.ts b/src/vs/workbench/contrib/chat/browser/chatPetAchievementsWidget.ts index a57dca937d9d86..0d5d69f85fc611 100644 --- a/src/vs/workbench/contrib/chat/browser/chatPetAchievementsWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatPetAchievementsWidget.ts @@ -167,9 +167,9 @@ export class ChatPetAchievementsWidget extends Disposable { const accessoryId = achievement.accessories[0].id; const card = this.renderDisposables.add(new Button(item, { secondary: true, - ariaLabel: unlocked - ? localize('chatPet.achievement.cardAriaLabel', "{0}. Reward: {1}. {2}", achievement.title, achievement.accessories[0].label, wearing ? localize('chatPet.achievement.wearing', "Wearing") : localize('chatPet.achievement.unlocked', "Unlocked")) - : localize('chatPet.achievement.lockedAriaLabel', "Locked secret achievement"), + ariaLabel: presentation.locked + ? localize('chatPet.achievement.lockedAriaLabel', "Locked. Hint: {0} Rewards: {1}.", presentation.hint, presentation.rewardLabels.join(', ')) + : localize('chatPet.achievement.cardAriaLabel', "{0}. Reward: {1}. {2}", presentation.title, presentation.accessories[0].label, wearing ? localize('chatPet.achievement.wearing', "Wearing") : localize('chatPet.achievement.unlocked', "Unlocked")), })); card.element.classList.add('chat-pet-achievement-card'); card.element.dataset.accessoryId = accessoryId; @@ -206,8 +206,9 @@ export class ChatPetAchievementsWidget extends Disposable { }); } else { DOM.append(cardContent, DOM.$('h3')).textContent = localize('chatPet.achievement.locked', "Locked"); - DOM.append(cardContent, DOM.$('p.chat-pet-achievement-secret')).textContent = localize('chatPet.achievement.secret', "Secret achievement."); - DOM.append(cardContent, DOM.$('p.chat-pet-achievement-description')).textContent = localize('chatPet.achievement.keepExploring', "Keep exploring agent features to uncover this secret."); + DOM.append(cardContent, DOM.$('span.chat-pet-achievement-state')).textContent = localize('chatPet.achievement.hint', "Hint"); + DOM.append(cardContent, DOM.$('p.chat-pet-achievement-description')).textContent = presentation.hint; + DOM.append(cardContent, DOM.$('p.chat-pet-achievement-reward')).textContent = localize('chatPet.achievement.rewards', "Rewards: {0}", presentation.rewardLabels.join(', ')); } this.renderDisposables.add(card.onDidClick(() => this.selectAccessory(accessoryId, achievement.id))); this.renderDisposables.add(card.onDidEscape(() => this.onDidRequestClose())); diff --git a/src/vs/workbench/contrib/chat/browser/media/chatPetAchievements.css b/src/vs/workbench/contrib/chat/browser/media/chatPetAchievements.css index 217cb1d5579bef..c5d1534a1304ea 100644 --- a/src/vs/workbench/contrib/chat/browser/media/chatPetAchievements.css +++ b/src/vs/workbench/contrib/chat/browser/media/chatPetAchievements.css @@ -85,7 +85,9 @@ border-radius: var(--vscode-cornerRadius-medium); background: var(--vscode-editorWidget-background); color: var(--vscode-foreground); - line-height: normal; + font-size: var(--vscode-fontSize-label1); + font-weight: var(--vscode-fontWeight-regular); + line-height: 1.4; text-align: left; white-space: normal; } @@ -209,12 +211,11 @@ display: inline-block; margin-bottom: var(--vscode-spacing-size100); color: var(--vscode-descriptionForeground); - font-size: var(--vscode-fontSize-label2); + font-size: var(--vscode-fontSize-label1); font-weight: var(--vscode-fontWeight-semiBold); } .chat-pet-achievement-description, -.chat-pet-achievement-secret, .chat-pet-achievement-reward { margin-bottom: var(--vscode-spacing-size80); overflow-wrap: anywhere; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts index db852c03195efb..cda3fc195f2ece 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import './media/chatPet.css'; +import { BroadcastDataChannel } from '../../../../../base/browser/broadcast.js'; import * as dom from '../../../../../base/browser/dom.js'; import { GlobalPointerMoveMonitor } from '../../../../../base/browser/globalPointerMoveMonitor.js'; import { StandardKeyboardEvent } from '../../../../../base/browser/keyboardEvent.js'; @@ -12,10 +13,11 @@ import { Button } from '../../../../../base/browser/ui/button/button.js'; import { status } from '../../../../../base/browser/ui/aria/aria.js'; import { Action, IAction, Separator } from '../../../../../base/common/actions.js'; import { RunOnceScheduler } from '../../../../../base/common/async.js'; +import { Event } from '../../../../../base/common/event.js'; import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { FileAccess } from '../../../../../base/common/network.js'; -import { autorun, derived, IObservable, observableFromEvent, observableValue } from '../../../../../base/common/observable.js'; +import { autorun, IObservable, ISettableObservable, observableFromEvent, observableValue } from '../../../../../base/common/observable.js'; import { localize } from '../../../../../nls.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; @@ -31,12 +33,24 @@ import { getChatPetAccessoryRigFrame, getChatPetReducedMotionRigFrame } from './ export type ChatPetState = 'idle' | 'sleep' | 'waking' | 'typing' | 'rendering' | 'achievementUnlocked' | 'buttonPress' | 'complete' | 'love' | 'clapping' | 'jump' | 'cool' | 'yapping' | 'yappingMouthOpen' | 'sing' | 'speechless' | 'worry' | 'dizzy' | 'falling' | 'wallImpact' | 'splat' | 'onTheRun' | 'searching' | 'searchingDown'; export type ChatPetClickInteraction = Extract; +export interface IChatPetWidgetHost { + readonly parent: HTMLElement; + readonly dragBounds: HTMLElement; + readonly movementBounds: HTMLElement; + readonly model: IObservable; + readonly hasInput: IObservable; + readonly inputChanged: (listener: () => void) => IDisposable; + readonly getPlatformTop: (petCenterX: number | undefined) => number | undefined; + readonly onDidChangePlatform: Event; +} + export const CHAT_PET_IDLE_SLEEP_DELAY = 20_000; export const CHAT_PET_CONFIRMATION_ATTENTION_DURATION = 2_000; export const CHAT_PET_ACHIEVEMENT_UNLOCKED_DURATION = 10_000; export const CHAT_PET_ICON_TRANSFORMATION_CHANCE = 1 / 100; export const CHAT_PET_YAPPING_CHANCE = 1 / 100; export const CHAT_PET_WALL_IMPACT_DURATION = 48; +export const CHAT_PET_WINDOW_OWNERSHIP_CHANNEL = 'vscode-chat-pet-window-ownership'; const TRANSIENT_STATE_DURATION = 2_000; const COMPLETE_STATE_DURATION = 960; const BUTTON_PRESS_STATE_DURATION = 2_850; @@ -485,16 +499,20 @@ export function getChatPetBaseState(hasActiveRequest: boolean, needsInput: boole return 'idle'; } -export function shouldReserveChatPetSpace(enabled: boolean, isLatestFocusedWidget: boolean): boolean { - return enabled && isLatestFocusedWidget; +export function shouldReserveChatPetSpace(enabled: boolean, activeHost: boolean): boolean { + return enabled && activeHost; +} + +export function isChatPetVisible(enabled: boolean, windowActive = true): boolean { + return enabled && windowActive; } -export function isChatPetVisible(enabled: boolean, isLatestFocusedWidget: boolean, windowFocused = true): boolean { - return shouldReserveChatPetSpace(enabled, isLatestFocusedWidget) && windowFocused; +export function isChatPetWindowActive(activeWindowId: number, targetWindowId: number): boolean { + return activeWindowId === targetWindowId; } -export function isChatPetWindowActive(applicationFocused: boolean, activeWindowId: number, targetWindowId: number): boolean { - return applicationFocused && activeWindowId === targetWindowId; +export function shouldClaimChatPetWindowOnConstruction(windowActive: boolean, documentFocused: boolean): boolean { + return windowActive && documentFocused; } export function isChatPetKeyboardInteractionEnabled(enabled: boolean, isDead: boolean, hasPointerInteraction: boolean, isAirborne: boolean, onTheRun: boolean): boolean { @@ -762,6 +780,29 @@ export function getChatPetRelativeHorizontalPosition(left: number, minimumLeft: return Math.max(0, Math.min(1, (left - minimumLeft) / horizontalRange)); } +export interface ChatPetHorizontalAnchor { + readonly edge: 'left' | 'right'; + readonly inset: number; +} + +export function getChatPetHorizontalAnchor(left: number, minimumLeft: number, maximumLeft: number): ChatPetHorizontalAnchor { + const clampedLeft = getChatPetHorizontalPosition(left, minimumLeft, maximumLeft); + const normalizedMaximumLeft = Math.max(minimumLeft, maximumLeft); + const leftInset = clampedLeft - minimumLeft; + const rightInset = normalizedMaximumLeft - clampedLeft; + return leftInset <= rightInset + ? { edge: 'left', inset: leftInset } + : { edge: 'right', inset: rightInset }; +} + +export function getChatPetAnchoredHorizontalPosition(anchor: ChatPetHorizontalAnchor, minimumLeft: number, maximumLeft: number): number { + const normalizedMaximumLeft = Math.max(minimumLeft, maximumLeft); + const left = anchor.edge === 'left' + ? minimumLeft + anchor.inset + : normalizedMaximumLeft - anchor.inset; + return getChatPetHorizontalPosition(left, minimumLeft, normalizedMaximumLeft); +} + export function getChatPetScale(scale: number, delta: number): number { return Math.max(CHAT_PET_MIN_SCALE, Math.round((scale + delta) * 10) / 10); } @@ -867,12 +908,18 @@ export function shouldSettleChatPetThrow(startTime: number, currentTime: number, return currentTime - startTime >= THROW_MAX_DURATION || (top > floorTop && verticalVelocity >= 0); } -export function getChatPetFallTarget(petLeft: number, petTop: number, petWidth: number, petHeight: number, platformLeft: number, platformRight: number, platformTop: number, floorBottom: number): { readonly top: number; readonly landsOnPlatform: boolean } { +export function getChatPetFallTarget(petLeft: number, petTop: number, petWidth: number, petHeight: number, platformLeft: number, platformRight: number, platformTop: number, floorBottom: number, fallbackPlatformTop?: number): { readonly top: number; readonly landsOnPlatform: boolean } { const petCenter = petLeft + petWidth / 2; - const landsOnPlatform = petCenter >= platformLeft && petCenter <= platformRight && petTop + petHeight <= platformTop; + const isWithinPlatform = petCenter >= platformLeft && petCenter <= platformRight; + const petBottom = petTop + petHeight; + const landingPlatformTop = isWithinPlatform && petBottom <= platformTop + ? platformTop + : isWithinPlatform && fallbackPlatformTop !== undefined && petBottom <= fallbackPlatformTop + ? fallbackPlatformTop + : undefined; return { - top: landsOnPlatform ? platformTop - petHeight : floorBottom - petHeight, - landsOnPlatform, + top: landingPlatformTop !== undefined ? landingPlatformTop - petHeight : floorBottom - petHeight, + landsOnPlatform: landingPlatformTop !== undefined, }; } @@ -912,6 +959,15 @@ export function getChatPetPlatformTop(hostTop: number, inputTop: number, substan return hostTop + getChatPetVerticalOffset(hostTop, inputTop); } +export function getChatPetPillPlatformTop(petCenterX: number, pillBounds: readonly Pick[]): number | undefined { + for (const bounds of pillBounds) { + if (bounds.width > 0 && bounds.height > 0 && petCenterX >= bounds.left && petCenterX <= bounds.right) { + return bounds.top; + } + } + return undefined; +} + export function shouldPlaceChatPetSpeechBubbleLeft(state: ChatPetState | undefined, buttonRight: number, inputRight: number, scale = 1): boolean { return state === 'rendering' && buttonRight + CHAT_PET_SPEECH_BUBBLE_RIGHT_OVERHANG * scale > inputRight; } @@ -1010,6 +1066,11 @@ export class ChatPetHopController extends Disposable { export class ChatPetWidget extends Disposable { + private parent: HTMLElement; + private dragBounds: HTMLElement; + private movementBounds: HTMLElement; + private readonly _host: ISettableObservable; + private readonly _hostLayoutDisposables = this._register(new MutableDisposable()); private readonly _overlay: HTMLElement; private readonly _button: Button; private readonly _visual: HTMLElement; @@ -1049,7 +1110,10 @@ export class ChatPetWidget extends Disposable { private readonly _respawnFallScheduler = this._register(new RunOnceScheduler(() => this._beginRespawnFall(), RESPAWN_EFFECT_DURATION)); private readonly _hopController = this._register(new ChatPetHopController({ onDirectionChange: direction => this._button.element.dataset.hopDirection = direction < 0 ? 'left' : 'right', - onMove: delta => this._setHorizontalPosition(this._getCurrentLeft() + delta), + onMove: delta => { + this._setHorizontalPosition(this._getCurrentLeft() + delta); + this._updateVerticalPosition(); + }, onStart: () => { if (this._transientState.get() === 'jump') { this._renderState('jump', true); @@ -1078,6 +1142,7 @@ export class ChatPetWidget extends Disposable { private _enablementInitialized = false; private _positionInitialized = false; private _hasCustomPosition = false; + private _horizontalAnchor: ChatPetHorizontalAnchor | undefined; private _suppressNextPointerClick = false; private _contextMenuVisible = false; private _lastClickInteraction: ChatPetClickInteraction | undefined; @@ -1087,20 +1152,13 @@ export class ChatPetWidget extends Disposable { private _deathPosition: readonly [number, number] | undefined; private _respawnPhase: 'none' | 'despawning' | 'respawning' | 'falling' = 'none'; private _respawnPosition: readonly [number, number] | undefined; - private _platformTopProvider: (() => number | undefined) | undefined; private readonly _resizeObserver: dom.DisposableResizeObserver; private _variant: ChatPetVariant; private _selectedAccessory: ChatPetAccessoryId | undefined; private _scale = 1; constructor( - private readonly parent: HTMLElement, - private readonly dragBounds: HTMLElement, - private readonly movementBounds: HTMLElement, - model: IObservable, - hasInput: IObservable, - isLatestFocusedWidget: IObservable, - inputChanged: (listener: () => void) => IDisposable, + host: IChatPetWidgetHost, @IChatPetService private readonly chatPetService: IChatPetService, @IAccessibilityService private readonly accessibilityService: IAccessibilityService, @IContextMenuService private readonly contextMenuService: IContextMenuService, @@ -1110,13 +1168,20 @@ export class ChatPetWidget extends Disposable { ) { super(); + this.parent = host.parent; + this.dragBounds = host.dragBounds; + this.movementBounds = host.movementBounds; + this._host = observableValue(this, host); this._variant = this.chatPetService.variant.get(); this._selectedAccessory = this.chatPetService.selectedAccessory.get(); this._searchScheduler = this._register(new RunOnceScheduler(() => this._trySearch(), SEARCH_INTERVAL)); this.parent.classList.add('chat-pet-host'); this._overlay = dom.$('.chat-pet-overlay'); this.parent.prepend(this._overlay); - this._register(toDisposable(() => this._overlay.remove())); + this._register(toDisposable(() => { + this.parent.classList.remove('chat-pet-host'); + this._overlay.remove(); + })); this._button = this._register(new Button(this._overlay, { ariaLabel: this._getAriaLabel(false, false), })); @@ -1179,45 +1244,11 @@ export class ChatPetWidget extends Disposable { speechBubbleImage.alt = ''; speechBubbleImage.setAttribute('aria-hidden', 'true'); this._speechBubble = { container: speechBubbleContainer, image: speechBubbleImage, canvas: speechBubbleCanvas }; - this._resizeObserver = this._register(new dom.DisposableResizeObserver('ChatPetWidget.dragBounds', () => { - if (!this._enabled || this._getHorizontalBounds() === undefined) { - return; - } - if (!this._positionInitialized) { - this._updateVerticalPosition(); - this._restoreHorizontalPosition(); - return; - } - this._updateSpeechBubblePosition(); - const isAirborne = this._isAirborne(); - if (this._isDead.get()) { - this._updateRespawnEffectPosition(); - } else if (isAirborne) { - if (this._button.element.classList.contains('throwing')) { - this._throwGeometryDirty = true; - } - return; - } else if (this._fallLandsOnPlatform && !this._isDragging.get()) { - if (this._hasCustomPosition) { - this._setPlatformPosition(this._getCurrentLeft()); - } else { - this._setDefaultPlatformPosition(); - } - } else { - this._updateVerticalPosition(); - if (this._hasCustomPosition && !this._isDragging.get()) { - this._setHorizontalPosition(this._getCurrentLeft()); - } else if (!this._isDragging.get()) { - this._setDefaultHorizontalPosition(); - } - } - }, dom.getWindow(this._button.element))); - this._register(this._resizeObserver.observe(this.dragBounds)); - this._register(this._resizeObserver.observe(this.movementBounds)); - this._register(this._resizeObserver.observe(this.parent)); + this._resizeObserver = this._register(new dom.DisposableResizeObserver('ChatPetWidget.dragBounds', () => this._handleHostLayoutChange(), dom.getWindow(this._button.element))); + this._observeHost(host); if (this._getHorizontalBounds() !== undefined) { - this._updateVerticalPosition(); this._restoreHorizontalPosition(); + this._updateVerticalPosition(); this._updateSpeechBubblePosition(); } this._register(dom.addDisposableListener(speechBubbleImage, 'load', () => this._updateSpeechBubble(this._renderedState, true))); @@ -1259,10 +1290,13 @@ export class ChatPetWidget extends Disposable { dom.EventHelper.stop(event, true); this._showContextMenu(event); })); - this._register(inputChanged(() => { - if (this._enabled && !this.chatPetService.onTheRun.get()) { - this._wake(); - } + this._register(autorun(reader => { + const currentHost = this._host.read(reader); + reader.store.add(currentHost.inputChanged(() => { + if (this._enabled && !this.chatPetService.onTheRun.read(undefined)) { + this._wake(); + } + })); })); this._register(this._button.onDidClick(e => { @@ -1336,9 +1370,26 @@ export class ChatPetWidget extends Disposable { const motionReduced = observableFromEvent(this, this.accessibilityService.onDidChangeReducedMotion, () => this.accessibilityService.isMotionReduced()); const targetWindow = dom.getWindow(this._button.element); const targetWindowId = dom.getWindowId(targetWindow); - const applicationFocused = observableFromEvent(this, this.hostService.onDidChangeFocus, () => this.hostService.hasFocus); - const activeWindowId = observableFromEvent(this, this.hostService.onDidChangeActiveWindow, windowId => windowId ?? dom.getWindowId(dom.getActiveWindow())); - const windowFocused = derived(this, reader => isChatPetWindowActive(applicationFocused.read(reader), activeWindowId.read(reader), targetWindowId)); + const windowActive = observableValue(this, isChatPetWindowActive(dom.getWindowId(dom.getActiveWindow()), targetWindowId)); + const ownershipChannel = this._register(new BroadcastDataChannel<{ readonly windowId: number }>(CHAT_PET_WINDOW_OWNERSHIP_CHANNEL)); + this._register(ownershipChannel.onDidReceiveData(({ windowId }) => { + windowActive.set(isChatPetWindowActive(windowId, targetWindowId), undefined); + })); + const claimWindowOwnership = () => { + windowActive.set(true, undefined); + ownershipChannel.postData({ windowId: targetWindowId }); + }; + this._register(dom.addDisposableListener(targetWindow, dom.EventType.FOCUS, claimWindowOwnership)); + this._register(this.hostService.onDidChangeActiveWindow(windowId => { + if (isChatPetWindowActive(windowId, targetWindowId)) { + claimWindowOwnership(); + } else { + windowActive.set(false, undefined); + } + })); + if (shouldClaimChatPetWindowOnConstruction(windowActive.get(), targetWindow.document.hasFocus())) { + claimWindowOwnership(); + } this._register(autorun(reader => { const wasMotionReduced = this._motionReduced; this._motionReduced = motionReduced.read(reader); @@ -1349,13 +1400,12 @@ export class ChatPetWidget extends Disposable { this._finishThrow(); } const serviceEnabled = this.chatPetService.enabled.read(reader); - const latestFocusedWidget = isLatestFocusedWidget.read(reader); - const isWindowFocused = windowFocused.read(reader); + const isWindowActive = windowActive.read(reader); const scale = this.chatPetService.scale.read(reader); if (scale !== this._scale) { this._setScale(scale); } - const enabled = isChatPetVisible(serviceEnabled, latestFocusedWidget, isWindowFocused); + const enabled = isChatPetVisible(serviceEnabled, isWindowActive); const variant = this.chatPetService.variant.read(reader); const variantChanged = variant !== this._variant; this._variant = variant; @@ -1368,7 +1418,8 @@ export class ChatPetWidget extends Disposable { const onTheRun = this.chatPetService.onTheRun.read(reader); const isDead = this._isDead.read(reader); this._button.element.classList.toggle('on-the-run', onTheRun); - const chatModel = model.read(reader); + const currentHost = this._host.read(reader); + const chatModel = currentHost.model.read(reader); const request = chatModel?.lastRequestObs.read(reader); const needsInput = !!request?.response?.isPendingConfirmation.read(reader); let confirmationAttentionExpired = this._confirmationAttentionExpired.read(reader); @@ -1382,7 +1433,7 @@ export class ChatPetWidget extends Disposable { this._confirmationAttentionScheduler.schedule(); } const hasActiveRequest = chatModel?.hasActiveRequest.read(reader) ?? false; - const inputHasContent = hasInput.read(reader); + const inputHasContent = currentHost.hasInput.read(reader); this._busy = hasActiveRequest || needsInput; let idleExpired = this._idleExpired.read(reader); let transientState = this._transientState.read(reader); @@ -1400,7 +1451,7 @@ export class ChatPetWidget extends Disposable { this._startEnableAnimation(); } } else if (wasInitialized) { - if (serviceEnabled && (!latestFocusedWidget || !isWindowFocused)) { + if (serviceEnabled && !isWindowActive) { this._finishDisable(); } else { this._startDisableAnimation(); @@ -1469,7 +1520,7 @@ export class ChatPetWidget extends Disposable { })); this._register(autorun(reader => { - const chatModel = model.read(reader); + const chatModel = this._host.read(reader).model.read(reader); const response = chatModel?.lastRequestObs.read(reader)?.response; if (!response) { return; @@ -1482,21 +1533,82 @@ export class ChatPetWidget extends Disposable { })); } - setPlatformTopProvider(provider: () => number | undefined): void { - this._platformTopProvider = provider; + setHost(host: IChatPetWidgetHost): void { + if (this._host.get() === host) { + return; + } + + this.parent.classList.remove('chat-pet-host'); + this.parent = host.parent; + this.dragBounds = host.dragBounds; + this.movementBounds = host.movementBounds; + this.parent.classList.add('chat-pet-host'); + this.parent.prepend(this._overlay); + this._observeHost(host); + this._host.set(host, undefined); + this._handleHostLayoutChange(); + } + + private _observeHost(host: IChatPetWidgetHost): void { + const store = new DisposableStore(); + store.add(this._resizeObserver.observe(host.dragBounds)); + store.add(this._resizeObserver.observe(host.movementBounds)); + store.add(this._resizeObserver.observe(host.parent)); + store.add(host.onDidChangePlatform(() => this._updatePlatformPosition())); + this._hostLayoutDisposables.value = store; + } + + private _handleHostLayoutChange(): void { + if (!this._enabled || this._getHorizontalBounds() === undefined) { + return; + } + if (!this._positionInitialized) { + this._restoreHorizontalPosition(); + this._updateVerticalPosition(); + return; + } + this._updateSpeechBubblePosition(); + if (this._isDead.get()) { + this._updateRespawnEffectPosition(); + } else if (this._isAirborne()) { + if (this._button.element.classList.contains('throwing')) { + this._throwGeometryDirty = true; + } + } else { + this._updateRestingPosition(); + } + } + + private _updatePlatformPosition(): void { + if (!this._enabled || this._isDead.get() || this._getHorizontalBounds() === undefined) { + return; + } if (this._isAirborne()) { if (this._button.element.classList.contains('throwing')) { this._throwGeometryDirty = true; } return; } - this._updateVerticalPosition(); - if (this._fallLandsOnPlatform && !this._isDragging.get()) { + this._updateRestingPosition(); + } + + private _updateRestingPosition(): void { + if (this._isDragging.get()) { + return; + } + if (this._fallLandsOnPlatform) { if (this._hasCustomPosition) { - this._setPlatformPosition(this._getCurrentLeft()); + this._setAnchoredPlatformPosition(); } else { this._setDefaultPlatformPosition(); } + } else { + if (this._hasCustomPosition) { + this._setAnchoredHorizontalPosition(); + } else { + this._setDefaultHorizontalPosition(); + } + this._updateVerticalPosition(); } } @@ -1576,6 +1688,7 @@ export class ChatPetWidget extends Disposable { private _getFallTarget(): { readonly top: number; readonly landsOnPlatform: boolean } { const overlayBounds = this._overlay.getBoundingClientRect(); const platformBounds = this._getPlatformBounds(); + const fallbackPlatformBounds = this._getPlatformBounds(false); const movementBounds = this.movementBounds.getBoundingClientRect(); return getChatPetFallTarget( Number.parseFloat(this._button.element.style.left), @@ -1586,6 +1699,7 @@ export class ChatPetWidget extends Disposable { platformBounds.right - overlayBounds.left, platformBounds.top - overlayBounds.top, movementBounds.bottom - overlayBounds.top, + fallbackPlatformBounds.top - overlayBounds.top, ); } @@ -1598,7 +1712,7 @@ export class ChatPetWidget extends Disposable { private _getThrowGeometry(): ChatPetThrowGeometry { const overlayBounds = this._overlay.getBoundingClientRect(); const movementBounds = this.movementBounds.getBoundingClientRect(); - const platformBounds = this._getPlatformBounds(); + const platformBounds = this._getPlatformBounds(false); const displaySize = this._getDisplaySize(); return { bounds: { @@ -1969,23 +2083,10 @@ export class ChatPetWidget extends Disposable { if (this._button.element.classList.contains('throwing')) { this._throwGeometryDirty = true; } - if (this._isDead.get() || this._isDragging.get() || this._isAirborne()) { + if (!this._enabled || this._isDead.get() || this._isDragging.get() || this._isAirborne()) { return; } - if (this._fallLandsOnPlatform) { - if (this._hasCustomPosition) { - this._setPlatformPosition(this._getCurrentLeft()); - } else { - this._setDefaultPlatformPosition(); - } - } else { - this._updateVerticalPosition(); - if (this._hasCustomPosition) { - this._setHorizontalPosition(this._getCurrentLeft()); - } else { - this._setDefaultHorizontalPosition(); - } - } + this._updateRestingPosition(); } private _setHorizontalPosition(left: number): boolean { @@ -1993,15 +2094,33 @@ export class ChatPetWidget extends Disposable { if (!bounds) { return false; } + return this._applyHorizontalPosition(left, bounds, true); + } + + private _setAnchoredHorizontalPosition(): void { + const bounds = this._getHorizontalBounds(); + if (!bounds) { + return; + } + const left = this._horizontalAnchor + ? getChatPetAnchoredHorizontalPosition(this._horizontalAnchor, bounds.minimumLeft, bounds.maximumLeft) + : this._getCurrentLeft(); + this._applyHorizontalPosition(left, bounds, false); + } + + private _applyHorizontalPosition(left: number, bounds: { readonly minimumLeft: number; readonly maximumLeft: number }, updateAnchor: boolean): boolean { const { minimumLeft, maximumLeft } = bounds; const clampedLeft = getChatPetHorizontalPosition(left, minimumLeft, maximumLeft); this._button.element.style.left = `${clampedLeft}px`; this._button.element.style.right = 'auto'; this._positionInitialized = true; this._hasCustomPosition = true; - const relativePosition = getChatPetRelativeHorizontalPosition(clampedLeft, minimumLeft, maximumLeft); - if (relativePosition !== undefined) { - this.chatPetService.setHorizontalPosition(relativePosition); + if (updateAnchor) { + this._horizontalAnchor = getChatPetHorizontalAnchor(clampedLeft, minimumLeft, maximumLeft); + const relativePosition = getChatPetRelativeHorizontalPosition(clampedLeft, minimumLeft, maximumLeft); + if (relativePosition !== undefined) { + this.chatPetService.setHorizontalPosition(relativePosition); + } } this._updateSpeechBubblePosition(); return clampedLeft !== left; @@ -2017,6 +2136,7 @@ export class ChatPetWidget extends Disposable { this._button.element.style.right = 'auto'; this._positionInitialized = true; this._hasCustomPosition = false; + this._horizontalAnchor = undefined; this._updateSpeechBubblePosition(); } @@ -2032,36 +2152,44 @@ export class ChatPetWidget extends Disposable { }; } - private _getPlatformBounds(): { readonly left: number; readonly right: number; readonly top: number } { + private _getPlatformBounds(includeHorizontalPlatform = true): { readonly left: number; readonly right: number; readonly top: number } { const hostBounds = this._overlay.getBoundingClientRect(); const inputBounds = this.dragBounds.getBoundingClientRect(); + const petCenterX = includeHorizontalPlatform ? hostBounds.left + this._getCurrentLeft() + this._getDisplaySize() / 2 : undefined; return { left: inputBounds.left, right: inputBounds.right, - top: getChatPetPlatformTop(hostBounds.top, inputBounds.top, this._platformTopProvider?.()), + top: getChatPetPlatformTop(hostBounds.top, inputBounds.top, this._host.get().getPlatformTop(petCenterX)), }; } private _updateVerticalPosition(): void { const overlayBounds = this._overlay.getBoundingClientRect(); const platformTop = this._getPlatformBounds().top; + this._button.element.style.top = 'auto'; this._button.element.style.bottom = `calc(100% - ${platformTop - overlayBounds.top}px)`; } private _setPlatformPosition(left: number): void { - const overlayBounds = this._overlay.getBoundingClientRect(); - const platformBounds = this._getPlatformBounds(); - this._button.element.style.top = `${platformBounds.top - overlayBounds.top - this._getDisplaySize()}px`; - this._button.element.style.bottom = 'auto'; this._setHorizontalPosition(left); + this._updatePlatformVerticalPosition(); } - private _setDefaultPlatformPosition(): void { + private _setAnchoredPlatformPosition(): void { + this._setAnchoredHorizontalPosition(); + this._updatePlatformVerticalPosition(); + } + + private _updatePlatformVerticalPosition(): void { const overlayBounds = this._overlay.getBoundingClientRect(); const platformBounds = this._getPlatformBounds(); this._button.element.style.top = `${platformBounds.top - overlayBounds.top - this._getDisplaySize()}px`; this._button.element.style.bottom = 'auto'; + } + + private _setDefaultPlatformPosition(): void { this._setDefaultHorizontalPosition(); + this._updatePlatformVerticalPosition(); } private _showRespawnSequence(): void { @@ -2254,6 +2382,7 @@ export class ChatPetWidget extends Disposable { this._button.element.classList.remove('hidden', 'exiting', 'entering'); this._button.element.tabIndex = 0; this._restoreHorizontalPosition(); + this._updateVerticalPosition(); this._button.element.getBoundingClientRect(); this._gazeScheduler.schedule(); if (!this._motionReduced) { @@ -2267,10 +2396,12 @@ export class ChatPetWidget extends Disposable { return; } const relativePosition = this.chatPetService.horizontalPosition.get(); - this._button.element.style.left = `${getChatPetRestoredHorizontalPosition(relativePosition, bounds.minimumLeft, bounds.maximumLeft)}px`; + const left = getChatPetRestoredHorizontalPosition(relativePosition, bounds.minimumLeft, bounds.maximumLeft); + this._button.element.style.left = `${left}px`; this._button.element.style.right = 'auto'; this._positionInitialized = true; this._hasCustomPosition = relativePosition !== undefined; + this._horizontalAnchor = relativePosition === undefined ? undefined : getChatPetHorizontalAnchor(left, bounds.minimumLeft, bounds.maximumLeft); this._updateSpeechBubblePosition(); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatPetWidgetService.ts b/src/vs/workbench/contrib/chat/browser/widget/chatPetWidgetService.ts new file mode 100644 index 00000000000000..b869eb05703ea6 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatPetWidgetService.ts @@ -0,0 +1,230 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../base/browser/dom.js'; +import { Event } from '../../../../../base/common/event.js'; +import { Disposable, DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { autorun, constObservable, IObservable, ISettableObservable, observableValue, transaction } from '../../../../../base/common/observable.js'; +import { createDecorator, IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IChatWidgetService } from '../chat.js'; +import { ChatPetWidget, IChatPetWidgetHost } from './chatPetWidget.js'; + +export const IChatPetWidgetService = createDecorator('chatPetWidgetService'); + +export interface IChatPetWidgetHostRegistration extends IDisposable { + readonly active: IObservable; +} + +export interface IChatPetWidgetService { + readonly _serviceBrand: undefined; + register(owner: object, host: IChatPetWidgetHost, preferred?: IObservable, onDidFocus?: Event): IChatPetWidgetHostRegistration; +} + +interface IChatPetWidgetInstance extends IDisposable { + setHost(host: IChatPetWidgetHost): void; +} + +interface IChatPetHostEntry { + readonly owner: object; + readonly host: IChatPetWidgetHost; + readonly windowId: number; + readonly preferred: IObservable | undefined; + readonly active: ISettableObservable; + readonly store: DisposableStore; +} + +interface IChatPetWindowEntry { + readonly pet: IChatPetWidgetInstance; + readonly dormantHost: IChatPetWidgetHost; + activeHost: IChatPetHostEntry | undefined; +} + +export class ChatPetWidgetCoordinator extends Disposable { + + private readonly hosts = new Map(); + private readonly windows = new Map(); + + constructor( + private readonly createPet: (host: IChatPetWidgetHost) => IChatPetWidgetInstance, + private readonly chatWidgetService: IChatWidgetService, + onWillUnregisterWindow: Event = Event.None, + ) { + super(); + this._register(this.chatWidgetService.onDidChangeFocusedWidget(widget => { + const host = widget ? this.hosts.get(widget) : undefined; + if (host) { + this.activate(host); + } + })); + this._register(onWillUnregisterWindow(windowId => this.disposeWindow(windowId))); + } + + register(owner: object, host: IChatPetWidgetHost, preferred?: IObservable, onDidFocus?: Event): IChatPetWidgetHostRegistration { + if (this.hosts.has(owner)) { + throw new Error('Cannot register the same chat pet host multiple times'); + } + + const windowId = dom.getWindowId(dom.getWindow(host.parent)); + const entry: IChatPetHostEntry = { + owner, + host, + windowId, + preferred, + active: observableValue(this, false), + store: new DisposableStore(), + }; + this.hosts.set(owner, entry); + + if (onDidFocus) { + entry.store.add(onDidFocus(() => this.activate(entry))); + } + + if (preferred) { + entry.store.add(autorun(reader => { + if (preferred.read(reader)) { + this.activate(entry); + } else if (entry.active.read(reader)) { + const replacement = this.findPreferredHost(windowId); + if (replacement) { + this.activate(replacement); + } + } + })); + } + + if (this.chatWidgetService.lastFocusedWidget === owner || (!preferred && !this.windows.has(windowId))) { + this.activate(entry); + } + + return { + active: entry.active, + dispose: () => this.unregister(entry), + }; + } + + private activate(entry: IChatPetHostEntry): void { + const current = this.windows.get(entry.windowId); + if (current?.activeHost === entry) { + return; + } + + if (current) { + current.pet.setHost(entry.host); + transaction(tx => { + current.activeHost?.active.set(false, tx); + entry.active.set(true, tx); + }); + current.activeHost = entry; + return; + } + + const pet = this.createPet(entry.host); + entry.active.set(true, undefined); + this.windows.set(entry.windowId, { + pet, + dormantHost: this.createDormantHost(entry.host), + activeHost: entry, + }); + } + + private unregister(entry: IChatPetHostEntry): void { + if (this.hosts.get(entry.owner) !== entry) { + return; + } + + entry.store.dispose(); + this.hosts.delete(entry.owner); + const windowEntry = this.windows.get(entry.windowId); + if (windowEntry?.activeHost !== entry) { + return; + } + + entry.active.set(false, undefined); + const replacement = this.findReplacementHost(entry.windowId); + if (replacement) { + this.activate(replacement); + } else { + windowEntry.pet.setHost(windowEntry.dormantHost); + windowEntry.activeHost = undefined; + } + } + + private createDormantHost(host: IChatPetWidgetHost): IChatPetWidgetHost { + const parent = host.parent.ownerDocument.createElement('div'); + return { + parent, + dragBounds: parent, + movementBounds: parent, + model: constObservable(undefined), + hasInput: constObservable(false), + inputChanged: Event.None, + getPlatformTop: () => undefined, + onDidChangePlatform: Event.None, + }; + } + + private disposeWindow(windowId: number): void { + const windowEntry = this.windows.get(windowId); + if (!windowEntry) { + return; + } + windowEntry.activeHost?.active.set(false, undefined); + windowEntry.pet.dispose(); + this.windows.delete(windowId); + } + + private findPreferredHost(windowId: number): IChatPetHostEntry | undefined { + return this.getWindowHosts(windowId).find(entry => entry.preferred?.get()); + } + + private findReplacementHost(windowId: number): IChatPetHostEntry | undefined { + const hosts = this.getWindowHosts(windowId); + const preferred = hosts.find(entry => entry.preferred?.get()); + if (preferred) { + return preferred; + } + const focused = this.chatWidgetService.lastFocusedWidget; + return hosts.find(entry => entry.owner === focused) ?? hosts[0]; + } + + private getWindowHosts(windowId: number): IChatPetHostEntry[] { + return Array.from(this.hosts.values()).filter(entry => entry.windowId === windowId); + } + + override dispose(): void { + for (const entry of this.hosts.values()) { + entry.store.dispose(); + } + this.hosts.clear(); + for (const entry of this.windows.values()) { + entry.pet.dispose(); + } + this.windows.clear(); + super.dispose(); + } +} + +export class ChatPetWidgetService extends Disposable implements IChatPetWidgetService { + + declare readonly _serviceBrand: undefined; + + private readonly coordinator: ChatPetWidgetCoordinator; + + constructor( + @IInstantiationService instantiationService: IInstantiationService, + @IChatWidgetService chatWidgetService: IChatWidgetService, + ) { + super(); + this.coordinator = this._register(new ChatPetWidgetCoordinator( + host => instantiationService.createInstance(ChatPetWidget, host), + chatWidgetService, + Event.map(dom.onWillUnregisterWindow, window => dom.getWindowId(window)), + )); + } + + register(owner: object, host: IChatPetWidgetHost, preferred?: IObservable, onDidFocus?: Event): IChatPetWidgetHostRegistration { + return this.coordinator.register(owner, host, preferred, onDidFocus); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 8fb1a93f9aedb2..5f8d74b695edac 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -22,7 +22,7 @@ import { ResourceSet } from '../../../../../base/common/map.js'; import { Schemas } from '../../../../../base/common/network.js'; import { IsSessionsWindowContext } from '../../../../common/contextkeys.js'; import { filter } from '../../../../../base/common/objects.js'; -import { autorun, derived, observableFromEvent, observableValue } from '../../../../../base/common/observable.js'; +import { autorun, derived, IObservable, observableFromEvent, observableValue } from '../../../../../base/common/observable.js'; import { extUri, isEqual } from '../../../../../base/common/resources.js'; import { isDefined } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -103,7 +103,8 @@ import { getChatSessionType } from '../../common/model/chatUri.js'; import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; import { CHAT_READ_ONLY_BANNER_HEIGHT, ChatReadOnlyBanner } from './chatReadOnlyBanner.js'; import { IChatSubmitRequestHandlerService } from '../chatSubmitRequestHandlerService.js'; -import { ChatPetWidget, shouldReserveChatPetSpace } from './chatPetWidget.js'; +import { shouldReserveChatPetSpace } from './chatPetWidget.js'; +import { IChatPetWidgetService } from './chatPetWidgetService.js'; import { IChatPetService } from '../chatPetService.js'; import { ChatPetAchievementIds, hasChatPetImageAttachment } from '../chatPetAchievements.js'; import { stopDictationForEditor } from '../speechToText/dictationSession.js'; @@ -566,6 +567,7 @@ export class ChatWidget extends Disposable implements IChatWidget { @IChatGoalSummaryService private readonly chatGoalSummaryService: IChatGoalSummaryService, @IChatSubmitRequestHandlerService private readonly chatSubmitRequestHandlerService: IChatSubmitRequestHandlerService, @IChatPetService private readonly chatPetService: IChatPetService, + @IChatPetWidgetService private readonly chatPetWidgetService: IChatPetWidgetService, @IAgentHostService private readonly _agentHostService: IAgentHostService, @IAgentHostCustomizationService private readonly _agentHostCustomizationService: IAgentHostCustomizationService, @IAgentHostNewSessionFolderService private readonly _agentHostNewSessionFolderService: IAgentHostNewSessionFolderService, @@ -991,7 +993,7 @@ export class ChatWidget extends Disposable implements IChatWidget { return this.input.attachmentModel; } - render(parent: HTMLElement, petMovementBounds?: HTMLElement): void { + render(parent: HTMLElement, petMovementBounds?: HTMLElement, preferredPetHost?: IObservable): void { const viewId = isIChatViewViewContext(this.viewContext) ? this.viewContext.viewId : undefined; this.editorOptions = this._register(this.instantiationService.createInstance(ChatEditorOptions, viewId, this.styles.listForeground, this.styles.inputEditorBackground, this.styles.resultEditorBackground)); const renderInputOnTop = this.viewOptions.renderInputOnTop ?? false; @@ -1043,17 +1045,18 @@ export class ChatWidget extends Disposable implements IChatWidget { const inputContainer = this.inputPart.inputContainerElement; const petHost = this.inputPart.element; const inputHasContent = observableFromEvent(this, this.inputEditor.onDidChangeModelContent, () => this.inputEditor.getValue().length > 0); - const targetWindow = dom.getWindow(this.container); - const isLatestFocusedWidgetInWindow = observableValue(this, this.chatWidgetService.lastFocusedWidget === this); - this._register(this.chatWidgetService.onDidChangeFocusedWidget(focusedWidget => { - if (focusedWidget && dom.getWindow(focusedWidget.domNode) === targetWindow) { - isLatestFocusedWidgetInWindow.set(focusedWidget === this, undefined); - } - })); - const petSpaceReserved = derived(this, reader => shouldReserveChatPetSpace(this.chatPetService.enabled.read(reader), isLatestFocusedWidgetInWindow.read(reader))); + const registration = this._register(this.chatPetWidgetService.register(this, { + parent: petHost, + dragBounds: inputContainer ?? petHost, + movementBounds: petMovementBounds ?? parent, + model: this._viewModelObs.map(viewModel => viewModel?.model), + hasInput: inputHasContent, + inputChanged: this.inputEditor.onDidChangeModelContent, + getPlatformTop: petCenterX => this.inputPart.getChatPetPlatformTop(petCenterX), + onDidChangePlatform: this.inputPart.onDidChangeChatPetHorizontalPlatforms, + }, preferredPetHost)); + const petSpaceReserved = derived(this, reader => shouldReserveChatPetSpace(this.chatPetService.enabled.read(reader), registration.active.read(reader))); this._register(autorun(reader => this.container.classList.toggle('chat-pet-enabled', petSpaceReserved.read(reader)))); - const petWidget = this._register(this.instantiationService.createInstance(ChatPetWidget, petHost, inputContainer ?? petHost, petMovementBounds ?? parent, this._viewModelObs.map(viewModel => viewModel?.model), inputHasContent, isLatestFocusedWidgetInWindow, this.inputEditor.onDidChangeModelContent)); - petWidget.setPlatformTopProvider(() => this.inputPart.getChatPetPlatformTop()); } this.renderWelcomeViewContentIfNeeded(); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 6689b36a781639..ae4814c880301e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -151,6 +151,7 @@ import { ChatArtifactsWidget } from '../chatArtifactsWidget.js'; import { handleTerminalCommandPaste, isTerminalCommandInput, isTerminalCommandPaste as isTerminalCommandPasteContent } from '../../chatTerminalCommandPaste.js'; import { ChatDynamicVariableModel } from '../../attachments/chatDynamicVariables.js'; import { ChatDragAndDrop } from '../chatDragAndDrop.js'; +import { getChatPetPillPlatformTop } from '../chatPetWidget.js'; import { ChatFollowups } from './chatFollowups.js'; import { IChatInputNotificationService } from './chatInputNotificationService.js'; import { ChatGoalBannerWidget } from './chatGoalBannerWidget.js'; @@ -191,6 +192,12 @@ export interface IChatInputStyles { listShadow?: string; } +/** A dynamic set of elements that can act as raised platforms for the chat pet. */ +export interface IChatPetHorizontalPlatformProvider { + readonly onDidChange: Event; + getElements(): readonly HTMLElement[]; +} + export interface IChatInputPartOptions { defaultMode?: IChatMode; renderFollowups: boolean; @@ -445,6 +452,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private chatInputNotificationContainer!: HTMLElement; private chatGoalBannerContainer!: HTMLElement; private persistentContentContainer!: HTMLElement; + private readonly _chatPetHorizontalPlatformProviders = new Set(); + private readonly _onDidChangeChatPetHorizontalPlatforms = this._register(new Emitter()); + readonly onDidChangeChatPetHorizontalPlatforms = this._onDidChangeChatPetHorizontalPlatforms.event; private inputContainer!: HTMLElement; private inputAndSideToolbar!: HTMLElement; private readonly _notificationWidget = this._register(new MutableDisposable()); @@ -476,8 +486,36 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge /** Arbitrates which notice occupies the area above this input. */ readonly noticeHost = this._register(new ChatInputNoticeHost(() => this.focus())); - getChatPetPlatformTop(): number { + /** Registers raised platforms that occupy only part of the input width. */ + registerChatPetHorizontalPlatformProvider(provider: IChatPetHorizontalPlatformProvider): IDisposable { + this._chatPetHorizontalPlatformProviders.add(provider); + const store = new DisposableStore(); + store.add(provider.onDidChange(() => this._onDidChangeChatPetHorizontalPlatforms.fire())); + store.add(toDisposable(() => { + this._chatPetHorizontalPlatformProviders.delete(provider); + this._onDidChangeChatPetHorizontalPlatforms.fire(); + })); + this._onDidChangeChatPetHorizontalPlatforms.fire(); + return store; + } + + getChatPetPlatformTop(petCenterX?: number): number { const inputTop = this.inputContainer.getBoundingClientRect().top; + if (petCenterX !== undefined) { + const pillBounds: DOMRect[] = []; + for (const provider of this._chatPetHorizontalPlatformProviders) { + for (const element of provider.getElements()) { + pillBounds.push(element.getBoundingClientRect()); + } + } + const pillTop = getChatPetPillPlatformTop( + petCenterX, + pillBounds + ); + if (pillTop !== undefined) { + return pillTop; + } + } let container = this.container; let previousElement: Element | undefined = this.persistentContentContainer; while (true) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chatPet.css b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet.css index b24f3b7bd38f20..4cb205a5abbdf9 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chatPet.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet.css @@ -38,9 +38,7 @@ cursor: grabbing; } -.chat-pet-button.dragging, -.chat-pet-button.falling, -.chat-pet-button.throwing, +.chat-pet-button, .chat-pet-respawn-effect { z-index: 1; } diff --git a/src/vs/workbench/contrib/chat/test/browser/chatPetAchievementsEditor.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatPetAchievementsEditor.test.ts index 8cfefac4336a41..160de1e60842bf 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatPetAchievementsEditor.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatPetAchievementsEditor.test.ts @@ -87,6 +87,62 @@ suite('Chat Pet Achievements Editor', () => { assert.strictEqual(openCount, 0); }); + test('shows locked hints and rewards without revealing achievement names', () => { + const parent = mainWindow.document.createElement('div'); + parent.style.setProperty('--vscode-fontSize-heading3', '13px'); + parent.style.setProperty('--vscode-fontSize-label1', '12px'); + mainWindow.document.body.appendChild(parent); + store.add(toDisposable(() => parent.remove())); + const chatPetService = new class extends mock() { + override readonly enabled = constObservable(true); + override readonly unlockedAchievements = constObservable([]); + override readonly unseenAchievements = constObservable([]); + override readonly selectedAccessory = constObservable(undefined); + override readonly variant = constObservable('stable'); + }(); + store.add(new ChatPetAchievementsWidget( + parent, + () => { }, + chatPetService, + new TestThemeService(), + store.add(new NullLogService()), + )); + + const lockedCard = parent.querySelector(`[data-accessory-id="${ChatPetAccessoryIds.TopHatMonocle}"]`); + assert.ok(lockedCard); + const title = lockedCard.querySelector('h3'); + const state = lockedCard.querySelector('.chat-pet-achievement-state'); + const hint = lockedCard.querySelector('.chat-pet-achievement-description'); + const reward = lockedCard.querySelector('.chat-pet-achievement-reward'); + assert.deepStrictEqual({ + title: title?.textContent, + state: state?.textContent, + hint: hint?.textContent, + reward: reward?.textContent, + ariaLabel: lockedCard.getAttribute('aria-label'), + containsAchievementName: lockedCard.textContent?.includes('Second Draft'), + fontSizes: { + title: title && mainWindow.getComputedStyle(title).fontSize, + state: state && mainWindow.getComputedStyle(state).fontSize, + hint: hint && mainWindow.getComputedStyle(hint).fontSize, + reward: reward && mainWindow.getComputedStyle(reward).fontSize, + }, + }, { + title: 'Locked', + state: 'Hint', + hint: 'An earlier request may deserve a second pass.', + reward: 'Rewards: Grand Top Hat & Monocle', + ariaLabel: 'Locked. Hint: An earlier request may deserve a second pass. Rewards: Grand Top Hat & Monocle.', + containsAchievementName: false, + fontSizes: { + title: '13px', + state: '12px', + hint: '12px', + reward: '12px', + }, + }); + }); + test('requests modal close when Escape is pressed on a selectable card', () => { const parent = mainWindow.document.createElement('div'); mainWindow.document.body.appendChild(parent); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts index 49182a0d66580c..a44f7e4e191bdf 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts @@ -23,7 +23,7 @@ import { CHAT_PET_OPEN_ACHIEVEMENTS_COMMAND_ID, chatPetAchievements, ChatPetAcce import { ChatPetService, getChatPetVariant } from '../../../browser/chatPetService.js'; import { getChatPetAccessoryImageSource, hasChatPetAccessoryImageDimensions, hasChatPetBodyImageDimensions } from '../../../browser/widget/chatPetAccessoryRenderer.js'; import { getChatPetAccessoryRigFrame, getChatPetAccessoryRigPose, getChatPetAccessoryTrack, getChatPetAntennaeOcclusionBounds, getChatPetEyeAccessoryAnchor, getChatPetReducedMotionRigFrame } from '../../../browser/widget/chatPetAccessoryRig.js'; -import { CHAT_PET_ACHIEVEMENT_UNLOCKED_DURATION, CHAT_PET_CONFIRMATION_ATTENTION_DURATION, CHAT_PET_ICON_TRANSFORMATION_CHANCE, CHAT_PET_IDLE_SLEEP_DELAY, CHAT_PET_WALL_IMPACT_DURATION, CHAT_PET_YAPPING_CHANCE, ChatPetBlinkController, ChatPetDirectionChangeController, ChatPetFacingController, ChatPetHopController, ChatPetWidget, advanceChatPetThrow, doesChatPetStateBlink, doesChatPetStateTrackCursor, drawChatPetAchievementStar, getChatPetAnimationFrame, getChatPetBaseState, getChatPetBlinkDelay, getChatPetBuddyName, getChatPetClickInteraction, getChatPetDefaultHorizontalPosition, getChatPetDragPosition, getChatPetEyeAccessoryGazeOffset, getChatPetFallDuration, getChatPetFallTarget, getChatPetFrameDurations, getChatPetGazeDirection, getChatPetHorizontalPosition, getChatPetPlatformTop, getChatPetRelativeHorizontalPosition, getChatPetRenderedState, getChatPetRespawnFrameDurations, getChatPetRestoredHorizontalPosition, getChatPetScale, getChatPetSpeechFrameDurations, getChatPetSpriteName, getChatPetThrowLanding, getChatPetThrowRotation, getChatPetThrowVelocity, getChatPetVerticalOffset, getChatPetWallReboundVelocity, getChatPetWideSpriteHorizontalOffset, isChatPetImageSource, isChatPetKeyboardInteractionEnabled, isChatPetVisible, isChatPetWindowActive, setChatPetWideLayerOffset, shouldPlaceChatPetSpeechBubbleLeft, shouldReserveChatPetSpace, shouldSettleChatPetThrow } from '../../../browser/widget/chatPetWidget.js'; +import { CHAT_PET_ACHIEVEMENT_UNLOCKED_DURATION, CHAT_PET_CONFIRMATION_ATTENTION_DURATION, CHAT_PET_ICON_TRANSFORMATION_CHANCE, CHAT_PET_IDLE_SLEEP_DELAY, CHAT_PET_WALL_IMPACT_DURATION, CHAT_PET_WINDOW_OWNERSHIP_CHANNEL, CHAT_PET_YAPPING_CHANCE, ChatPetBlinkController, ChatPetDirectionChangeController, ChatPetFacingController, ChatPetHopController, ChatPetWidget, IChatPetWidgetHost, advanceChatPetThrow, doesChatPetStateBlink, doesChatPetStateTrackCursor, drawChatPetAchievementStar, getChatPetAnchoredHorizontalPosition, getChatPetAnimationFrame, getChatPetBaseState, getChatPetBlinkDelay, getChatPetBuddyName, getChatPetClickInteraction, getChatPetDefaultHorizontalPosition, getChatPetDragPosition, getChatPetEyeAccessoryGazeOffset, getChatPetFallDuration, getChatPetFallTarget, getChatPetFrameDurations, getChatPetGazeDirection, getChatPetHorizontalAnchor, getChatPetHorizontalPosition, getChatPetPillPlatformTop, getChatPetPlatformTop, getChatPetRelativeHorizontalPosition, getChatPetRenderedState, getChatPetRespawnFrameDurations, getChatPetRestoredHorizontalPosition, getChatPetScale, getChatPetSpeechFrameDurations, getChatPetSpriteName, getChatPetThrowLanding, getChatPetThrowRotation, getChatPetThrowVelocity, getChatPetVerticalOffset, getChatPetWallReboundVelocity, getChatPetWideSpriteHorizontalOffset, isChatPetImageSource, isChatPetKeyboardInteractionEnabled, isChatPetVisible, isChatPetWindowActive, setChatPetWideLayerOffset, shouldClaimChatPetWindowOnConstruction, shouldPlaceChatPetSpeechBubbleLeft, shouldReserveChatPetSpace, shouldSettleChatPetThrow } from '../../../browser/widget/chatPetWidget.js'; suite('ChatPetWidget', () => { @@ -57,6 +57,19 @@ suite('ChatPetWidget', () => { return { controller, events, getLeft: () => left }; } + function createPetHost(parent: HTMLElement, dragBounds: HTMLElement, movementBounds: HTMLElement, hasInput = false): IChatPetWidgetHost { + return { + parent, + dragBounds, + movementBounds, + model: constObservable(undefined), + hasInput: constObservable(hasInput), + inputChanged: Event.None, + getPlatformTop: () => undefined, + onDidChangePlatform: Event.None, + }; + } + test('runs one timed hop for a single key press', () => { const clock = sinon.useFakeTimers(); const { controller, events } = createHopHarness(); @@ -91,13 +104,7 @@ suite('ChatPetWidget', () => { })); const service = disposables.add(new ChatPetService(disposables.add(new TestStorageService()), new TestTelemetryService(), new NullLogService())); disposables.add(new ChatPetWidget( - parent, - dragBounds, - movementBounds, - constObservable(undefined), - constObservable(false), - constObservable(true), - Event.None, + createPetHost(parent, dragBounds, movementBounds), service, new TestAccessibilityService(), new class extends mock() { }(), @@ -123,6 +130,53 @@ suite('ChatPetWidget', () => { }); }); + test('moves one pet instance between chat hosts without respawning it', () => { + const firstParent = mainWindow.document.createElement('div'); + const firstBounds = mainWindow.document.createElement('div'); + const secondParent = mainWindow.document.createElement('div'); + const secondBounds = mainWindow.document.createElement('div'); + const movementBounds = mainWindow.document.createElement('div'); + mainWindow.document.body.append(firstParent, firstBounds, secondParent, secondBounds, movementBounds); + disposables.add(toDisposable(() => { + firstParent.remove(); + firstBounds.remove(); + secondParent.remove(); + secondBounds.remove(); + movementBounds.remove(); + })); + const service = disposables.add(new ChatPetService(disposables.add(new TestStorageService()), new TestTelemetryService(), new NullLogService())); + const widget = disposables.add(new ChatPetWidget( + createPetHost(firstParent, firstBounds, movementBounds), + service, + new TestAccessibilityService(), + new class extends mock() { }(), + new class extends mock() { }(), + new NullLogService(), + new class extends mock() { + override readonly hasFocus = true; + override readonly onDidChangeFocus = Event.None; + override readonly onDidChangeActiveWindow = Event.None; + }(), + )); + const button = firstParent.getElementsByClassName('chat-pet-button')[0]; + + widget.setHost(createPetHost(secondParent, secondBounds, movementBounds, true)); + + assert.deepStrictEqual({ + firstPetCount: firstParent.getElementsByClassName('chat-pet-button').length, + secondPetCount: secondParent.getElementsByClassName('chat-pet-button').length, + sameButton: secondParent.getElementsByClassName('chat-pet-button')[0] === button, + firstHostClass: firstParent.classList.contains('chat-pet-host'), + secondHostClass: secondParent.classList.contains('chat-pet-host'), + }, { + firstPetCount: 0, + secondPetCount: 1, + sameButton: true, + firstHostClass: false, + secondHostClass: true, + }); + }); + test('repeats hops while key requests remain within the hold grace period', () => { const clock = sinon.useFakeTimers(); const { controller, events } = createHopHarness(); @@ -283,34 +337,92 @@ suite('ChatPetWidget', () => { assert.strictEqual(CHAT_PET_CONFIRMATION_ATTENTION_DURATION, 2_000); }); - test('only shows in the active window but reserves space in each window\'s latest focused chat', () => { - assert.deepStrictEqual([ - { visible: isChatPetVisible(false, false, false), spaceReserved: shouldReserveChatPetSpace(false, false) }, - { visible: isChatPetVisible(false, true, true), spaceReserved: shouldReserveChatPetSpace(false, true) }, - { visible: isChatPetVisible(true, false, true), spaceReserved: shouldReserveChatPetSpace(true, false) }, - { visible: isChatPetVisible(true, true, false), spaceReserved: shouldReserveChatPetSpace(true, true) }, - { visible: isChatPetVisible(true, true, true), spaceReserved: shouldReserveChatPetSpace(true, true) }, - ], [ - { visible: false, spaceReserved: false }, - { visible: false, spaceReserved: false }, - { visible: false, spaceReserved: false }, - { visible: false, spaceReserved: true }, - { visible: true, spaceReserved: true }, - ]); + test('shows the window pet only in the active VS Code window and reserves only its active host', () => { + assert.deepStrictEqual({ + visible: [ + isChatPetVisible(false, false), + isChatPetVisible(true, false), + isChatPetVisible(true, true), + ], + spaceReserved: [ + shouldReserveChatPetSpace(false, false), + shouldReserveChatPetSpace(true, false), + shouldReserveChatPetSpace(true, true), + ], + }, { + visible: [false, false, true], + spaceReserved: [false, false, true], + }); }); - test('tracks the active renderer window independently from application focus', () => { - assert.deepStrictEqual([ - isChatPetWindowActive(false, 1, 1), - isChatPetWindowActive(true, 1, 1), - isChatPetWindowActive(true, 2, 1), - isChatPetWindowActive(true, 1, 2), - ], [ - false, - true, - false, - false, - ]); + test('keeps the pet on external-app blur but transfers it to another VS Code window', async () => { + const parent = mainWindow.document.createElement('div'); + const dragBounds = mainWindow.document.createElement('div'); + const movementBounds = mainWindow.document.createElement('div'); + mainWindow.document.body.append(parent, dragBounds, movementBounds); + disposables.add(toDisposable(() => { + parent.remove(); + dragBounds.remove(); + movementBounds.remove(); + })); + const hostService = new class extends mock() { + override readonly hasFocus = true; + override readonly onDidChangeFocus = Event.None; + override readonly onDidChangeActiveWindow = Event.None; + }(); + const service = disposables.add(new ChatPetService(disposables.add(new TestStorageService()), new TestTelemetryService(), new NullLogService())); + disposables.add(new ChatPetWidget( + createPetHost(parent, dragBounds, movementBounds), + service, + new TestAccessibilityService(), + new class extends mock() { }(), + new class extends mock() { }(), + new NullLogService(), + hostService, + )); + const button = parent.getElementsByClassName('chat-pet-button')[0]; + service.toggle(); + const initiallyHidden = button.classList.contains('hidden'); + const ownershipChannel = new BroadcastChannel(CHAT_PET_WINDOW_OWNERSHIP_CHANNEL); + disposables.add(toDisposable(() => ownershipChannel.close())); + + mainWindow.dispatchEvent(new FocusEvent('blur')); + const hiddenAfterExternalBlur = button.classList.contains('hidden'); + ownershipChannel.postMessage({ windowId: mainWindow.vscodeWindowId + 1 }); + await new Promise(resolve => mainWindow.setTimeout(resolve, 10)); + const hiddenAfterWindowTransfer = button.classList.contains('hidden'); + mainWindow.dispatchEvent(new FocusEvent('focus')); + const hiddenAfterReturn = button.classList.contains('hidden'); + + assert.deepStrictEqual({ + initiallyHidden, + hiddenAfterExternalBlur, + hiddenAfterWindowTransfer, + hiddenAfterReturn, + }, { + initiallyHidden: false, + hiddenAfterExternalBlur: false, + hiddenAfterWindowTransfer: true, + hiddenAfterReturn: false, + }); + }); + + test('tracks only the active VS Code renderer window', () => { + assert.deepStrictEqual({ + windowActive: [ + isChatPetWindowActive(1, 1), + isChatPetWindowActive(2, 1), + isChatPetWindowActive(1, 2), + ], + claimOnConstruction: [ + shouldClaimChatPetWindowOnConstruction(true, true), + shouldClaimChatPetWindowOnConstruction(true, false), + shouldClaimChatPetWindowOnConstruction(false, true), + ], + }, { + windowActive: [true, false, false], + claimOnConstruction: [true, false, false], + }); }); test('blocks keyboard interaction while unavailable or already interacting', () => { @@ -353,6 +465,32 @@ suite('ChatPetWidget', () => { ]); }); + test('preserves the inset from the nearest edge while resizing', () => { + const leftAnchor = getChatPetHorizontalAnchor(70, 20, 220); + const rightAnchor = getChatPetHorizontalAnchor(170, 20, 220); + assert.deepStrictEqual({ + leftAnchor, + rightAnchor, + centerAnchor: getChatPetHorizontalAnchor(120, 20, 220), + leftNarrow: getChatPetAnchoredHorizontalPosition(leftAnchor, 20, 120), + leftClamped: getChatPetAnchoredHorizontalPosition(leftAnchor, 20, 60), + leftWide: getChatPetAnchoredHorizontalPosition(leftAnchor, 20, 220), + rightNarrow: getChatPetAnchoredHorizontalPosition(rightAnchor, 20, 120), + rightClamped: getChatPetAnchoredHorizontalPosition(rightAnchor, 20, 60), + rightWide: getChatPetAnchoredHorizontalPosition(rightAnchor, 20, 220), + }, { + leftAnchor: { edge: 'left', inset: 50 }, + rightAnchor: { edge: 'right', inset: 50 }, + centerAnchor: { edge: 'left', inset: 100 }, + leftNarrow: 70, + leftClamped: 60, + leftWide: 70, + rightNarrow: 70, + rightClamped: 20, + rightWide: 170, + }); + }); + test('gives dragging precedence over base and transient states', () => { assert.deepStrictEqual([ getChatPetRenderedState('rendering', undefined, false), @@ -431,13 +569,7 @@ suite('ChatPetWidget', () => { const storageService = disposables.add(new TestStorageService()); const service = disposables.add(new ChatPetService(storageService, new TestTelemetryService(), new NullLogService())); const widget = disposables.add(new ChatPetWidget( - parent, - dragBounds, - movementBounds, - constObservable(undefined), - constObservable(false), - constObservable(true), - Event.None, + createPetHost(parent, dragBounds, movementBounds), service, new TestAccessibilityService(), new class extends mock() { }(), @@ -885,7 +1017,7 @@ suite('ChatPetWidget', () => { }); }); - test('does not expose secret achievement copy or locked rewards in presentation data', () => { + test('reveals locked hints and rewards without exposing achievement identity or exact requirements', () => { const lockedPresentation = getChatPetAchievementPresentation(chatPetAchievements[0], false); const unlockedAccessories = getUnlockedChatPetAccessories([ChatPetAchievementIds.RequestRevision]); const allUnlockedAccessories = getUnlockedChatPetAccessories(chatPetAchievements.map(achievement => achievement.id)); @@ -893,6 +1025,8 @@ suite('ChatPetWidget', () => { assert.deepStrictEqual({ lockedPresentation, lockedSerializationContainsTitle: JSON.stringify(lockedPresentation).includes(chatPetAchievements[0].title), + lockedSerializationContainsDescription: JSON.stringify(lockedPresentation).includes(chatPetAchievements[0].description), + lockedSerializationContainsExactRequirement: JSON.stringify(lockedPresentation).includes('Edit and resend an earlier chat request.'), lockedSerializationContainsReward: chatPetAchievements[0].accessories.some(accessory => JSON.stringify(lockedPresentation).includes(accessory.label)), unlockedAccessoryIds: unlockedAccessories.map(accessory => accessory.id), allUnlockedAccessoryIds: allUnlockedAccessories.map(accessory => accessory.id), @@ -900,9 +1034,13 @@ suite('ChatPetWidget', () => { lockedPresentation: { locked: true, id: ChatPetAchievementIds.RequestRevision, + hint: 'An earlier request may deserve a second pass.', + rewardLabels: ['Grand Top Hat & Monocle'], }, lockedSerializationContainsTitle: false, - lockedSerializationContainsReward: false, + lockedSerializationContainsDescription: false, + lockedSerializationContainsExactRequirement: false, + lockedSerializationContainsReward: true, unlockedAccessoryIds: [ ChatPetAccessoryIds.TopHatMonocle, ], @@ -1661,6 +1799,8 @@ suite('ChatPetWidget', () => { getChatPetFallTarget(50, 151.5, 48, 48, 40, 200, 200, 400), getChatPetFallTarget(50, 152.5, 48, 48, 40, 200, 200, 400), getChatPetFallTarget(50, 220, 48, 48, 40, 200, 200, 400), + getChatPetFallTarget(50, 100, 48, 48, 40, 200, 120, 400, 200), + getChatPetFallTarget(50, 160, 48, 48, 40, 200, 120, 400, 200), ], [ { top: 152, landsOnPlatform: true }, { top: 352, landsOnPlatform: false }, @@ -1669,6 +1809,8 @@ suite('ChatPetWidget', () => { { top: 152, landsOnPlatform: true }, { top: 352, landsOnPlatform: false }, { top: 352, landsOnPlatform: false }, + { top: 152, landsOnPlatform: true }, + { top: 352, landsOnPlatform: false }, ]); }); @@ -1700,7 +1842,7 @@ suite('ChatPetWidget', () => { ]); }); - test('ignores passive pills when choosing the active platform', () => { + test('uses substantive input surfaces as the platform', () => { assert.deepStrictEqual([ getChatPetPlatformTop(100, 160), getChatPetPlatformTop(100, 160, 120), @@ -1714,6 +1856,29 @@ suite('ChatPetWidget', () => { ]); }); + test('uses only the pill under the pet as a raised platform', () => { + const pillBounds = [ + { left: 10, right: 50, top: 120, width: 40, height: 22 }, + { left: 56, right: 96, top: 118, width: 40, height: 24 }, + { left: 104, right: 144, top: 116, width: 0, height: 24 }, + ]; + assert.deepStrictEqual([ + getChatPetPillPlatformTop(9, pillBounds), + getChatPetPillPlatformTop(10, pillBounds), + getChatPetPillPlatformTop(50, pillBounds), + getChatPetPillPlatformTop(53, pillBounds), + getChatPetPillPlatformTop(75, pillBounds), + getChatPetPillPlatformTop(120, pillBounds), + ], [ + undefined, + 120, + 120, + undefined, + 118, + undefined, + ]); + }); + test('moves only the rendering speech bubble before it crosses the input edge', () => { assert.deepStrictEqual([ shouldPlaceChatPetSpeechBubbleLeft('rendering', 980, 1000), diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidgetService.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidgetService.test.ts new file mode 100644 index 00000000000000..e6755238b8a3d9 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidgetService.test.ts @@ -0,0 +1,146 @@ +/*--------------------------------------------------------------------------------------------- + * 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 * as dom from '../../../../../../base/browser/dom.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { constObservable, observableValue } from '../../../../../../base/common/observable.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IChatWidget, IChatWidgetService } from '../../../browser/chat.js'; +import { IChatPetWidgetHost } from '../../../browser/widget/chatPetWidget.js'; +import { ChatPetWidgetCoordinator } from '../../../browser/widget/chatPetWidgetService.js'; + +suite('ChatPetWidgetService', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function createHost(): IChatPetWidgetHost { + const parent = document.createElement('div'); + return { + parent, + dragBounds: parent, + movementBounds: parent, + model: constObservable(undefined), + hasInput: constObservable(false), + inputChanged: Event.None, + getPlatformTop: () => undefined, + onDidChangePlatform: Event.None, + }; + } + + test('uses one window pet and moves it between focused or preferred chat hosts', () => { + const focusEmitter = disposables.add(new Emitter()); + const firstWidget = new class extends mock() { }(); + const secondWidget = new class extends mock() { }(); + const thirdWidget = new class extends mock() { }(); + const chatWidgetService = new class extends mock() { + override lastFocusedWidget: IChatWidget | undefined = firstWidget; + override readonly onDidChangeFocusedWidget = focusEmitter.event; + + focus(widget: IChatWidget): void { + this.lastFocusedWidget = widget; + focusEmitter.fire(widget); + } + }(); + const instances: { + host: IChatPetWidgetHost; + readonly hostHistory: IChatPetWidgetHost[]; + disposed: boolean; + }[] = []; + const coordinator = disposables.add(new ChatPetWidgetCoordinator(host => { + const instance = { + host, + hostHistory: [host], + disposed: false, + setHost(nextHost: IChatPetWidgetHost) { + this.host = nextHost; + this.hostHistory.push(nextHost); + }, + dispose() { + this.disposed = true; + }, + }; + instances.push(instance); + return instance; + }, chatWidgetService)); + const firstHost = createHost(); + const secondHost = createHost(); + const thirdHost = createHost(); + const firstPreferred = observableValue(disposables, true); + const secondPreferred = observableValue(disposables, false); + const firstRegistration = disposables.add(coordinator.register(firstWidget, firstHost, firstPreferred)); + const secondRegistration = disposables.add(coordinator.register(secondWidget, secondHost, secondPreferred)); + + firstPreferred.set(false, undefined); + secondPreferred.set(true, undefined); + chatWidgetService.focus(firstWidget); + firstRegistration.dispose(); + const thirdRegistration = disposables.add(coordinator.register(thirdWidget, thirdHost)); + chatWidgetService.focus(thirdWidget); + + assert.deepStrictEqual({ + instanceCount: instances.length, + hostHistory: instances[0].hostHistory, + firstActive: firstRegistration.active.get(), + secondActive: secondRegistration.active.get(), + thirdActive: thirdRegistration.active.get(), + disposed: instances[0].disposed, + }, { + instanceCount: 1, + hostHistory: [firstHost, secondHost, firstHost, secondHost, thirdHost], + firstActive: false, + secondActive: false, + thirdActive: true, + disposed: false, + }); + }); + + test('keeps the window pet through a host gap and disposes it with the coordinator', () => { + const focusEmitter = disposables.add(new Emitter()); + const widget = new class extends mock() { }(); + const chatWidgetService = new class extends mock() { + override lastFocusedWidget: IChatWidget | undefined = widget; + override readonly onDidChangeFocusedWidget = focusEmitter.event; + }(); + let pet: { dispose(): void; setHost(host: IChatPetWidgetHost): void } | undefined; + let disposed = false; + const coordinator = disposables.add(new ChatPetWidgetCoordinator(() => { + const instance = { + dispose: () => disposed = true, + setHost: () => { }, + }; + pet = instance; + return instance; + }, chatWidgetService)); + const registration = coordinator.register(widget, createHost()); + + registration.dispose(); + const disposedAfterHost = disposed; + coordinator.dispose(); + + assert.deepStrictEqual({ created: !!pet, disposedAfterHost, disposed }, { created: true, disposedAfterHost: false, disposed: true }); + }); + + test('disposes a parked pet when its auxiliary window closes', () => { + const focusEmitter = disposables.add(new Emitter()); + const windowCloseEmitter = disposables.add(new Emitter()); + const widget = new class extends mock() { }(); + const chatWidgetService = new class extends mock() { + override lastFocusedWidget: IChatWidget | undefined = widget; + override readonly onDidChangeFocusedWidget = focusEmitter.event; + }(); + let disposed = false; + const coordinator = disposables.add(new ChatPetWidgetCoordinator(() => ({ + setHost: () => { }, + dispose: () => disposed = true, + }), chatWidgetService, windowCloseEmitter.event)); + const host = createHost(); + const registration = disposables.add(coordinator.register(widget, host)); + + windowCloseEmitter.fire(dom.getWindowId(dom.getWindow(host.parent))); + + assert.deepStrictEqual({ active: registration.active.get(), disposed }, { active: false, disposed: true }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatTurnPills.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatTurnPills.test.ts index 9e7af4ba00c8d5..43908084ba6cb4 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatTurnPills.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatTurnPills.test.ts @@ -35,23 +35,31 @@ suite('ChatTurnPills', () => { const action = disposables.add(new Action('test.chatPill', 'Session Changes')); const pills = observableValue(disposables, []); const widget = disposables.add(instantiationService.createInstance(ChatPillsWidget, { pills }, undefined)); + let pillChangeCount = 0; + disposables.add(widget.onDidChangePills(() => pillChangeCount++)); pills.set([{ action }], undefined); const visible = { hidden: widget.element.classList.contains('hidden'), labels: [...widget.element.querySelectorAll('.chat-pill-label')].map(element => element.textContent), + platformElementCount: widget.getPillElements().length, }; pills.set([], undefined); assert.deepStrictEqual({ visible, hiddenAfterClear: widget.element.classList.contains('hidden'), + platformElementCountAfterClear: widget.getPillElements().length, + pillChangeCount, }, { visible: { hidden: false, labels: ['Session Changes'], + platformElementCount: 1, }, hiddenAfterClear: true, + platformElementCountAfterClear: 0, + pillChangeCount: 2, }); }); diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts index b1f48fd8502200..06f825e77d3e53 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts @@ -52,6 +52,7 @@ import { IVoiceModeOnboardingService } from '../../../../contrib/agentsVoice/bro import { IChatAccessibilityService, IChatWidget, IChatWidgetService } from '../../../../contrib/chat/browser/chat.js'; import { IChatResponseFileChangesService } from '../../../../contrib/chat/browser/chatResponseFileChangesService.js'; import { IChatPetService } from '../../../../contrib/chat/browser/chatPetService.js'; +import { ChatPetWidgetService, IChatPetWidgetService } from '../../../../contrib/chat/browser/widget/chatPetWidgetService.js'; import { IChatOutputRendererService } from '../../../../contrib/chat/browser/chatOutputItemRenderer.js'; import { IAiEditTelemetryService } from '../../../../contrib/editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryService.js'; import { EditSuggestionId } from '../../../../../editor/common/textModelEditSource.js'; @@ -231,6 +232,7 @@ export function registerChatFixtureServices(reg: ServiceRegistration, options: I override getWidgetsByLocations() { return []; } override register() { return { dispose() { } }; } }()); + reg.define(IChatPetWidgetService, ChatPetWidgetService); reg.defineInstance(IChatAccessibilityService, new class extends mock() { override acceptRequest() { } override disposeRequest() { } diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index aa9b771283070c..f422188f459ae6 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -43,10 +43,10 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/df1f42cc6f6a3eb52f36effd880cfc010b8107fccb88dcbeffbf57ae145ca2e4) #### chat/petAchievements/standaloneModal/chatPetAchievementsEditor/MixedSelected/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/5a1609dbdbd0452d5e037bc334c8f52b119a8e142331604bc6ad3c4778b437b9) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/987887f3edd330dfdcf3e9cb2164b03046eb6858780a98175bb8d4e3e089b4fa) #### chat/petAchievements/standaloneModal/chatPetAchievementsEditor/MixedSelected/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/2ed2c21fadb55a5799671ae54206454af12cf6b7967ca280662bfb58cef58cba) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/4c34a203cbbdad71b881f3a5aa2f715dfb50ed356dbf19e18e4a79b42f5f18fa) #### editor/codeEditor/CodeEditor/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/09075b2f4715fa8a8ad426165bb85ba96a15b7174259c7da7ef0c2d5e74f7f79)