diff --git a/src/vs/base/common/observableInternal/observables/derived.ts b/src/vs/base/common/observableInternal/observables/derived.ts index bb8f9ce5578b3..8e35590be0fad 100644 --- a/src/vs/base/common/observableInternal/observables/derived.ts +++ b/src/vs/base/common/observableInternal/observables/derived.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IObservable, IReader, ITransaction, ISettableObservable, IObservableWithChange } from '../base.js'; +import { IObservable, IReader, IReaderWithStore, ITransaction, ISettableObservable, IObservableWithChange } from '../base.js'; import { IChangeTracker } from '../changeTracker.js'; import { DisposableStore, EqualityComparer, IDisposable, strictEquals } from '../commonFacade/deps.js'; import { DebugLocation } from '../debugLocation.js'; @@ -63,7 +63,7 @@ export function derivedOpts( equalsFn?: EqualityComparer; onLastObserverRemoved?: (() => void); }, - computeFn: (reader: IReader) => T, + computeFn: (reader: IReaderWithStore) => T, debugLocation = DebugLocation.ofCaller() ): IObservable { return new Derived( diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 05e5f0598afd5..746a5e46c3152 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -36,7 +36,7 @@ import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/co import { buildMutableConfigSchema, IAgentHostSessionsProvider, resolvedConfigsEqual } from '../../../../common/agentHostSessionsProvider.js'; import { agentHostSessionWorkspaceKey } from '../../../../common/agentHostSessionWorkspace.js'; import { isSessionConfigComplete } from '../../../../common/sessionConfig.js'; -import { IChat, IGitHubInfo, ISession, ISessionAgentRef, ISessionChangeset, ISessionChangesSummary, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, sessionFileChangesEqual, SessionStatus, toSessionId } from '../../../../services/sessions/common/session.js'; +import { IChat, IGitHubInfo, gitHubInfoEqual, ISession, ISessionAgentRef, ISessionChangeset, ISessionChangesSummary, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, sessionFileChangesEqual, SessionStatus, toSessionId } from '../../../../services/sessions/common/session.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISendRequestOptions, ISessionChangeEvent } from '../../../../services/sessions/common/sessionsProvider.js'; import { computePullRequestIcon } from '../../../github/common/types.js'; @@ -261,7 +261,15 @@ export class AgentHostSessionAdapter implements ISession { gitHubService.findPullRequestNumberByHeadBranch(coords.owner, coords.repo, coords.branch) ); }); - this.gitHubInfo = derived(this, reader => { + // Latches the last known icon for a given PR identity. The live PR model is + // shared and ref-counted: when the session goes inactive (and agent host sessions + // don't poll) the model can be released, disposed and later re-acquired in an + // unpopulated state, so `livePR` momentarily reads `undefined`. Without latching, + // the icon would downgrade to the fallback dot (it flickers on list updates and + // disappears entirely when clicking away from a session). Keyed by the full PR + // identity so a different PR never reuses a stale icon. + let lastKnownPr: { readonly owner: string; readonly repo: string; readonly number: number; readonly icon: ThemeIcon } | undefined; + this.gitHubInfo = derivedOpts({ owner: this, equalsFn: gitHubInfoEqual }, reader => { const coords = gitHubCoords.read(reader); if (!coords) { return undefined; @@ -274,12 +282,23 @@ export class AgentHostSessionAdapter implements ISession { const uri = URI.parse(`https://github.com/${coords.owner}/${coords.repo}/pull/${prNumber}`); let icon: ThemeIcon | undefined; if (gitHubService) { - const ref = reader.store.add(gitHubService.createPullRequestModelReference(coords.owner, coords.repo, prNumber)); + // Hold the PR model reference in `delayedStore` (not `store`) so the cached, + // ref-counted model survives across recomputations. With `store`, the reference + // is released before the recompute runs, dropping the refcount to 0 and disposing + // the model, so `livePR` momentarily reads `undefined`. + const ref = reader.delayedStore.add(gitHubService.createPullRequestModelReference(coords.owner, coords.repo, prNumber)); const livePR = ref.object.pullRequest.read(reader); if (livePR) { icon = computePullRequestIcon(livePR.isDraft ? 'draft' : livePR.state); } } + if (icon) { + lastKnownPr = { owner: coords.owner, repo: coords.repo, number: prNumber, icon }; + } else if (lastKnownPr && lastKnownPr.owner === coords.owner && lastKnownPr.repo === coords.repo && lastKnownPr.number === prNumber) { + // The live model hasn't produced a state yet (e.g. it was just re-acquired); + // keep showing the last known icon for this same PR instead of the fallback dot. + icon = lastKnownPr.icon; + } return { owner: coords.owner, repo: coords.repo, diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 76c8f1ad4c5c9..1ec1dd4804086 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -10,7 +10,7 @@ import { CancellationError } from '../../../../../base/common/errors.js'; import { IMarkdownString, MarkdownString } from '../../../../../base/common/htmlContent.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; -import { autorun, constObservable, derived, IObservable, IReader, ISettableObservable, observableFromEvent, observableValue, observableValueOpts, transaction } from '../../../../../base/common/observable.js'; +import { autorun, constObservable, derived, derivedOpts, IObservable, IReader, ISettableObservable, observableFromEvent, observableValue, observableValueOpts, transaction } from '../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; @@ -23,7 +23,7 @@ import { AgentSessionProviders, AgentSessionTarget } from '../../../../../workbe import { IChatService, IChatSendRequestOptions } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatResponseModel } from '../../../../../workbench/contrib/chat/common/model/chatModel.js'; import { ChatSessionStatus, IChatSessionsService, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, SessionType } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { ISession, IChat, ISessionGitRepository, ISessionFolder, ISessionWorkspace, SessionStatus, GITHUB_REMOTE_FILE_SCHEME, IGitHubInfo, ISessionType, ISessionWorkspaceBrowseAction, ISessionFileChange, sessionFileChangesEqual, toSessionId, SESSION_WORKSPACE_GROUP_LOCAL, ISessionChangeset, IChatCheckpoints } from '../../../../services/sessions/common/session.js'; +import { ISession, IChat, ISessionGitRepository, ISessionFolder, ISessionWorkspace, SessionStatus, GITHUB_REMOTE_FILE_SCHEME, IGitHubInfo, gitHubInfoEqual, ISessionType, ISessionWorkspaceBrowseAction, ISessionFileChange, sessionFileChangesEqual, toSessionId, SESSION_WORKSPACE_GROUP_LOCAL, ISessionChangeset, IChatCheckpoints } from '../../../../services/sessions/common/session.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel, isChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js'; import { basename, dirname, isEqual } from '../../../../../base/common/resources.js'; import { ISendRequestOptions, ISessionChangeEvent, ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; @@ -961,7 +961,7 @@ class AgentSessionAdapter implements ICopilotChatSession { private readonly _lastTurnEnd: ReturnType>; readonly lastTurnEnd: IObservable; - private readonly _baseGitHubInfo: ReturnType>; + private readonly _baseGitHubInfo: ReturnType>; readonly gitHubInfo: IObservable; readonly permissionLevel: IObservable = constObservable(ChatPermissionLevel.Default); @@ -984,13 +984,18 @@ class AgentSessionAdapter implements ICopilotChatSession { this.icon = this._getSessionTypeIcon(session); this.createdAt = new Date(session.timing.created); - this._baseGitHubInfo = observableValue(this, this._extractGitHubInfo(session)); - this.gitHubInfo = derived(this, reader => { + this._baseGitHubInfo = observableValueOpts({ owner: this, equalsFn: gitHubInfoEqual }, this._extractGitHubInfo(session)); + this.gitHubInfo = derivedOpts({ owner: this, equalsFn: gitHubInfoEqual }, reader => { const base = this._baseGitHubInfo.read(reader); if (!base?.pullRequest || !this._gitHubService) { return base; } - const prModelRef = reader.store.add(this._gitHubService.createPullRequestModelReference(base.owner, base.repo, base.pullRequest.number)); + // Hold the PR model reference in `delayedStore` (not `store`) so the cached, + // ref-counted model survives across recomputations. With `store`, the reference + // is released before the recompute runs, dropping the refcount to 0 and disposing + // the model, so `livePR` momentarily reads `undefined` and the icon resets to the + // fallback dot — causing a visible cross-fade flicker in the session list. + const prModelRef = reader.delayedStore.add(this._gitHubService.createPullRequestModelReference(base.owner, base.repo, base.pullRequest.number)); const livePR = prModelRef.object.pullRequest.read(reader); if (!livePR) { return base;