Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/vs/base/common/observableInternal/observables/derived.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -63,7 +63,7 @@ export function derivedOpts<T>(
equalsFn?: EqualityComparer<T>;
onLastObserverRemoved?: (() => void);
},
computeFn: (reader: IReader) => T,
computeFn: (reader: IReaderWithStore) => T,
debugLocation = DebugLocation.ofCaller()
): IObservable<T> {
return new Derived(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -261,7 +261,15 @@ export class AgentHostSessionAdapter implements ISession {
gitHubService.findPullRequestNumberByHeadBranch(coords.owner, coords.repo, coords.branch)
);
});
this.gitHubInfo = derived<IGitHubInfo | undefined>(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<IGitHubInfo | undefined>({ owner: this, equalsFn: gitHubInfoEqual }, reader => {
const coords = gitHubCoords.read(reader);
if (!coords) {
return undefined;
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -961,7 +961,7 @@ class AgentSessionAdapter implements ICopilotChatSession {
private readonly _lastTurnEnd: ReturnType<typeof observableValue<Date | undefined>>;
readonly lastTurnEnd: IObservable<Date | undefined>;

private readonly _baseGitHubInfo: ReturnType<typeof observableValue<IGitHubInfo | undefined>>;
private readonly _baseGitHubInfo: ReturnType<typeof observableValueOpts<IGitHubInfo | undefined>>;
readonly gitHubInfo: IObservable<IGitHubInfo | undefined>;

readonly permissionLevel: IObservable<ChatPermissionLevel> = constObservable(ChatPermissionLevel.Default);
Expand All @@ -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;
Expand Down
Loading