From dd95da52fe612532b984cb79558059a35a162166 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Sun, 31 May 2026 14:03:42 -0700 Subject: [PATCH 1/2] sessions: fix PR status icon flicker on session list updates The GitHub PR status icons in the Agents window session list flickered whenever the list updated. The renderer cross-fades icons when the icon selector changes, and the icon was being transiently reset to the read/unread fallback dot on every recompute. Root cause: the `gitHubInfo` derived in both session providers acquired the ref-counted PR model via `reader.store`, which the observable framework disposes *before* each recompute. Releasing the reference dropped the refcount to 0, disposing the cached model; the freshly re-acquired model starts with `pullRequest === undefined`, so `livePR` momentarily read `undefined` and the icon fell back to the dot, triggering a cross-fade. In the copilot-chat provider this recompute fired on every unrelated `update()` because `_baseGitHubInfo` was reset with a fresh object each time. Fix: - Acquire the PR model reference via `reader.delayedStore` (disposed *after* recompute) so the cached, ref-counted model survives the recompute and `livePR` never blips to `undefined`. - Add `equalsFn: gitHubInfoEqual` to the `gitHubInfo` derived (both providers) and to `_baseGitHubInfo` so structurally-unchanged GitHub info no longer recomputes/re-notifies on unrelated list updates. - Widen `derivedOpts`'s computeFn reader type to `IReaderWithStore` so callers can use `store`/`delayedStore`, matching `derived`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../observableInternal/observables/derived.ts | 4 ++-- .../browser/baseAgentHostSessionsProvider.ts | 11 ++++++++--- .../browser/copilotChatSessionsProvider.ts | 17 +++++++++++------ 3 files changed, 21 insertions(+), 11 deletions(-) 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..ed4314a5376a5 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,7 @@ export class AgentHostSessionAdapter implements ISession { gitHubService.findPullRequestNumberByHeadBranch(coords.owner, coords.repo, coords.branch) ); }); - this.gitHubInfo = derived(this, reader => { + this.gitHubInfo = derivedOpts({ owner: this, equalsFn: gitHubInfoEqual }, reader => { const coords = gitHubCoords.read(reader); if (!coords) { return undefined; @@ -274,7 +274,12 @@ 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` and the icon resets to the + // fallback dot — causing a visible cross-fade flicker in the session list. + 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); 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; From 13c69c4ba15d986e4aa798c5dc304d6a3feb3c19 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Sun, 31 May 2026 18:11:12 -0700 Subject: [PATCH 2/2] Latch last-known PR icon for agent host sessions The agent host session adapter resolves its PR number asynchronously, so per-session polling never starts (it reads gitHubInfo.pullRequest at session-add time, when it is still undefined). The shared, ref-counted PR model is therefore only kept alive by the session list rows. When a session goes inactive, the active-session refresh stops updating its model, and the list re-splice that updates selection briefly unobserves the row's gitHubInfo derived, releasing the last reference. The model is disposed and re-acquired in an unpopulated state, so livePR reads undefined and the icon downgraded to the fallback dot -- it disappeared when clicking away from a session. Latch the last known icon keyed by the full PR identity (owner/repo/number) and reuse it whenever livePR is transiently undefined for the same PR. This also prevents the original flicker on list updates, while still updating promptly when the live model reports a new state. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/baseAgentHostSessionsProvider.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index ed4314a5376a5..746a5e46c3152 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -261,6 +261,14 @@ export class AgentHostSessionAdapter implements ISession { gitHubService.findPullRequestNumberByHeadBranch(coords.owner, coords.repo, coords.branch) ); }); + // 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) { @@ -277,14 +285,20 @@ export class AgentHostSessionAdapter implements ISession { // 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. + // 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,