diff --git a/src/vs/sessions/contrib/chat/browser/sessionArchiveNudge.ts b/src/vs/sessions/contrib/chat/browser/sessionArchiveNudge.ts index 284581e69598..0d113edb1204 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionArchiveNudge.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionArchiveNudge.ts @@ -7,6 +7,7 @@ import { structuralEquals } from '../../../../base/common/equals.js'; import { Event } from '../../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { autorun, derived, derivedOpts, IObservable, IReader, observableSignalFromEvent, observableValue } from '../../../../base/common/observable.js'; +import { getComparisonKey } from '../../../../base/common/resources.js'; import { localize } from '../../../../nls.js'; import { getChatSessionArchiveActionWording } from '../../../../platform/chat/common/sessionArchiveActions.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; @@ -18,6 +19,8 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { logSettingExperimentTrigger } from '../../../../platform/telemetry/common/experimentTrigger.js'; import { IChatSessionArchiveNudgeOptions } from '../../../../workbench/contrib/chat/browser/widget/input/chatSessionArchiveNudge.js'; +import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { IChatModel } from '../../../../workbench/contrib/chat/common/model/chatModel.js'; import { onboardingScenarioRegistry } from '../../../../workbench/contrib/onboarding/common/onboardingRegistry.js'; import { OnboardingOutcome } from '../../../../workbench/contrib/onboarding/common/onboardingScenario.js'; import { IOnboardingScenarioService, ONBOARDING_ENABLED_CONFIG } from '../../../../workbench/contrib/onboarding/common/onboardingScenarioService.js'; @@ -37,6 +40,7 @@ import { SessionsView, SessionsViewId } from '../../sessions/browser/views/sessi export const SESSION_ARCHIVE_NUDGE_SETTING = 'chat.agentSessions.archiveNudge.enabled'; const DISMISSED_STORAGE_KEY_PREFIX = 'sessions.archiveNudge.dismissed.'; +const IMPRESSION_STORAGE_KEY_PREFIX = 'sessions.archiveNudge.impression.'; const ARCHIVE_COUNT_STORAGE_KEY = 'sessions.archiveNudge.archiveCount'; const COMPACT_AFTER_ARCHIVE_COUNT = 3; @@ -46,6 +50,17 @@ interface ISessionArchiveNudgeState { readonly pullRequestCount: number; } +interface ISessionArchiveNudgeCandidate extends ISessionArchiveNudgeState { + readonly pullRequestKeys: readonly string[]; +} + +interface ISessionArchiveNudgeImpression { + readonly pullRequestKeys: readonly string[]; + readonly lastRequestIds: Record; + readonly shownAt: number; + readonly continued: boolean; +} + export interface ISessionArchiveNudgeService { readonly _serviceBrand: undefined; /** @@ -55,10 +70,11 @@ export interface ISessionArchiveNudgeService { */ readonly experimentTriggerPending: IObservable; isDismissed(session: ISession, reader: IReader | undefined): boolean; + hasContinued(session: ISession, pullRequestKeys: readonly string[], reader: IReader | undefined): boolean; shouldShowCompact(reader: IReader | undefined): boolean; /** Reports the enablement experiment trigger of a suggestion that would show if it were enabled and not dismissed. */ reportWouldShow(): void; - markShown(state: ISessionArchiveNudgeState): void; + markShown(state: ISessionArchiveNudgeCandidate): void; dismiss(state: ISessionArchiveNudgeState): void; showArchiveOnboarding(session: ISession): Promise; archive(state: ISessionArchiveNudgeState): Promise; @@ -87,6 +103,7 @@ export class SessionArchiveNudgeService extends Disposable implements ISessionAr private readonly _shown = new Set(); private readonly _dismissalChanged: IObservable; + private readonly _impressionChanged: IObservable; private readonly _archiveCountChanged: IObservable; private readonly _onboardingStore = this._register(new MutableDisposable()); private _onboardingInFlight: Promise | undefined; @@ -100,6 +117,7 @@ export class SessionArchiveNudgeService extends Disposable implements ISessionAr @IConfigurationService private readonly _configurationService: IConfigurationService, @IViewsService private readonly _viewsService: IViewsService, @IOnboardingScenarioService private readonly _onboardingService: IOnboardingScenarioService, + @IChatService private readonly _chatService: IChatService, ) { super(); @@ -108,6 +126,10 @@ export class SessionArchiveNudgeService extends Disposable implements ISessionAr event => event.key.startsWith(DISMISSED_STORAGE_KEY_PREFIX), )); this._archiveCountChanged = observableSignalFromEvent(this, this._storageService.onDidChangeValue(StorageScope.PROFILE, ARCHIVE_COUNT_STORAGE_KEY, this._store)); + this._impressionChanged = observableSignalFromEvent(this, Event.filter( + this._storageService.onDidChangeValue(StorageScope.PROFILE, undefined, this._store), + event => event.key.startsWith(IMPRESSION_STORAGE_KEY_PREFIX), + )); this._register(this._sessionsManagementService.onDidArchiveSession(session => this._clear(session))); this._register(this._sessionsManagementService.onDidUnarchiveSession(session => this._clear(session))); this._register(this._sessionsManagementService.onDidDeleteSession(session => this._clear(session))); @@ -116,9 +138,22 @@ export class SessionArchiveNudgeService extends Disposable implements ISessionAr this._register(autorun(reader => { sessionsChanged.read(reader); this._dismissalChanged.read(reader); + this._impressionChanged.read(reader); for (const session of this._sessionsManagementService.getSessions()) { - if (this.isDismissed(session, reader) && session.isArchived.read(reader)) { + const impression = this._getImpression(session, reader); + if ((impression || this.isDismissed(session, reader)) && session.isArchived.read(reader)) { this._clear(session); + } else if (impression && !impression.continued) { + const continued = this._getChatModels(session, reader).some(model => { + const request = model.lastRequestObs.read(reader); + const chatResource = getComparisonKey(model.sessionResource); + // Ignore older history revealed by request removal or lazy chat loading. + return !!request && request.timestamp >= impression.shownAt + && (!Object.hasOwn(impression.lastRequestIds, chatResource) || request.id !== impression.lastRequestIds[chatResource]); + }); + if (continued) { + this._storageService.store(`${IMPRESSION_STORAGE_KEY_PREFIX}${session.sessionId}`, { ...impression, continued: true }, StorageScope.PROFILE, StorageTarget.MACHINE); + } } } })); @@ -129,6 +164,11 @@ export class SessionArchiveNudgeService extends Disposable implements ISessionAr return this._storageService.getBoolean(`${DISMISSED_STORAGE_KEY_PREFIX}${session.sessionId}`, StorageScope.PROFILE, false); } + hasContinued(session: ISession, pullRequestKeys: readonly string[], reader: IReader | undefined): boolean { + const impression = this._getImpression(session, reader); + return !!impression?.continued && pullRequestKeys.every(key => impression.pullRequestKeys.includes(key)); + } + shouldShowCompact(reader: IReader | undefined): boolean { this._archiveCountChanged.read(reader); return this._storageService.getNumber(ARCHIVE_COUNT_STORAGE_KEY, StorageScope.PROFILE, 0) >= COMPACT_AFTER_ARCHIVE_COUNT; @@ -139,7 +179,19 @@ export class SessionArchiveNudgeService extends Disposable implements ISessionAr this._experimentTriggerPending.set(false, undefined); } - markShown(state: ISessionArchiveNudgeState): void { + markShown(state: ISessionArchiveNudgeCandidate): void { + const impression = this._getImpression(state.session, undefined); + if (!impression || state.pullRequestKeys.some(key => !impression.pullRequestKeys.includes(key))) { + const lastRequestIds = Object.fromEntries(this._getChatModels(state.session, undefined).map(model => [ + getComparisonKey(model.sessionResource), model.lastRequestObs.get()?.id, + ])); + this._storageService.store(`${IMPRESSION_STORAGE_KEY_PREFIX}${state.session.sessionId}`, { + pullRequestKeys: [...new Set([...(impression?.pullRequestKeys ?? []), ...state.pullRequestKeys])], + lastRequestIds, + shownAt: Date.now(), + continued: false, + } satisfies ISessionArchiveNudgeImpression, StorageScope.PROFILE, StorageTarget.MACHINE); + } if (!this._shown.has(state.session.sessionId)) { this._shown.add(state.session.sessionId); this._log(state, 'shown'); @@ -203,9 +255,20 @@ export class SessionArchiveNudgeService extends Disposable implements ISessionAr private _clear(session: ISession): void { this._shown.delete(session.sessionId); + this._storageService.remove(`${IMPRESSION_STORAGE_KEY_PREFIX}${session.sessionId}`, StorageScope.PROFILE); this._storageService.remove(`${DISMISSED_STORAGE_KEY_PREFIX}${session.sessionId}`, StorageScope.PROFILE); } + private _getImpression(session: ISession, reader: IReader | undefined): ISessionArchiveNudgeImpression | undefined { + this._impressionChanged.read(reader); + return this._storageService.getObject(`${IMPRESSION_STORAGE_KEY_PREFIX}${session.sessionId}`, StorageScope.PROFILE); + } + + private _getChatModels(session: ISession, reader: IReader | undefined): IChatModel[] { + const resources = new Set([session.mainChat.read(reader).resource, ...session.chats.read(reader).map(chat => chat.resource)].map(resource => getComparisonKey(resource))); + return [...this._chatService.chatModels.read(reader)].filter(model => resources.has(getComparisonKey(model.sessionResource))); + } + private _log(state: ISessionArchiveNudgeState, action: SessionArchiveNudgeEvent['action']): void { this._telemetryService.publicLog2('agents/sessionArchiveNudge', { agentSessionId: hashSessionIdForTelemetry(state.session.sessionId), @@ -216,7 +279,7 @@ export class SessionArchiveNudgeService extends Disposable implements ISessionAr } } -/** Keeps dismissal cleanup running even when no chat view is open. */ +/** Keeps suggestion history and lifecycle cleanup running even when no chat view is open. */ export class SessionArchiveNudgeContribution { static readonly ID = 'workbench.contrib.sessionArchiveNudge'; @@ -278,7 +341,7 @@ function getSessionPullRequests(session: ISession, reader: IReader): readonly { export class SessionArchiveNudge extends Disposable { readonly options: IObservable; - private readonly _state: IObservable; + private readonly _state: IObservable; constructor( session: IObservable, @@ -307,7 +370,8 @@ export class SessionArchiveNudge extends Disposable { const pullRequests = derivedOpts>({ owner: this, equalsFn: structuralEquals }, reader => { const current = eligibleSession.read(reader); // The shared model must not resolve github.com pull requests against an enterprise host. - return current && !gitHubService.enterpriseHost ? getSessionPullRequests(current, reader) : undefined; + const refs = current && !gitHubService.enterpriseHost ? getSessionPullRequests(current, reader) : undefined; + return current && refs && !this._nudgeService.hasContinued(current, refs.map(ref => getPullRequestKey(ref.owner, ref.repo, ref.number)), reader) ? refs : undefined; }); const models = derived(this, reader => { return pullRequests.read(reader)?.map(pullRequest => { @@ -319,13 +383,17 @@ export class SessionArchiveNudge extends Disposable { }); this._state = derivedOpts({ owner: this, - equalsFn: (a: ISessionArchiveNudgeState | undefined, b: ISessionArchiveNudgeState | undefined) => - a?.session === b?.session && a?.hasWorktree === b?.hasWorktree && a?.pullRequestCount === b?.pullRequestCount, + equalsFn: (a: ISessionArchiveNudgeCandidate | undefined, b: ISessionArchiveNudgeCandidate | undefined) => + a?.session === b?.session && a?.hasWorktree === b?.hasWorktree && structuralEquals(a?.pullRequestKeys, b?.pullRequestKeys), }, reader => { const current = eligibleSession.read(reader); if (!current) { return undefined; } + const refs = pullRequests.read(reader); + if (!refs?.length) { + return undefined; + } const pullRequestModels = models.read(reader); const pullRequestCount = pullRequestModels?.length; if (!pullRequestCount || !pullRequestModels?.every(model => model.pullRequest.read(reader)?.state === GitHubPullRequestState.Merged)) { @@ -336,6 +404,7 @@ export class SessionArchiveNudge extends Disposable { session: current, hasWorktree: !!workspace && !workspace.isVirtualWorkspace && (!!current.worktreePending?.read(reader) || workspace.folders.some(folder => !!folder.gitRepository?.workTreeUri)), pullRequestCount, + pullRequestKeys: refs.map(ref => getPullRequestKey(ref.owner, ref.repo, ref.number)), }; }); this._register(autorun(reader => { diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index b8b19ef20572..ef665e061a31 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -130,6 +130,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(archiveActionWording === ChatSessionArchiveActionWording.MarkAsDone ? localize('sessionsChat.doneNudge', "When all GitHub pull requests associated with the session have merged and no chat is working or waiting for input, a mark as done suggestion may appear above the chat input. Use Tab or Shift+Tab to reach Mark as Done, Configure Automatic Cleanup, or Dismiss Mark as Done Suggestion, then Enter or Space to activate it. Configure Automatic Cleanup opens the settings for automatically archiving inactive merged sessions and permanently deleting automatically archived merged sessions. The explanation under What Does \"Mark as Done\" Do? is collapsed by default. Use Tab to reach it and Enter or Space to expand or collapse it. Dismiss Mark as Done Suggestion, or Escape while the suggestion is focused, hides the suggestion for this session, including after a reload, until it is marked as done or deleted, and returns focus to the chat input. Marking the session as done hides it from the sessions list so you can focus on your remaining tasks. The session is not deleted. Ask your agent to find it, or look in the \"Done\" section of the sessions list. You can restore it anytime. The worktree created for the session, if any, will be deleted. You can recreate it by restoring the session.") : localize('sessionsChat.archiveNudge', "When all GitHub pull requests associated with the session have merged and no chat is working or waiting for input, an archive suggestion may appear above the chat input. Use Tab or Shift+Tab to reach Archive, Configure Automatic Cleanup, or Dismiss Archive Suggestion, then Enter or Space to activate it. Configure Automatic Cleanup opens the settings for automatically archiving inactive merged sessions and permanently deleting automatically archived merged sessions. The explanation under What Does \"Archive\" Do? is collapsed by default. Use Tab to reach it and Enter or Space to expand or collapse it. Dismiss Archive Suggestion, or Escape while the suggestion is focused, hides the suggestion for this session, including after a reload, until it is archived or deleted, and returns focus to the chat input. Archiving the session hides it from the sessions list so you can focus on your remaining tasks. The session is not deleted. Ask your agent to find it, or look in the \"Archived\" section of the sessions list. You can unarchive it anytime. The worktree created for the session, if any, will be deleted. You can recreate it by unarchiving the session.")); + content.push(localize('sessionsChat.archiveNudgeContinuation', "Sending a new message after the suggestion appears hides it for those pull requests, including after a reload. It can appear again when a new pull request is added to the session and all its pull requests have merged.")); } content.push(localize('sessionsChat.feedbackAttachment', "When a feedback comments attachment appears above the input, focus it and press Enter or Space. A single comment opens directly. Multiple comments open a tree grouped by file; use the arrow keys to navigate, Enter to reveal a comment, and Escape to close the tree.")); content.push(localize('sessionsChat.inputBackground', "Press Alt+Enter to start the session in the background without navigating into it. The started session appears in the Chat Sessions view.")); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionArchiveNudge.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionArchiveNudge.test.ts index 706fec5be3ec..bfdeea70af5e 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionArchiveNudge.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionArchiveNudge.test.ts @@ -29,6 +29,8 @@ import { NullWorkbenchAssignmentService } from '../../../../../workbench/service import { TestHostService, TestLayoutService } from '../../../../../workbench/test/browser/workbenchTestServices.js'; import { TestChatEntitlementService, TestLifecycleService, TestStorageService } from '../../../../../workbench/test/common/workbenchTestServices.js'; import { IViewsService } from '../../../../../workbench/services/views/common/viewsService.js'; +import { IChatService } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { IChatModel, IChatRequestModel } from '../../../../../workbench/contrib/chat/common/model/chatModel.js'; import { OnboardingScenarioService } from '../../../../../workbench/contrib/onboarding/browser/onboardingService.js'; import { ISpotlightPayload, SPOTLIGHT_PRESENTATION_KIND } from '../../../../../workbench/contrib/onboarding/browser/spotlight/spotlightTypes.js'; import { SpotlightOverlay } from '../../../../../workbench/contrib/onboarding/browser/spotlight/spotlightOverlay.js'; @@ -206,6 +208,26 @@ suite('SessionArchiveNudge', () => { return viewAvailable ? view : null; }); const viewsService = instantiationService.get(IViewsService); + const chatModels = observableValue('chatModels', []); + const chatRequests = new Map>>(); + function setChatRequest(session: ISession, requestId: string | undefined, resource = session.mainChat.get().resource, timestamp = Date.now()) { + const key = resource.toString(); + let lastRequest = chatRequests.get(key); + const request = requestId === undefined ? undefined : upcastPartial({ id: requestId, timestamp }); + if (!lastRequest) { + lastRequest = observableValue('lastRequest', request); + chatRequests.set(key, lastRequest); + chatModels.set([...chatModels.get(), upcastPartial({ sessionResource: resource, lastRequestObs: lastRequest })], undefined); + } else { + lastRequest.set(request, undefined); + } + } + for (const session of sessions) { + setChatRequest(session, 'turn-1', undefined, 1); + } + const chatService = new class extends mock() { + override readonly chatModels = chatModels; + }(); store.add(onboardingPresentationRegistry.register({ kind: SPOTLIGHT_PRESENTATION_KIND, async run(scenario, runContext) { @@ -236,7 +258,7 @@ suite('SessionArchiveNudge', () => { new NullWorkbenchAssignmentService(), NullTelemetryService, )); - let service = store.add(new SessionArchiveNudgeService(storage, management, telemetry, configuration, viewsService, onboardingService)); + let service = store.add(new SessionArchiveNudgeService(storage, management, telemetry, configuration, viewsService, onboardingService, chatService)); const current = observableValue('current', sessions[0]); function createNudge() { const nudge = store.add(new SessionArchiveNudge(current, configuration, entitlement, github, service, commandService)); @@ -249,6 +271,7 @@ suite('SessionArchiveNudge', () => { get service() { return service; }, get counts() { return { references, polling, refreshes }; }, createNudge, + setChatRequest, onboarding: { service: onboardingService, events: onboardingEvents, @@ -260,7 +283,7 @@ suite('SessionArchiveNudge', () => { }, reloadService() { service.dispose(); - service = store.add(new SessionArchiveNudgeService(storage, management, telemetry, configuration, viewsService, onboardingService)); + service = store.add(new SessionArchiveNudgeService(storage, management, telemetry, configuration, viewsService, onboardingService, chatService)); }, setArchiveError(error: Error) { archiveError = error; }, setArchiveNoop() { archiveNoop = true; }, @@ -572,6 +595,221 @@ suite('SessionArchiveNudge', () => { }); }); + test('suppresses an exposed PR set after sending a new turn, not before exposure', () => { + const session = createSession(); + const context = setup([session]); + context.setPullRequest(1, GitHubPullRequestState.Merged); + const nudge = context.createNudge(); + const states = [!!nudge.options.get()]; + context.setChatRequest(session, 'turn-2'); + states.push(!!nudge.options.get()); + nudge.markShown(); + nudge.markShown(); + context.setChatRequest(session, 'turn-3'); + states.push(!!nudge.options.get()); + session.status.set(SessionStatus.InProgress, undefined); + session.status.set(SessionStatus.Completed, undefined); + states.push(!!nudge.options.get()); + nudge.markShown(); + context.setChatRequest(session, 'turn-4'); + states.push(!!nudge.options.get()); + + assert.deepStrictEqual({ states, live: context.counts.references, polling: context.counts.polling, impressions: context.events.length }, { + states: [true, true, false, false, false], live: 0, polling: 0, impressions: 1, + }); + }); + + test('removing the latest request does not suppress the nudge', () => runWithFakedTimers({ startTime: 100 }, async () => { + const session = createSession(); + const context = setup([session]); + context.setChatRequest(session, 'turn-2', undefined, 80); + context.setPullRequest(1, GitHubPullRequestState.Merged); + const nudge = context.createNudge(); + nudge.markShown(); + const states = [!!nudge.options.get()]; + context.setChatRequest(session, 'turn-1', undefined, 40); + states.push(!!nudge.options.get()); + context.setChatRequest(session, undefined); + states.push(!!nudge.options.get()); + nudge.dispose(); + context.reloadService(); + const reloaded = context.createNudge(); + states.push(!!reloaded.options.get()); + context.setChatRequest(session, 'turn-3', undefined, 101); + states.push(!!reloaded.options.get()); + + assert.deepStrictEqual(states, [true, true, true, true, false]); + })); + + test('remembers the shown turn across hidden views and reloads, and observes new turns without a view', () => { + const session = createSession(); + const context = setup([session]); + context.setPullRequest(1, GitHubPullRequestState.Merged); + const nudge = context.createNudge(); + nudge.markShown(); + context.current.set(undefined, undefined); + nudge.dispose(); + context.reloadService(); + context.current.set(session, undefined); + const reloaded = context.createNudge(); + const states = [!!reloaded.options.get()]; + reloaded.markShown(); + context.current.set(undefined, undefined); + reloaded.dispose(); + context.setChatRequest(session, 'turn-2'); + context.reloadService(); + context.current.set(session, undefined); + const continued = context.createNudge(); + states.push(!!continued.options.get()); + context.setChatRequest(session, 'turn-1', undefined, 1); + states.push(!!continued.options.get()); + + assert.deepStrictEqual(states, [true, false, false]); + }); + + test('rearms only for a new PR, waits for its merge, and suppresses it again after the next turn', () => { + const session = createSession(); + const context = setup([session]); + context.setPullRequest(1, GitHubPullRequestState.Merged); + const nudge = context.createNudge(); + nudge.markShown(); + context.setChatRequest(session, 'turn-2'); + const states: (number | undefined)[] = [nudge.options.get()?.pullRequestCount]; + session.artifacts.set([artifact(1, { label: 'Renamed', link: URI.parse('https://github.com/OWNER/REPO/pull/1/') }), artifact(2, { isArtifact: false })], undefined); + states.push(nudge.options.get()?.pullRequestCount); + context.setPullRequest(2, GitHubPullRequestState.Open); + session.artifacts.set([artifact(2), artifact(1)], undefined); + states.push(nudge.options.get()?.pullRequestCount); + context.setPullRequest(2, GitHubPullRequestState.Merged); + states.push(nudge.options.get()?.pullRequestCount); + nudge.markShown(); + session.artifacts.set([artifact(1), artifact(2)], undefined); + nudge.markShown(); + states.push(nudge.options.get()?.pullRequestCount); + context.setChatRequest(session, 'turn-3'); + states.push(nudge.options.get()?.pullRequestCount); + session.artifacts.set([artifact(1)], undefined); + states.push(nudge.options.get()?.pullRequestCount); + session.artifacts.set([artifact(1), artifact(2)], undefined); + states.push(nudge.options.get()?.pullRequestCount); + context.setPullRequest(3, GitHubPullRequestState.Merged); + session.artifacts.set([artifact(1), artifact(3)], undefined); + states.push(nudge.options.get()?.pullRequestCount); + nudge.markShown(); + context.setChatRequest(session, 'turn-4'); + states.push(nudge.options.get()?.pullRequestCount); + + assert.deepStrictEqual(states, [undefined, undefined, undefined, 2, 2, undefined, undefined, undefined, 2, undefined]); + }); + + test('detects a newer restored turn even if the service did not observe it being sent', () => { + const session = createSession(); + const context = setup([session]); + context.setPullRequest(1, GitHubPullRequestState.Merged); + const nudge = context.createNudge(); + nudge.markShown(); + nudge.dispose(); + context.service.dispose(); + context.setChatRequest(session, 'turn-2'); + context.reloadService(); + const reloaded = context.createNudge(); + + assert.strictEqual(reloaded.options.get(), undefined); + }); + + test('rejects a stale archive action after the user sends a new turn', async () => { + const session = createSession(); + const context = setup([session]); + context.setPullRequest(1, GitHubPullRequestState.Merged); + const nudge = context.createNudge(); + const action = nudge.options.get()!.onArchive; + nudge.markShown(); + context.setChatRequest(session, 'turn-2'); + + await assert.rejects(action(), /suggestion is no longer available/); + assert.deepStrictEqual(context.archiveTargets, []); + }); + + test('remembers same-count PR replacements even when the suggestion stays visible', () => { + const session = createSession(); + const context = setup([session]); + context.setPullRequest(1, GitHubPullRequestState.Merged); + context.setPullRequest(2, GitHubPullRequestState.Merged); + const nudge = context.createNudge(); + store.add(autorun(reader => { + if (nudge.options.read(reader)) { + nudge.markShown(); + } + })); + session.artifacts.set([artifact(2)], undefined); + context.setChatRequest(session, 'turn-2'); + session.artifacts.set([artifact(1)], undefined); + + assert.strictEqual(nudge.options.get(), undefined); + }); + + test('a new turn in a peer chat suppresses the session but loading older history does not', () => { + const session = createSession(); + const peerResource = URI.from({ scheme: 'test-chat', path: '/peer' }); + session.chats.set([upcastPartial({ + resource: peerResource, + status: observableValue('peerStatus', SessionStatus.Completed), + })], undefined); + const context = setup([session]); + context.setPullRequest(1, GitHubPullRequestState.Merged); + const nudge = context.createNudge(); + nudge.markShown(); + context.setChatRequest(session, 'old-peer-turn', peerResource, 1); + const states = [!!nudge.options.get()]; + context.setChatRequest(session, 'new-peer-turn', peerResource); + states.push(!!nudge.options.get()); + + assert.deepStrictEqual(states, [true, false]); + }); + + test('continuation is isolated per session and does not override explicit dismissal', () => { + const first = createSession('first'); + const second = createSession('second'); + const context = setup([first, second]); + context.setPullRequest(1, GitHubPullRequestState.Merged); + const nudge = context.createNudge(); + nudge.markShown(); + context.setChatRequest(first, 'turn-2'); + const states = [!!nudge.options.get()]; + context.current.set(second, undefined); + states.push(!!nudge.options.get()); + nudge.markShown(); + nudge.options.get()!.onDismiss(); + context.setChatRequest(second, 'turn-2'); + context.setPullRequest(2, GitHubPullRequestState.Merged); + second.artifacts.set([artifact(1), artifact(2)], undefined); + states.push(!!nudge.options.get()); + + assert.deepStrictEqual(states, [false, true, false]); + }); + + for (const lifecycle of ['archive', 'unarchive', 'delete', 'observedArchive'] as const) { + test(`clears the shown turn and PR set on ${lifecycle}`, () => { + const session = createSession(); + const context = setup([session]); + context.setPullRequest(1, GitHubPullRequestState.Merged); + const nudge = context.createNudge(); + nudge.markShown(); + context.setChatRequest(session, 'turn-2'); + if (lifecycle === 'observedArchive') { + session.isArchived.set(true, undefined); + session.isArchived.set(false, undefined); + } else { + ({ archive: context.archived, unarchive: context.unarchived, delete: context.deleted })[lifecycle].fire(session); + } + + assert.deepStrictEqual({ + visible: !!nudge.options.get(), + keys: context.storage.keys(StorageScope.PROFILE, StorageTarget.MACHINE), + }, { visible: true, keys: [] }); + }); + } + test('describes actual worktrees without treating missing or virtual workspaces as worktrees', () => { const session = createSession(); const context = setup([session]); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionsChatAccessibilityHelp.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionsChatAccessibilityHelp.test.ts index 32f4ebbfef19..01c67173e017 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionsChatAccessibilityHelp.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionsChatAccessibilityHelp.test.ts @@ -555,6 +555,7 @@ suite('SessionsChatAccessibilityHelp', () => { focus: nudgeHelp?.includes('returns focus to the chat input'), close: nudgeHelp?.includes('Close'), onboarding: content.includes('The action waits until you activate the highlighted action, activate Understood, or press Escape to end the spotlight.'), + continuation: content.includes('Sending a new message after the suggestion appears hides it for those pull requests, including after a reload. It can appear again when a new pull request is added to the session and all its pull requests have merged.'), sessionListHelp, }, { controls: true, @@ -563,6 +564,7 @@ suite('SessionsChatAccessibilityHelp', () => { focus: true, close: false, onboarding: true, + continuation: true, sessionListHelp: expectedSessionListHelp, }); });