mirror of
https://github.com/microsoft/vscode.git
synced 2026-08-21 00:42:58 +01:00
AgentHost - adopt the concept of reviewed from the protocol (#325310)
* AgentHost - adopt the concept of reviewed file from the agent host protocol
This commit is contained in:
@@ -37,6 +37,11 @@ export function buildReviewedRefName(sanitizedSessionId: string): string {
|
||||
export interface IAgentHostReviewService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
/**
|
||||
* Persists the review state for files in a local Branch Changes changeset.
|
||||
*/
|
||||
setReviewState(channel: ProtocolURI, resources: readonly ProtocolURI[], reviewed: boolean): Promise<void>;
|
||||
|
||||
/**
|
||||
* Marks a single file reviewed at its current working-tree content by
|
||||
* overlaying that content into the reviewed tree and advancing the
|
||||
@@ -76,6 +81,7 @@ export interface IAgentHostReviewService {
|
||||
*/
|
||||
export const NULL_REVIEW_SERVICE: IAgentHostReviewService = {
|
||||
_serviceBrand: undefined,
|
||||
setReviewState: async () => { },
|
||||
markFileReviewed: async () => { },
|
||||
markFileUnreviewed: async () => { },
|
||||
getReviewedPaths: async () => new Set(),
|
||||
|
||||
@@ -19,7 +19,7 @@ import type { CompletionsParams, CompletionsResult, CreateTerminalParams, Resolv
|
||||
import type { InitializeResult } from './state/protocol/common/commands.js';
|
||||
import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from './state/protocol/channels-changeset/commands.js';
|
||||
import { ProtectedResourceMetadata, type Changeset, type ConfigSchema, type MessageAttachment, type ModelSelection, type AgentSelection, type SessionActiveClient, type ToolCallPendingConfirmationState, type ToolDefinition, ChangesSummary } from './state/protocol/state.js';
|
||||
import type { ActionEnvelope, AuthRequiredParams, INotification, IRootConfigChangedAction, SessionAction, ChatAction, TerminalAction, ClientAnnotationsAction } from './state/sessionActions.js';
|
||||
import type { ActionEnvelope, AuthRequiredParams, INotification, IRootConfigChangedAction, SessionAction, ChatAction, TerminalAction, ClientAnnotationsAction, ClientChangesetAction } from './state/sessionActions.js';
|
||||
import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMkdirParams, ResourceMkdirResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceResolveParams, ResourceResolveResult, ResourceWatchState, ResourceWriteParams, ResourceWriteResult, CreateResourceWatchParams, CreateResourceWatchResult, IStateSnapshot } from './state/sessionProtocol.js';
|
||||
import { ComponentToState, ChatInputResponseKind, SessionStatus, StateComponents, buildSubagentChatUri, parseRequiredSessionUriFromChatUri, type AgentCapabilities, type ClientPluginCustomization, type Customization, type PendingMessage, type RootState, type ChatInputAnswer, type SessionMeta, type ToolCallResult, type Turn, type PolicyState } from './state/sessionState.js';
|
||||
|
||||
@@ -1807,7 +1807,7 @@ export interface IAgentService {
|
||||
* rather than {@link URI} objects so that authority-less scheme URIs
|
||||
* like `ahp-root://` survive the wire format without normalization.
|
||||
*/
|
||||
dispatchAction(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number): void;
|
||||
dispatchAction(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number): void;
|
||||
|
||||
/**
|
||||
* List the contents of a directory on the agent host's filesystem.
|
||||
@@ -1926,7 +1926,7 @@ export interface IAgentConnection {
|
||||
* than {@link URI} objects so authority-less scheme URIs like
|
||||
* `ahp-root://` survive the wire format without normalization.
|
||||
*/
|
||||
dispatch(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction): void;
|
||||
dispatch(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction): void;
|
||||
|
||||
// ---- Events (connection-level) ------------------------------------------
|
||||
readonly onDidNotification: Event<INotification>;
|
||||
|
||||
@@ -315,7 +315,8 @@ export function buildDefaultChangesetCatalog(sessionUri: URI, gitState?: ISessio
|
||||
? formatBranchChangesetDescription(gitState)
|
||||
: undefined,
|
||||
uriTemplate: buildBranchChangesetUri(sessionUri),
|
||||
changeKind: ChangesetKind.Branch
|
||||
changeKind: ChangesetKind.Branch,
|
||||
capabilities: { review: {} }
|
||||
},
|
||||
{
|
||||
label: uncommittedChangesetLabel(),
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Disposable, IReference } from '../../../../base/common/lifecycle.js';
|
||||
import { ResourceMap } from '../../../../base/common/map.js';
|
||||
import { IObservable, observableFromEvent } from '../../../../base/common/observable.js';
|
||||
import { URI } from '../../../../base/common/uri.js';
|
||||
import { ActionEnvelope, ActionType, ChangesetAction, ChatAction, AnnotationsAction, ClientAnnotationsAction, IRootConfigChangedAction, SessionAction, StateAction, isChangesetAction, isChatAction, isAnnotationsAction, isSessionAction } from './sessionActions.js';
|
||||
import { ActionEnvelope, ActionType, ChangesetAction, ChatAction, AnnotationsAction, ClientAnnotationsAction, ClientChangesetAction, IRootConfigChangedAction, SessionAction, StateAction, isChangesetAction, isChatAction, isAnnotationsAction, isSessionAction } from './sessionActions.js';
|
||||
import { changesetReducer, chatReducer, annotationsReducer, rootReducer, sessionReducer } from './sessionReducers.js';
|
||||
import { terminalReducer } from './protocol/reducers.js';
|
||||
import type { RootAction, SessionAction as IProtocolSessionAction, ChatAction as IProtocolChatAction, TerminalAction } from './protocol/action-origin.generated.js';
|
||||
@@ -570,22 +570,38 @@ export class TerminalStateSubscription extends BaseAgentSubscription<TerminalSta
|
||||
* Subscription to a changeset at an expanded changeset URI (e.g.
|
||||
* `<sessionUri>/changeset/session`).
|
||||
*
|
||||
* Server-only mutations — no write-ahead. The subscription itself does NOT
|
||||
* self-tear-down on lifecycle events; cleanup is driven externally:
|
||||
* - Workbench-side: `BaseAgentHostSessionsProvider._handleSessionRemoved`
|
||||
* disposes the per-session subscription map, which releases this
|
||||
* subscription's `IReference` and triggers `_releaseSubscription` on
|
||||
* the manager.
|
||||
* - Wire layer: {@link IAgentConnection} refcounts the underlying server
|
||||
* subscription so multiple consumers can share one wire-level subscribe.
|
||||
* Changeset review actions are client-dispatchable, so this subscription
|
||||
* supports write-ahead reconciliation.
|
||||
*/
|
||||
export class ChangesetStateSubscription extends BaseAgentSubscription<ChangesetState> {
|
||||
|
||||
private readonly _pendingActions: { readonly clientSeq: number; readonly action: ClientChangesetAction }[] = [];
|
||||
private _optimisticState: ChangesetState | undefined;
|
||||
private readonly _changesetUri: string;
|
||||
private readonly _seqAllocator: () => number;
|
||||
|
||||
constructor(changesetUri: string, clientId: string, log: (msg: string) => void) {
|
||||
constructor(changesetUri: string, clientId: string, seqAllocator: () => number, log: (msg: string) => void) {
|
||||
super(clientId, log);
|
||||
this._changesetUri = changesetUri;
|
||||
this._seqAllocator = seqAllocator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistically apply a changeset action and return its client sequence.
|
||||
*/
|
||||
applyOptimistic(action: ClientChangesetAction): number {
|
||||
const clientSeq = this._seqAllocator();
|
||||
this._pendingActions.push({ clientSeq, action });
|
||||
const base = this._optimisticState ?? this.verifiedValue;
|
||||
if (base) {
|
||||
this._optimisticState = changesetReducer(base, action, this._log);
|
||||
this._onDidChange.fire(this._optimisticState);
|
||||
}
|
||||
return clientSeq;
|
||||
}
|
||||
|
||||
protected override _getOptimisticState(): ChangesetState | undefined {
|
||||
return this._optimisticState;
|
||||
}
|
||||
|
||||
protected override _applyReducer(state: ChangesetState, action: StateAction): ChangesetState {
|
||||
@@ -595,6 +611,55 @@ export class ChangesetStateSubscription extends BaseAgentSubscription<ChangesetS
|
||||
protected override _isRelevantEnvelope(envelope: ActionEnvelope): boolean {
|
||||
return isChangesetAction(envelope.action) && envelope.channel === this._changesetUri;
|
||||
}
|
||||
|
||||
protected override _onSnapshotApplied(fromSeq: number): void {
|
||||
super._onSnapshotApplied(fromSeq);
|
||||
this._recomputeOptimistic();
|
||||
}
|
||||
|
||||
protected override _reconcile(envelope: ActionEnvelope, isOwnAction: boolean): void {
|
||||
if (isOwnAction && envelope.origin) {
|
||||
const index = this._pendingActions.findIndex(pending => pending.clientSeq === envelope.origin!.clientSeq);
|
||||
if (index !== -1) {
|
||||
if (!envelope.rejectionReason) {
|
||||
this._confirmedApply(envelope.action);
|
||||
}
|
||||
this._pendingActions.splice(index, 1);
|
||||
} else {
|
||||
this._confirmedApply(envelope.action);
|
||||
}
|
||||
} else {
|
||||
this._confirmedApply(envelope.action);
|
||||
}
|
||||
this._recomputeOptimistic();
|
||||
}
|
||||
|
||||
private _confirmedApply(action: StateAction): void {
|
||||
if (this._confirmedState) {
|
||||
this._confirmedState = this._applyReducer(this._confirmedState, action);
|
||||
}
|
||||
}
|
||||
|
||||
private _recomputeOptimistic(): void {
|
||||
const confirmed = this._confirmedState;
|
||||
if (!confirmed) {
|
||||
this._optimisticState = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._pendingActions.length === 0) {
|
||||
this._optimisticState = undefined;
|
||||
this._onDidChange.fire(confirmed);
|
||||
return;
|
||||
}
|
||||
|
||||
let state = confirmed;
|
||||
for (const pending of this._pendingActions) {
|
||||
state = changesetReducer(state, pending.action, this._log);
|
||||
}
|
||||
this._optimisticState = state;
|
||||
this._onDidChange.fire(state);
|
||||
}
|
||||
}
|
||||
|
||||
type ManagedSubscription = SessionStateSubscription | ChatStateSubscription | TerminalStateSubscription | ChangesetStateSubscription | AnnotationsStateSubscription;
|
||||
@@ -920,7 +985,7 @@ export class AgentSubscriptionManager extends Disposable {
|
||||
* `channel` is the protocol URI string identifying the channel the
|
||||
* action targets (a session URI for session actions, etc.).
|
||||
*/
|
||||
dispatchOptimistic(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction): number {
|
||||
dispatchOptimistic(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction): number {
|
||||
if (isSessionAction(action)) {
|
||||
const entry = this._subscriptions.get(URI.parse(channel));
|
||||
if (entry?.sub instanceof SessionStateSubscription) {
|
||||
@@ -931,6 +996,11 @@ export class AgentSubscriptionManager extends Disposable {
|
||||
if (entry?.sub instanceof ChatStateSubscription) {
|
||||
return entry.sub.applyOptimistic(action);
|
||||
}
|
||||
} else if (isChangesetAction(action)) {
|
||||
const entry = this._subscriptions.get(URI.parse(channel));
|
||||
if (entry?.sub instanceof ChangesetStateSubscription) {
|
||||
return entry.sub.applyOptimistic(action);
|
||||
}
|
||||
} else if (isAnnotationsAction(action)) {
|
||||
const entry = this._subscriptions.get(URI.parse(channel));
|
||||
if (entry?.sub instanceof AnnotationsStateSubscription) {
|
||||
@@ -1059,7 +1129,7 @@ export class AgentSubscriptionManager extends Disposable {
|
||||
case StateComponents.Terminal:
|
||||
return new TerminalStateSubscription(key, this._clientId, this._log);
|
||||
case StateComponents.Changeset:
|
||||
return new ChangesetStateSubscription(key, this._clientId, this._log);
|
||||
return new ChangesetStateSubscription(key, this._clientId, this._seqAllocator, this._log);
|
||||
case StateComponents.Annotations:
|
||||
return new AnnotationsStateSubscription(key, this._clientId, this._seqAllocator, this._log);
|
||||
case StateComponents.Root:
|
||||
|
||||
@@ -130,7 +130,7 @@ import type {
|
||||
} from './protocol/actions.js';
|
||||
|
||||
import type { SessionAddedParams, SessionRemovedParams, SessionSummaryChangedParams, ProgressParams, AuthRequiredParams } from './protocol/notifications.js';
|
||||
import type { RootAction as IRootAction_, SessionAction as ISessionAction_, ChatAction as IChatAction_, ClientSessionAction as IClientSessionAction_, ServerSessionAction as IServerSessionAction_, ClientChatAction as IClientChatAction_, ServerChatAction as IServerChatAction_, TerminalAction as ITerminalAction_, ClientTerminalAction as IClientTerminalAction_, ChangesetAction as IChangesetAction_, AnnotationsAction as IAnnotationsAction_, ClientAnnotationsAction as IClientAnnotationsAction_ } from './protocol/action-origin.generated.js';
|
||||
import type { RootAction as IRootAction_, SessionAction as ISessionAction_, ChatAction as IChatAction_, ClientSessionAction as IClientSessionAction_, ServerSessionAction as IServerSessionAction_, ClientChatAction as IClientChatAction_, ServerChatAction as IServerChatAction_, TerminalAction as ITerminalAction_, ClientTerminalAction as IClientTerminalAction_, ChangesetAction as IChangesetAction_, ClientChangesetAction as IClientChangesetAction_, AnnotationsAction as IAnnotationsAction_, ClientAnnotationsAction as IClientAnnotationsAction_ } from './protocol/action-origin.generated.js';
|
||||
|
||||
/**
|
||||
* Discriminated union of all server→client protocol notifications other than
|
||||
@@ -155,6 +155,7 @@ export type ServerChatAction = IServerChatAction_;
|
||||
export type TerminalAction = ITerminalAction_;
|
||||
export type ClientTerminalAction = IClientTerminalAction_;
|
||||
export type ChangesetAction = IChangesetAction_;
|
||||
export type ClientChangesetAction = IClientChangesetAction_;
|
||||
export type AnnotationsAction = IAnnotationsAction_;
|
||||
export type ClientAnnotationsAction = IClientAnnotationsAction_;
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import { wrapAgentServiceWithAhpLogging } from './localAhpJsonlLogging.js';
|
||||
import { AgentSubscriptionManager, isActionEnvelopeRelevantToSubscriptionUris, type IActiveSubscriptionInfo, type IAgentSubscription } from '../common/state/agentSubscription.js';
|
||||
import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js';
|
||||
import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js';
|
||||
import { ActionType, type ActionEnvelope, type INotification, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type ClientAnnotationsAction } from '../common/state/sessionActions.js';
|
||||
import { ActionType, type ActionEnvelope, type INotification, type IRootConfigChangedAction, type SessionAction, type ChatAction, type TerminalAction, type ClientAnnotationsAction, type ClientChangesetAction } from '../common/state/sessionActions.js';
|
||||
import { createRemoteWatchHandle, type IRemoteWatchHandle } from '../common/agentHostFileSystemProvider.js';
|
||||
import type { CreateResourceWatchParams, CreateResourceWatchResult, ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMkdirParams, ResourceMkdirResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceResolveParams, ResourceResolveResult, ResourceWriteParams, ResourceWriteResult, IStateSnapshot } from '../common/state/sessionProtocol.js';
|
||||
import { StateComponents, ROOT_STATE_URI, parseChatUri, type RootState } from '../common/state/sessionState.js';
|
||||
@@ -387,7 +387,7 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos
|
||||
this._subscribedResources.set(key, count - 1);
|
||||
}
|
||||
}
|
||||
dispatchAction(channel: string, action: SessionAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number): void {
|
||||
dispatchAction(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number): void {
|
||||
this._proxy.dispatchAction(channel, action, clientId, clientSeq);
|
||||
}
|
||||
private _nextSeq = 1;
|
||||
@@ -415,7 +415,7 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos
|
||||
return this._subscriptionManager.getActiveSubscriptions();
|
||||
}
|
||||
|
||||
dispatch(channel: string, action: SessionAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction): void {
|
||||
dispatch(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction): void {
|
||||
const seq = this._subscriptionManager.dispatchOptimistic(channel, action);
|
||||
this.dispatchAction(channel, action, this.clientId, seq);
|
||||
}
|
||||
|
||||
@@ -900,11 +900,13 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC
|
||||
continue;
|
||||
}
|
||||
if (reviewed) {
|
||||
// Surface per-file review status for the Branch changeset. The
|
||||
// file id is a `file:` URI under the repository root, so the
|
||||
// repo-relative path keys into the reviewed-paths set.
|
||||
const relPath = relativePath(reviewed.repoRoot, URI.parse(id));
|
||||
files.push({ id, edit, _meta: { reviewed: relPath ? reviewed.paths.has(relPath) : false } });
|
||||
files.push({
|
||||
id, edit,
|
||||
reviewed: relPath
|
||||
? reviewed.paths.has(relPath)
|
||||
: false
|
||||
});
|
||||
} else {
|
||||
files.push({ id, edit });
|
||||
}
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { CancellationToken } from '../../../base/common/cancellation.js';
|
||||
import { basename } from '../../../base/common/resources.js';
|
||||
import { URI } from '../../../base/common/uri.js';
|
||||
import { localize } from '../../../nls.js';
|
||||
import { ILogService } from '../../log/common/log.js';
|
||||
import { ChangesetKind, parseChangesetUri } from '../common/changesetUri.js';
|
||||
import { type IChangesetOperationHandler } from '../common/agentHostChangesetOperationService.js';
|
||||
import { IAgentHostChangesetService } from '../common/agentHostChangesetService.js';
|
||||
import { META_DIFF_BASE_BRANCH, resolveDiffBaseBranchName } from '../common/agentHostGitService.js';
|
||||
import { IAgentHostReviewService } from '../common/agentHostReviewService.js';
|
||||
import { ISessionDataService } from '../common/sessionDataService.js';
|
||||
import { ChangesetOperationTargetKind, type InvokeChangesetOperationParams, type InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js';
|
||||
import { readSessionGitState, type SessionState } from '../common/state/sessionState.js';
|
||||
import { AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../common/state/sessionProtocol.js';
|
||||
|
||||
/**
|
||||
* Handles the `mark-as-reviewed` and `mark-as-unreviewed` resource-scoped
|
||||
* changeset operations for the **Branch Changes** changeset. A single instance
|
||||
* handles one direction — marking a file as reviewed or clearing that mark —
|
||||
* selected by the `_reviewed` flag.
|
||||
*
|
||||
* The reviewed state is owned by {@link IAgentHostReviewService}, which tracks
|
||||
* it as a session-private synthetic git ref. This handler resolves the session's
|
||||
* working directory + base branch and delegates to that service.
|
||||
*/
|
||||
export class AgentHostReviewFileOperationHandler implements IChangesetOperationHandler {
|
||||
|
||||
public static readonly OPERATION_MARK_AS_REVIEWED = 'mark-as-reviewed';
|
||||
public static readonly OPERATION_MARK_AS_UNREVIEWED = 'mark-as-unreviewed';
|
||||
|
||||
constructor(
|
||||
private readonly _reviewed: boolean,
|
||||
private readonly _getSessionState: (sessionKey: string) => SessionState | undefined,
|
||||
@IAgentHostReviewService private readonly _reviewService: IAgentHostReviewService,
|
||||
@IAgentHostChangesetService private readonly _changesetService: IAgentHostChangesetService,
|
||||
@ISessionDataService private readonly _sessionDataService: ISessionDataService,
|
||||
@ILogService private readonly _logService: ILogService,
|
||||
) { }
|
||||
|
||||
private get _operationId(): string {
|
||||
return this._reviewed
|
||||
? AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_REVIEWED
|
||||
: AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_UNREVIEWED;
|
||||
}
|
||||
|
||||
async invoke(params: InvokeChangesetOperationParams, token: CancellationToken): Promise<InvokeChangesetOperationResult> {
|
||||
const parsed = parseChangesetUri(params.channel);
|
||||
if (!parsed || parsed.kind !== ChangesetKind.Branch) {
|
||||
throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Not a branch changeset URI: ${params.channel}`);
|
||||
}
|
||||
this._throwIfCancelled(token);
|
||||
|
||||
const sessionUri = parsed.sessionUri;
|
||||
const sessionState = this._getSessionState(sessionUri);
|
||||
if (!sessionState) {
|
||||
throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found: ${sessionUri}`);
|
||||
}
|
||||
|
||||
if (params.target?.kind !== ChangesetOperationTargetKind.Resource) {
|
||||
throw new ProtocolError(
|
||||
JsonRpcErrorCodes.InvalidParams,
|
||||
`Operation '${this._operationId}' requires a resource target.`);
|
||||
}
|
||||
|
||||
const workingDirectoryStr = sessionState.workingDirectory;
|
||||
if (!workingDirectoryStr) {
|
||||
throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Session has no working directory: ${sessionUri}`);
|
||||
}
|
||||
|
||||
const workingDirectory = URI.parse(workingDirectoryStr);
|
||||
const resource = URI.parse(params.target.resource);
|
||||
const baseBranch = await this._resolveBaseBranch(sessionUri, sessionState);
|
||||
|
||||
try {
|
||||
if (this._reviewed) {
|
||||
this._logService.info(`[AgentHostReviewFileOperationHandler] Marking '${resource.fsPath}' as reviewed for session ${sessionUri}`);
|
||||
await this._reviewService.markFileReviewed(sessionUri, workingDirectory, baseBranch, resource);
|
||||
this._changesetService.refreshBranchChangeset(sessionUri);
|
||||
|
||||
return { message: { markdown: localize('agentHost.changeset.reviewFile.marked', "Marked `{0}` as reviewed.", basename(resource)) } };
|
||||
}
|
||||
|
||||
this._logService.info(`[AgentHostReviewFileOperationHandler] Removing reviewed mark for '${resource.fsPath}' in session ${sessionUri}`);
|
||||
await this._reviewService.markFileUnreviewed(sessionUri, workingDirectory, baseBranch, resource);
|
||||
this._changesetService.refreshBranchChangeset(sessionUri);
|
||||
|
||||
return { message: { markdown: localize('agentHost.changeset.reviewFile.unmarked', "Removed the reviewed mark from `{0}`.", basename(resource)) } };
|
||||
} catch (err) {
|
||||
this._throwIfCancelled(token);
|
||||
throw new ProtocolError(
|
||||
JsonRpcErrorCodes.InternalError,
|
||||
`Failed to update reviewed state: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the Branch Changes base branch the same way the changeset service
|
||||
* does, so review status is keyed on the same baseline the diff uses.
|
||||
*/
|
||||
private async _resolveBaseBranch(sessionUri: string, sessionState: SessionState): Promise<string | undefined> {
|
||||
const databaseRef = this._sessionDataService.openDatabase(URI.parse(sessionUri));
|
||||
try {
|
||||
const persistedBaseBranch = await databaseRef.object.getMetadata(META_DIFF_BASE_BRANCH);
|
||||
const gitStateBaseBranch = readSessionGitState(sessionState._meta)?.baseBranchName;
|
||||
return resolveDiffBaseBranchName(persistedBaseBranch, gitStateBaseBranch);
|
||||
} finally {
|
||||
databaseRef.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private _throwIfCancelled(token: CancellationToken): void {
|
||||
if (token.isCancellationRequested) {
|
||||
throw new ProtocolError(JsonRpcErrorCodes.InternalError, localize('agentHost.changeset.reviewFile.cancelled', "Review file operation was cancelled."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Disposable, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js';
|
||||
import { localize } from '../../../nls.js';
|
||||
import { IInstantiationService } from '../../instantiation/common/instantiation.js';
|
||||
import { ChangesetKind } from '../common/changesetUri.js';
|
||||
import type { IChangesetOperationContribution, IChangesetOperationContext, IChangesetOperationRegistry } from '../common/agentHostChangesetOperationService.js';
|
||||
import { ChangesetOperationScope, ChangesetOperationStatus, type ChangesetOperation } from '../common/state/sessionState.js';
|
||||
import { AgentHostReviewFileOperationHandler } from './agentHostReviewFileOperationHandler.js';
|
||||
import { AgentHostStateManager } from './agentHostStateManager.js';
|
||||
|
||||
/**
|
||||
* Contributes the `mark-as-reviewed` / `mark-as-unreviewed` resource-scoped
|
||||
* operations for the **Branch Changes** changeset, backed by
|
||||
* {@link AgentHostReviewFileOperationHandler}.
|
||||
*/
|
||||
export class AgentHostReviewOperationContribution extends Disposable implements IChangesetOperationContribution {
|
||||
|
||||
constructor(
|
||||
private readonly _stateManager: AgentHostStateManager,
|
||||
@IInstantiationService private readonly _instantiationService: IInstantiationService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
registerHandlers(registry: IChangesetOperationRegistry): IDisposable {
|
||||
const store = new DisposableStore();
|
||||
const getSessionState = (sessionKey: string) => this._stateManager.getSessionState(sessionKey);
|
||||
const markHandler = this._instantiationService.createInstance(AgentHostReviewFileOperationHandler, true, getSessionState);
|
||||
const unmarkHandler = this._instantiationService.createInstance(AgentHostReviewFileOperationHandler, false, getSessionState);
|
||||
store.add(registry.registerChangesetOperationHandler(AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_REVIEWED, markHandler));
|
||||
store.add(registry.registerChangesetOperationHandler(AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_UNREVIEWED, unmarkHandler));
|
||||
return store;
|
||||
}
|
||||
|
||||
getOperations({ changesetKind }: IChangesetOperationContext): ChangesetOperation[] {
|
||||
if (changesetKind !== ChangesetKind.Branch) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_REVIEWED,
|
||||
label: localize('agentHost.changeset.markAsReviewed', "Mark as Reviewed"),
|
||||
icon: 'check',
|
||||
group: 'review',
|
||||
scopes: [ChangesetOperationScope.Resource],
|
||||
status: ChangesetOperationStatus.Idle,
|
||||
},
|
||||
{
|
||||
id: AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_UNREVIEWED,
|
||||
label: localize('agentHost.changeset.markAsUnreviewed', "Mark as Unreviewed"),
|
||||
icon: 'check',
|
||||
group: 'review',
|
||||
scopes: [ChangesetOperationScope.Resource],
|
||||
status: ChangesetOperationStatus.Idle,
|
||||
},
|
||||
] satisfies ChangesetOperation[];
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,11 @@ import { relativePath } from '../../../base/common/resources.js';
|
||||
import { URI } from '../../../base/common/uri.js';
|
||||
import { ILogService } from '../../log/common/log.js';
|
||||
import { AgentSession } from '../common/agentService.js';
|
||||
import type { URI as ProtocolURI } from '../common/state/sessionState.js';
|
||||
import { EMPTY_TREE_OBJECT, IAgentHostGitService } from '../common/agentHostGitService.js';
|
||||
import { ChangesetKind, parseChangesetUri } from '../common/changesetUri.js';
|
||||
import { EMPTY_TREE_OBJECT, IAgentHostGitService, META_DIFF_BASE_BRANCH, resolveDiffBaseBranchName } from '../common/agentHostGitService.js';
|
||||
import { buildReviewedRefName, IAgentHostReviewService } from '../common/agentHostReviewService.js';
|
||||
import { ISessionDataService } from '../common/sessionDataService.js';
|
||||
import { readSessionGitState, type URI as ProtocolURI } from '../common/state/sessionState.js';
|
||||
import { AgentHostStateManager } from './agentHostStateManager.js';
|
||||
|
||||
/**
|
||||
@@ -57,6 +58,37 @@ export class AgentHostReviewService extends Disposable implements IAgentHostRevi
|
||||
}));
|
||||
}
|
||||
|
||||
async setReviewState(channel: ProtocolURI, resources: readonly ProtocolURI[], reviewed: boolean): Promise<void> {
|
||||
const parsed = parseChangesetUri(channel);
|
||||
if (!parsed || parsed.kind !== ChangesetKind.Branch) {
|
||||
throw new Error(`Not a branch changeset URI: ${channel}`);
|
||||
}
|
||||
|
||||
const sessionState = this._stateManager.getSessionState(parsed.sessionUri);
|
||||
if (!sessionState) {
|
||||
throw new Error(`Session not found: ${parsed.sessionUri}`);
|
||||
}
|
||||
if (!sessionState.workingDirectory) {
|
||||
throw new Error(`Session has no working directory: ${parsed.sessionUri}`);
|
||||
}
|
||||
|
||||
const databaseRef = this._sessionDataService.openDatabase(URI.parse(parsed.sessionUri));
|
||||
let persistedBaseBranch: string | undefined;
|
||||
try {
|
||||
persistedBaseBranch = await databaseRef.object.getMetadata(META_DIFF_BASE_BRANCH);
|
||||
} finally {
|
||||
databaseRef.dispose();
|
||||
}
|
||||
|
||||
const workingDirectory = URI.parse(sessionState.workingDirectory);
|
||||
const baseBranch = resolveDiffBaseBranchName(persistedBaseBranch, readSessionGitState(sessionState._meta)?.baseBranchName);
|
||||
await this._sequencer.queue(parsed.sessionUri, async () => {
|
||||
for (const resource of resources) {
|
||||
await this._setReviewed(parsed.sessionUri, workingDirectory, baseBranch, URI.parse(resource), reviewed);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
markFileReviewed(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined, resource: URI): Promise<void> {
|
||||
return this._sequencer.queue(session, () => this._setReviewed(session, workingDirectory, baseBranch, resource, true));
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Disposable } from '../../../base/common/lifecycle.js';
|
||||
import { equals } from '../../../base/common/objects.js';
|
||||
import { ILogService } from '../../log/common/log.js';
|
||||
import { TelemetryLevel } from '../../telemetry/common/telemetry.js';
|
||||
import { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, ChatAction, RootAction, StateAction, TerminalAction, ChangesetAction, AnnotationsAction, ClientAnnotationsAction, isRootAction, isSessionAction, isChatAction, isChangesetAction, isAnnotationsAction, type AuthRequiredParams, type ProgressParams } from '../common/state/sessionActions.js';
|
||||
import { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, ChatAction, RootAction, StateAction, TerminalAction, ChangesetAction, ClientChangesetAction, AnnotationsAction, ClientAnnotationsAction, isRootAction, isSessionAction, isChatAction, isChangesetAction, isAnnotationsAction, type AuthRequiredParams, type ProgressParams } from '../common/state/sessionActions.js';
|
||||
import type { IStateSnapshot } from '../common/state/sessionProtocol.js';
|
||||
import { rootReducer, sessionReducer, chatReducer, changesetReducer, annotationsReducer } from '../common/state/sessionReducers.js';
|
||||
import { createRootState, createSessionState, createChatState, createDefaultChatSummary, chatSummaryFromState, buildDefaultChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, isAhpChatChannel, isDefaultChatUri, mergeSessionWithDefaultChat, isAhpRootChannel, SessionLifecycle, withHostBuildInfo, type Changeset, type ChangesetState, type AnnotationsState, type ChatState, type ChatSummary, type Customization, type ISessionWithDefaultChat, type Message, type RootState, type SessionConfigState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI, ChangesetStatus, IHostBuildInfo, SessionStatus } from '../common/state/sessionState.js';
|
||||
@@ -1123,7 +1123,7 @@ export class AgentHostStateManager extends Disposable {
|
||||
* The action is applied to state and emitted with the client's origin
|
||||
* so the originating client can reconcile.
|
||||
*/
|
||||
dispatchClientAction(channel: URI, action: SessionAction | ChatAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction, origin: ActionOrigin): unknown {
|
||||
dispatchClientAction(channel: URI, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction, origin: ActionOrigin): unknown {
|
||||
return this._applyAndEmit(channel, action, origin);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import { ILogService } from '../../log/common/log.js';
|
||||
import { AgentProvider, AgentSession, AgentSignal, AgentHostSessionReleaseGraceMsEnvVar, IAgent, IAgentChatDataChange, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentHostAuthTokenRequest, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSpawnChatEvent, AuthenticateParams, AuthenticateResult, IMcpNotification, IRestoredSubagentSession, SubagentChatSignal } from '../common/agentService.js';
|
||||
import { ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../common/sessionDataService.js';
|
||||
import { parseChangesetUri } from '../common/changesetUri.js';
|
||||
import { ActionType, ActionEnvelope, AuthRequiredReason, INotification, type ChatAction, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type ClientAnnotationsAction } from '../common/state/sessionActions.js';
|
||||
import { ActionType, ActionEnvelope, AuthRequiredReason, INotification, type ChatAction, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type ClientAnnotationsAction, type ClientChangesetAction } from '../common/state/sessionActions.js';
|
||||
import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js';
|
||||
import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js';
|
||||
import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, ResourceChangeType, ResourceType, ResourceWriteMode, type CreateResourceWatchParams, type CreateResourceWatchResult, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMkdirParams, type ResourceMkdirResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceResolveParams, type ResourceResolveResult, type ResourceWatchState, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js';
|
||||
@@ -70,7 +70,6 @@ import { GIT_DB_METADATA_KEYS, IAgentHostGitStateService, META_GIT_STATE, META_G
|
||||
import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js';
|
||||
import { AgentHostCommitOperationContribution } from './agentHostCommitOperationProvider.js';
|
||||
import { AgentHostDiscardChangesOperationContribution } from './agentHostDiscardChangesOperationProvider.js';
|
||||
import { AgentHostReviewOperationContribution } from './agentHostReviewOperationProvider.js';
|
||||
import { AgentHostPullRequestOperationContribution } from './agentHostPullRequestOperationProvider.js';
|
||||
import { AgentHostSyncOperationContribution } from './agentHostSyncOperationProvider.js';
|
||||
import { AgentHostReviewService } from './agentHostReviewService.js';
|
||||
@@ -215,6 +214,7 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
private readonly _changesetSubscriptions: IAgentHostChangesetSubscriptionService;
|
||||
/** Owns changeset operation contributions and handler activation. */
|
||||
private readonly _changesetOperationService: IAgentHostChangesetOperationService;
|
||||
private readonly _reviewService: IAgentHostReviewService;
|
||||
/** Owns AgentService-side orchestration of the changeset feature. */
|
||||
private readonly _changesetCoordinator: AgentHostChangesetCoordinator;
|
||||
/** Owns session git-state probing and git-backed catalogue decoration. */
|
||||
@@ -383,8 +383,8 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
services.set(IAgentHostChangesetOperationService, this._changesetOperationService);
|
||||
|
||||
// The changes review service is responsible for managing review/unreview state for changeset changes.
|
||||
const reviewService = this._register(instantiationService.createInstance(AgentHostReviewService, this._stateManager));
|
||||
services.set(IAgentHostReviewService, reviewService);
|
||||
this._reviewService = this._register(instantiationService.createInstance(AgentHostReviewService, this._stateManager));
|
||||
services.set(IAgentHostReviewService, this._reviewService);
|
||||
|
||||
// The changeset service is responsible for computing, publishing, and persisting changesets.
|
||||
this._changesets = this._register(instantiationService.createInstance(AgentHostChangesetService, this._stateManager));
|
||||
@@ -400,7 +400,6 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
this._register(this._changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostPullRequestOperationContribution, this._stateManager)));
|
||||
this._register(this._changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostSyncOperationContribution, this._stateManager)));
|
||||
this._register(this._changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostDiscardChangesOperationContribution, this._stateManager)));
|
||||
this._register(this._changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostReviewOperationContribution, this._stateManager)));
|
||||
|
||||
this._completions = this._register(instantiationService.createInstance(AgentHostCompletions));
|
||||
// Built-in generic provider: completes files in the session's workspace folder.
|
||||
@@ -1770,7 +1769,7 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
*/
|
||||
private readonly _clientDispatchQueues = new Map<string, Promise<void>>();
|
||||
|
||||
dispatchAction(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number): void {
|
||||
dispatchAction(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number): void {
|
||||
this._logService.trace(`[AgentService] dispatchAction: type=${action.type}, clientId=${clientId}, clientSeq=${clientSeq}`, action);
|
||||
|
||||
// Clients dispatch chat (chat) actions against a chat channel
|
||||
@@ -1787,9 +1786,17 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
return;
|
||||
}
|
||||
const next = (pending ?? Promise.resolve()).then(async () => {
|
||||
const rewritten: SessionAction | ChatAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction = this._needsAsyncRewrite(sessionChannel, action)
|
||||
const rewritten: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction = this._needsAsyncRewrite(sessionChannel, action)
|
||||
? await this._rewriteUserMessageAttachments(sessionChannel, action, clientId)
|
||||
: action;
|
||||
if (rewritten.type === ActionType.ChangesetFilesReviewChanged) {
|
||||
await this._reviewService.setReviewState(channel, rewritten.files, rewritten.reviewed);
|
||||
const changeset = parseChangesetUri(channel);
|
||||
if (!changeset) {
|
||||
throw new Error(`Invalid changeset URI: ${channel}`);
|
||||
}
|
||||
this._changesets.refreshBranchChangeset(changeset.sessionUri);
|
||||
}
|
||||
this._dispatchActionNow(channel, sessionChannel, rewritten, clientId, clientSeq);
|
||||
}).catch(err => {
|
||||
this._logService.error(`[AgentService] async dispatchAction failed: ${toErrorMessage(err)}`);
|
||||
@@ -1802,7 +1809,7 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
}));
|
||||
}
|
||||
|
||||
private _dispatchActionNow(channel: string, sessionChannel: string, action: SessionAction | ChatAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number): void {
|
||||
private _dispatchActionNow(channel: string, sessionChannel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number): void {
|
||||
const origin = { clientId, clientSeq };
|
||||
this._stateManager.dispatchClientAction(channel, action, origin);
|
||||
if (action.type === ActionType.RootConfigChanged) {
|
||||
@@ -1811,7 +1818,7 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
this._sideEffects.handleAction(channel, action, clientId);
|
||||
}
|
||||
|
||||
private _needsAsyncRewrite(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction): action is ChatTurnStartedAction | ChatPendingMessageSetAction {
|
||||
private _needsAsyncRewrite(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction): action is ChatTurnStartedAction | ChatPendingMessageSetAction {
|
||||
if (action.type !== ActionType.ChatTurnStarted && action.type !== ActionType.ChatPendingMessageSet) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@ import assert from 'assert';
|
||||
import { DisposableStore } from '../../../../base/common/lifecycle.js';
|
||||
import { URI } from '../../../../base/common/uri.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
|
||||
import { ActionType, type ActionEnvelope } from '../../common/state/sessionActions.js';
|
||||
import { MessageKind, SessionLifecycle, SessionStatus, TerminalClaimKind, TurnState, type RootState, type SessionState, type SessionSummary, type TerminalState } from '../../common/state/protocol/state.js';
|
||||
import { ActionType, type ActionEnvelope, type ClientChangesetAction } from '../../common/state/sessionActions.js';
|
||||
import { ChangesetStatus, MessageKind, SessionLifecycle, SessionStatus, TerminalClaimKind, TurnState, type ChangesetState, type RootState, type SessionState, type SessionSummary, type TerminalState } from '../../common/state/protocol/state.js';
|
||||
import { buildDefaultChatUri, createChatState, createDefaultChatSummary, ROOT_STATE_URI, StateComponents, type ChatState } from '../../common/state/sessionState.js';
|
||||
import { AgentSubscriptionManager, ChatStateSubscription, isActionEnvelopeRelevantToSubscriptionUris, RootStateSubscription, SessionStateSubscription, TerminalStateSubscription } from '../../common/state/agentSubscription.js';
|
||||
import { AgentSubscriptionManager, ChangesetStateSubscription, ChatStateSubscription, isActionEnvelopeRelevantToSubscriptionUris, RootStateSubscription, SessionStateSubscription, TerminalStateSubscription } from '../../common/state/agentSubscription.js';
|
||||
|
||||
// Helpers
|
||||
|
||||
@@ -81,6 +81,47 @@ const terminalUri = URI.from({ scheme: 'agenthost-terminal', path: '/term1' }).t
|
||||
const chatUri = buildDefaultChatUri(sessionUri);
|
||||
const changesetUri = `${sessionUri}/changeset/session`;
|
||||
|
||||
suite('ChangesetStateSubscription', () => {
|
||||
const disposables = ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
test('optimistically applies and reconciles file review state', () => {
|
||||
const state: ChangesetState = {
|
||||
status: ChangesetStatus.Ready,
|
||||
files: [{
|
||||
id: 'file:///test.txt',
|
||||
edit: {
|
||||
before: { uri: 'file:///test.txt', content: { uri: 'file:///before.txt' } },
|
||||
after: { uri: 'file:///test.txt', content: { uri: 'file:///after.txt' } },
|
||||
},
|
||||
}],
|
||||
};
|
||||
const subscription = disposables.add(new ChangesetStateSubscription(changesetUri, 'c1', () => 1, noop));
|
||||
subscription.handleSnapshot(state, 0);
|
||||
|
||||
const action: ClientChangesetAction = {
|
||||
type: ActionType.ChangesetFilesReviewChanged,
|
||||
files: ['file:///test.txt'],
|
||||
reviewed: true,
|
||||
};
|
||||
const clientSeq = subscription.applyOptimistic(action);
|
||||
const optimisticState = subscription.value as ChangesetState;
|
||||
subscription.receiveEnvelope(makeEnvelope(action, 1, { clientId: 'c1', clientSeq }));
|
||||
|
||||
assert.deepStrictEqual({
|
||||
optimisticReviewed: optimisticState.files[0].reviewed,
|
||||
verifiedBeforeEcho: state.files[0].reviewed,
|
||||
verifiedAfterEcho: subscription.verifiedValue?.files[0].reviewed,
|
||||
pendingCleared: subscription.value === subscription.verifiedValue,
|
||||
}, {
|
||||
optimisticReviewed: true,
|
||||
verifiedBeforeEcho: undefined,
|
||||
verifiedAfterEcho: true,
|
||||
pendingCleared: true,
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// RootStateSubscription
|
||||
|
||||
suite('RootStateSubscription', () => {
|
||||
@@ -551,7 +592,7 @@ suite('AgentSubscriptionManager', () => {
|
||||
|
||||
ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
function createManager(subscribe: (resource: URI) => Promise<{ resource: string; state: SessionState | TerminalState; fromSeq: number }> = async (resource) => {
|
||||
function createManager(subscribe: (resource: URI) => Promise<{ resource: string; state: SessionState | TerminalState | ChangesetState; fromSeq: number }> = async (resource) => {
|
||||
subscribedResources.push(resource.toString());
|
||||
const key = resource.toString();
|
||||
if (key.startsWith('copilot:')) {
|
||||
@@ -718,6 +759,40 @@ suite('AgentSubscriptionManager', () => {
|
||||
ref.dispose();
|
||||
});
|
||||
|
||||
test('dispatchOptimistic applies to matching changeset subscription', async () => {
|
||||
const state: ChangesetState = {
|
||||
status: ChangesetStatus.Ready,
|
||||
files: [{
|
||||
id: 'file:///test.txt',
|
||||
edit: {
|
||||
after: { uri: 'file:///test.txt', content: { uri: 'file:///after.txt' } },
|
||||
},
|
||||
}],
|
||||
};
|
||||
const mgr = createManager(async resource => ({ resource: resource.toString(), state, fromSeq: 0 }));
|
||||
const uri = URI.parse(changesetUri);
|
||||
const ref = mgr.getSubscription<ChangesetState>(StateComponents.Changeset, uri, 'test');
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
|
||||
const clientSeq = mgr.dispatchOptimistic(uri.toString(), {
|
||||
type: ActionType.ChangesetFilesReviewChanged,
|
||||
files: ['file:///test.txt'],
|
||||
reviewed: true,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual({
|
||||
clientSeq,
|
||||
optimisticReviewed: (ref.object.value as ChangesetState).files[0].reviewed,
|
||||
verifiedReviewed: ref.object.verifiedValue?.files[0].reviewed,
|
||||
}, {
|
||||
clientSeq: 1,
|
||||
optimisticReviewed: true,
|
||||
verifiedReviewed: undefined,
|
||||
});
|
||||
|
||||
ref.dispose();
|
||||
});
|
||||
|
||||
test('dispose clears all subscriptions and calls unsubscribe for each', async () => {
|
||||
const mgr = createManager();
|
||||
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 { CancellationToken } from '../../../../base/common/cancellation.js';
|
||||
import type { DisposableStore } from '../../../../base/common/lifecycle.js';
|
||||
import { URI } from '../../../../base/common/uri.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
|
||||
import { NullLogService } from '../../../log/common/log.js';
|
||||
import { buildBranchChangesetUri, buildSessionChangesetUri } from '../../common/changesetUri.js';
|
||||
import { META_DIFF_BASE_BRANCH } from '../../common/agentHostGitService.js';
|
||||
import type { IAgentHostReviewService } from '../../common/agentHostReviewService.js';
|
||||
import { ChangesetOperationTargetKind, type InvokeChangesetOperationParams } from '../../common/state/protocol/channels-changeset/commands.js';
|
||||
import { AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../../common/state/sessionProtocol.js';
|
||||
import { SessionStatus, } from '../../common/state/sessionState.js';
|
||||
import { AgentHostReviewFileOperationHandler } from '../../node/agentHostReviewFileOperationHandler.js';
|
||||
import { AgentHostStateManager } from '../../node/agentHostStateManager.js';
|
||||
import { createNoopChangesetService, createSessionDataService, TestSessionDatabase } from '../common/sessionTestHelpers.js';
|
||||
|
||||
interface IRecordedCall {
|
||||
readonly session: string;
|
||||
readonly workingDirectory: string;
|
||||
readonly baseBranch: string | undefined;
|
||||
readonly resource: string;
|
||||
}
|
||||
|
||||
class TestReviewService implements IAgentHostReviewService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
readonly markCalls: IRecordedCall[] = [];
|
||||
readonly unmarkCalls: IRecordedCall[] = [];
|
||||
error: Error | undefined;
|
||||
|
||||
async markFileReviewed(session: string, workingDirectory: URI, baseBranch: string | undefined, resource: URI): Promise<void> {
|
||||
this.markCalls.push({ session, workingDirectory: workingDirectory.toString(), baseBranch, resource: resource.toString() });
|
||||
if (this.error) { throw this.error; }
|
||||
}
|
||||
|
||||
async markFileUnreviewed(session: string, workingDirectory: URI, baseBranch: string | undefined, resource: URI): Promise<void> {
|
||||
this.unmarkCalls.push({ session, workingDirectory: workingDirectory.toString(), baseBranch, resource: resource.toString() });
|
||||
if (this.error) { throw this.error; }
|
||||
}
|
||||
|
||||
async getReviewedPaths(): Promise<ReadonlySet<string>> {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
async copyReviewedRef(): Promise<void> { }
|
||||
}
|
||||
|
||||
function makeResourceTarget(resource: URI): InvokeChangesetOperationParams['target'] {
|
||||
return { kind: ChangesetOperationTargetKind.Resource, resource: resource.toString() as unknown as InvokeChangesetOperationParams['channel'] };
|
||||
}
|
||||
|
||||
function setup(disposables: Pick<DisposableStore, 'add'>, opts?: { readonly reviewed?: boolean; readonly withWorkingDirectory?: boolean; readonly registerSession?: boolean; readonly baseBranch?: string }): { handler: AgentHostReviewFileOperationHandler; reviewService: TestReviewService; refreshedSessions: string[]; session: URI } {
|
||||
const reviewService = new TestReviewService();
|
||||
const database = new TestSessionDatabase();
|
||||
if (opts?.baseBranch) {
|
||||
database.setMetadata(META_DIFF_BASE_BRANCH, opts.baseBranch);
|
||||
}
|
||||
const sessionDataService = createSessionDataService(database);
|
||||
const refreshedSessions: string[] = [];
|
||||
const changesetService = createNoopChangesetService();
|
||||
changesetService.refreshBranchChangeset = session => { refreshedSessions.push(session); };
|
||||
const stateManager = disposables.add(new AgentHostStateManager(new NullLogService()));
|
||||
const session = URI.parse('agent:/session');
|
||||
if (opts?.registerSession !== false) {
|
||||
stateManager.createSession({
|
||||
resource: session.toString(),
|
||||
provider: 'copilot',
|
||||
title: 'Session',
|
||||
status: SessionStatus.Idle,
|
||||
createdAt: new Date(1).toISOString(),
|
||||
modifiedAt: new Date(1).toISOString(),
|
||||
workingDirectory: opts?.withWorkingDirectory === false ? undefined : URI.file('/repo').toString(),
|
||||
});
|
||||
}
|
||||
const handler = new AgentHostReviewFileOperationHandler(
|
||||
opts?.reviewed ?? true,
|
||||
sessionKey => stateManager.getSessionState(sessionKey),
|
||||
reviewService,
|
||||
changesetService,
|
||||
sessionDataService,
|
||||
new NullLogService(),
|
||||
);
|
||||
return { handler, reviewService, refreshedSessions, session };
|
||||
}
|
||||
|
||||
suite('AgentHostReviewFileOperationHandler', () => {
|
||||
const disposables = ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
test('marks the targeted file as reviewed, passing the resolved base branch', async () => {
|
||||
const { handler, reviewService, refreshedSessions, session } = setup(disposables, { reviewed: true, baseBranch: 'main' });
|
||||
const target = URI.file('/repo/src/file.ts');
|
||||
|
||||
const result = await handler.invoke({
|
||||
channel: buildBranchChangesetUri(session.toString()),
|
||||
operationId: AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_REVIEWED,
|
||||
target: makeResourceTarget(target),
|
||||
}, CancellationToken.None);
|
||||
|
||||
assert.deepStrictEqual({
|
||||
markCalls: reviewService.markCalls,
|
||||
unmarkCalls: reviewService.unmarkCalls,
|
||||
refreshedSessions,
|
||||
message: result.message,
|
||||
}, {
|
||||
markCalls: [{ session: session.toString(), workingDirectory: URI.file('/repo').toString(), baseBranch: 'main', resource: target.toString() }],
|
||||
unmarkCalls: [],
|
||||
refreshedSessions: [session.toString()],
|
||||
message: { markdown: 'Marked `file.ts` as reviewed.' },
|
||||
});
|
||||
});
|
||||
|
||||
test('clears the reviewed mark for the targeted file', async () => {
|
||||
const { handler, reviewService, refreshedSessions, session } = setup(disposables, { reviewed: false });
|
||||
const target = URI.file('/repo/src/file.ts');
|
||||
|
||||
const result = await handler.invoke({
|
||||
channel: buildBranchChangesetUri(session.toString()),
|
||||
operationId: AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_UNREVIEWED,
|
||||
target: makeResourceTarget(target),
|
||||
}, CancellationToken.None);
|
||||
|
||||
assert.deepStrictEqual({
|
||||
markCalls: reviewService.markCalls,
|
||||
unmarkCalls: reviewService.unmarkCalls,
|
||||
refreshedSessions,
|
||||
message: result.message,
|
||||
}, {
|
||||
markCalls: [],
|
||||
unmarkCalls: [{ session: session.toString(), workingDirectory: URI.file('/repo').toString(), baseBranch: undefined, resource: target.toString() }],
|
||||
refreshedSessions: [session.toString()],
|
||||
message: { markdown: 'Removed the reviewed mark from `file.ts`.' },
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects channels that are not branch-changeset URIs', async () => {
|
||||
const { handler, reviewService, session } = setup(disposables);
|
||||
|
||||
let err: ProtocolError | undefined;
|
||||
try {
|
||||
await handler.invoke({
|
||||
channel: buildSessionChangesetUri(session.toString()),
|
||||
operationId: AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_REVIEWED,
|
||||
target: makeResourceTarget(URI.file('/repo/src/file.ts')),
|
||||
}, CancellationToken.None);
|
||||
} catch (error) {
|
||||
err = error as ProtocolError;
|
||||
}
|
||||
|
||||
assert.deepStrictEqual({ code: err?.code, marks: reviewService.markCalls.length }, { code: JsonRpcErrorCodes.InvalidParams, marks: 0 });
|
||||
});
|
||||
|
||||
test('throws AHP_SESSION_NOT_FOUND when the session is unknown', async () => {
|
||||
const { handler, reviewService } = setup(disposables, { registerSession: false });
|
||||
const session = URI.parse('agent:/missing');
|
||||
|
||||
let err: ProtocolError | undefined;
|
||||
try {
|
||||
await handler.invoke({
|
||||
channel: buildBranchChangesetUri(session.toString()),
|
||||
operationId: AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_REVIEWED,
|
||||
target: makeResourceTarget(URI.file('/repo/src/file.ts')),
|
||||
}, CancellationToken.None);
|
||||
} catch (error) {
|
||||
err = error as ProtocolError;
|
||||
}
|
||||
|
||||
assert.deepStrictEqual({ code: err?.code, marks: reviewService.markCalls.length }, { code: AHP_SESSION_NOT_FOUND, marks: 0 });
|
||||
});
|
||||
|
||||
test('rejects invocations without a Resource target', async () => {
|
||||
const { handler, reviewService, session } = setup(disposables);
|
||||
|
||||
let err: ProtocolError | undefined;
|
||||
try {
|
||||
await handler.invoke({
|
||||
channel: buildBranchChangesetUri(session.toString()),
|
||||
operationId: AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_REVIEWED,
|
||||
target: undefined,
|
||||
}, CancellationToken.None);
|
||||
} catch (error) {
|
||||
err = error as ProtocolError;
|
||||
}
|
||||
|
||||
assert.deepStrictEqual({ code: err?.code, marks: reviewService.markCalls.length }, { code: JsonRpcErrorCodes.InvalidParams, marks: 0 });
|
||||
});
|
||||
});
|
||||
@@ -208,6 +208,8 @@ On the agent host, workspace-less is **inferred from an absent `workingDirectory
|
||||
|
||||
Sessions produce file changes organized into **`ISessionChangeset`** groups — named, togglable collections of file modifications that let users review and selectively apply changes.
|
||||
|
||||
Review-capable changesets expose `setReviewState(resource, reviewed)`. Agent-host changesets dispatch the client-originated `changeset/filesReviewChanged` action to the changeset channel, where the subscription applies it optimistically and reconciles it with the server echo.
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
@@ -23,6 +23,7 @@ import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextke
|
||||
import { ISessionChangesService, SessionChangesService } from './sessionChangesService.js';
|
||||
import './changesActions.js';
|
||||
import './changesViewActions.js';
|
||||
import './changesetReviewActions.js';
|
||||
import './checksActions.js';
|
||||
import './media/multiFileDiffEditor.css';
|
||||
import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js';
|
||||
@@ -33,8 +34,6 @@ import { AccessibleViewRegistry } from '../../../../platform/accessibility/brows
|
||||
import { SessionsChangesAccessibilityHelp } from './sessionsChangesAccessibilityHelp.js';
|
||||
import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js';
|
||||
|
||||
registerSingleton(ISessionChangesService, SessionChangesService, InstantiationType.Delayed);
|
||||
|
||||
/**
|
||||
* Registers the custom single-pane Changes editor (multi-diff pane with the header
|
||||
* toolbar) and its serializer, only when the single-pane layout is enabled. In the
|
||||
@@ -145,3 +144,4 @@ Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).regis
|
||||
});
|
||||
|
||||
registerSingleton(IChangesViewService, ChangesViewService, InstantiationType.Delayed);
|
||||
registerSingleton(ISessionChangesService, SessionChangesService, InstantiationType.Delayed);
|
||||
|
||||
@@ -36,7 +36,6 @@ import { IChangesViewService } from '../common/changesViewService.js';
|
||||
import { ChangesMultiDiffSourceResolver, SessionChangesFileResourceContext, SessionChangesReviewedFilesContext } from './changesMultiDiffSourceResolver.js';
|
||||
import { ISessionChangesService } from './sessionChangesService.js';
|
||||
import { SessionChangesEditor } from './sessionChangesEditor.js';
|
||||
import { isEqual } from '../../../../base/common/resources.js';
|
||||
import { VIEW_SESSION_CHANGES_COMMAND_ID } from '../common/changes.js';
|
||||
|
||||
// --- View All Changes action
|
||||
@@ -454,31 +453,11 @@ class ChangesetOperationsActionControllerContribution extends Disposable impleme
|
||||
SessionChangesReviewedFilesContext.key),
|
||||
menu: [{
|
||||
id: MenuId.AgentsChangeInlineToolbar,
|
||||
// This is a temporary solution until the agent host protocol
|
||||
// adds support to specify operations for each individual file
|
||||
when: operation.group === 'review'
|
||||
? ContextKeyExpr.false()
|
||||
: ContextKeyExpr.true(),
|
||||
group: 'navigation',
|
||||
order: 100
|
||||
},
|
||||
{
|
||||
id: MenuId.MultiDiffEditorFileToolbar,
|
||||
// This is a temporary solution until the agent host protocol
|
||||
// adds support to specify operations for each individual file
|
||||
when: operation.group === 'review'
|
||||
? operation.id === 'mark-as-reviewed'
|
||||
? ContextKeyExpr.and(
|
||||
ContextKeyExpr.equals('resourceScheme', 'changes-multi-diff-source'),
|
||||
ContextKeyExpr.notIn(
|
||||
SessionChangesFileResourceContext.key,
|
||||
SessionChangesReviewedFilesContext.key))
|
||||
: ContextKeyExpr.and(
|
||||
ContextKeyExpr.equals('resourceScheme', 'changes-multi-diff-source'),
|
||||
ContextKeyExpr.in(
|
||||
SessionChangesFileResourceContext.key,
|
||||
SessionChangesReviewedFilesContext.key))
|
||||
: ContextKeyExpr.equals('resourceScheme', 'changes-multi-diff-source'),
|
||||
group: 'navigation',
|
||||
order: 100
|
||||
}]
|
||||
@@ -486,8 +465,6 @@ class ChangesetOperationsActionControllerContribution extends Disposable impleme
|
||||
}
|
||||
|
||||
async run(accessor: ServicesAccessor, ...args: unknown[]): Promise<void> {
|
||||
const activeEditorPane = accessor.get(IEditorService).activeEditorPane;
|
||||
|
||||
// The Changes view provides the resource as the third argument (uses a
|
||||
// custom action runner) while the multi-file diff editor provides the
|
||||
// resource as the first argument.
|
||||
@@ -496,28 +473,6 @@ class ChangesetOperationsActionControllerContribution extends Disposable impleme
|
||||
return;
|
||||
}
|
||||
|
||||
// Optimistic update the state
|
||||
if (operation.id === 'mark-as-reviewed') {
|
||||
// Update context key for the toolbar
|
||||
const agentHostReviewedFiles = agentHostReviewedFilesObs.read(undefined);
|
||||
clientReviewedFilesObs.set([...agentHostReviewedFiles, resource.toString()], undefined);
|
||||
|
||||
// Collapse multi-file diff editor item
|
||||
if (activeEditorPane instanceof MultiDiffEditor) {
|
||||
const viewModel = activeEditorPane.viewModel;
|
||||
const item = viewModel?.items.read(undefined)
|
||||
.find(i => isEqual(i.modifiedUri, resource) || isEqual(i.originalUri, resource));
|
||||
|
||||
if (item) {
|
||||
viewModel!.collapse(item);
|
||||
}
|
||||
}
|
||||
} else if (operation.id === 'mark-as-unreviewed') {
|
||||
// Update context key for the toolbar
|
||||
const agentHostReviewedFiles = agentHostReviewedFilesObs.read(undefined);
|
||||
clientReviewedFilesObs.set([...agentHostReviewedFiles.filter(f => f !== resource.toString())], undefined);
|
||||
}
|
||||
|
||||
await changeset?.invokeOperation(operation.id, {
|
||||
kind: 'resource',
|
||||
resource,
|
||||
|
||||
@@ -9,6 +9,8 @@ import { Disposable } from '../../../../base/common/lifecycle.js';
|
||||
import { autorun, derived, derivedObservableWithCache, derivedOpts, IObservable, ISettableObservable, observableSignalFromEvent, observableValue } from '../../../../base/common/observable.js';
|
||||
import { isEqual } from '../../../../base/common/resources.js';
|
||||
import { URI } from '../../../../base/common/uri.js';
|
||||
import { IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js';
|
||||
import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js';
|
||||
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
|
||||
import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js';
|
||||
import { ISessionChangeset, ISessionChangesetOperation, ISessionFileChange } from '../../../services/sessions/common/session.js';
|
||||
@@ -17,6 +19,9 @@ import { ICodeReviewService, PRReviewStateKind } from '../../codeReview/browser/
|
||||
import { ChangesViewMode, IsolationMode } from '../common/changes.js';
|
||||
import { ActiveSessionState, IChangesViewService } from '../common/changesViewService.js';
|
||||
|
||||
export const ChangesetReviewSupportContext = new RawContextKey<boolean>('sessions.changesetReviewSupport', false);
|
||||
export const ChangesetReviewedFilesContext = new RawContextKey<string[]>('sessions.changesetReviewedFiles', []);
|
||||
|
||||
export class ChangesViewService extends Disposable implements IChangesViewService {
|
||||
|
||||
declare readonly _serviceBrand: undefined;
|
||||
@@ -54,6 +59,7 @@ export class ChangesViewService extends Disposable implements IChangesViewServic
|
||||
constructor(
|
||||
@IAgentFeedbackService private readonly agentFeedbackService: IAgentFeedbackService,
|
||||
@ICodeReviewService private readonly codeReviewService: ICodeReviewService,
|
||||
@IContextKeyService private readonly contextKeyService: IContextKeyService,
|
||||
@ISessionsService private readonly sessionsService: ISessionsService,
|
||||
@IStorageService private readonly storageService: IStorageService,
|
||||
) {
|
||||
@@ -174,6 +180,22 @@ export class ChangesViewService extends Disposable implements IChangesViewServic
|
||||
this.activeSessionResourceObs.read(reader);
|
||||
this.setChangesetId(undefined);
|
||||
}));
|
||||
|
||||
// Global context keys
|
||||
this._bindContextKeys();
|
||||
}
|
||||
|
||||
setChangesetFilesReviewState(resources: readonly URI[], reviewed: boolean): void {
|
||||
if (resources.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const changeset = this.activeSessionChangesetObs.get();
|
||||
if (!changeset || !changeset.setReviewState) {
|
||||
return;
|
||||
}
|
||||
|
||||
changeset.setReviewState(resources, reviewed);
|
||||
}
|
||||
|
||||
private _getActiveSessionState(): IObservable<ActiveSessionState | undefined> {
|
||||
@@ -282,4 +304,20 @@ export class ChangesViewService extends Disposable implements IChangesViewServic
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
private _bindContextKeys(): void {
|
||||
this._register(bindContextKey<boolean>(ChangesetReviewSupportContext, this.contextKeyService, reader => {
|
||||
const changeset = this.activeSessionChangesetObs.read(reader);
|
||||
return changeset?.capabilities?.review === true;
|
||||
}));
|
||||
|
||||
this._register(bindContextKey<string[]>(ChangesetReviewedFilesContext, this.contextKeyService, reader => {
|
||||
const changes = this.activeSessionChangesObs.read(reader);
|
||||
|
||||
return changes
|
||||
.filter(change => change.reviewed)
|
||||
.map(change => change.modifiedUri?.toString() ?? change.originalUri?.toString())
|
||||
.filter((uri: string | undefined) => uri !== undefined);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Codicon } from '../../../../base/common/codicons.js';
|
||||
import { URI } from '../../../../base/common/uri.js';
|
||||
import { localize } from '../../../../nls.js';
|
||||
import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js';
|
||||
import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js';
|
||||
import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
|
||||
import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js';
|
||||
import { IChangesViewService } from '../common/changesViewService.js';
|
||||
import { SessionChangesFileResourceContext } from './changesMultiDiffSourceResolver.js';
|
||||
import { ChangesetReviewedFilesContext, ChangesetReviewSupportContext } from './changesViewService.js';
|
||||
import { SessionChangesEditor } from './sessionChangesEditor.js';
|
||||
|
||||
export class ChangesetReviewAction extends Action2 {
|
||||
constructor() {
|
||||
super({
|
||||
id: 'changeset.review',
|
||||
title: localize('changeset.viewed', "Mark as Viewed"),
|
||||
icon: Codicon.check,
|
||||
f1: false,
|
||||
toggled: {
|
||||
title: localize('changeset.notviewed', "Mark as Not Viewed"),
|
||||
condition: ContextKeyExpr.in(
|
||||
SessionChangesFileResourceContext.key,
|
||||
ChangesetReviewedFilesContext.key)
|
||||
},
|
||||
menu: {
|
||||
id: MenuId.MultiDiffEditorFileToolbar,
|
||||
when: ContextKeyExpr.and(
|
||||
ChangesetReviewSupportContext.isEqualTo(true),
|
||||
ContextKeyExpr.equals('resourceScheme', 'changes-multi-diff-source')
|
||||
),
|
||||
group: 'navigation',
|
||||
order: 100
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
override run(accessor: ServicesAccessor, ...args: unknown[]): void {
|
||||
const resource = args[0];
|
||||
if (!(resource instanceof URI)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const changesViewService = accessor.get(IChangesViewService);
|
||||
const activeEditorPane = accessor.get(IEditorService).activeEditorPane;
|
||||
|
||||
const reviewedFiles = changesViewService.activeSessionChangesObs.get()
|
||||
.filter(change => change.reviewed)
|
||||
.map(change => change.modifiedUri?.toString() ?? change.originalUri?.toString())
|
||||
.filter((uri: string | undefined) => uri !== undefined);
|
||||
|
||||
const review = !reviewedFiles.includes(resource.toString());
|
||||
|
||||
// Toggle multi-file diff editor item
|
||||
if (activeEditorPane instanceof SessionChangesEditor) {
|
||||
if (review) {
|
||||
activeEditorPane.collapse(resource);
|
||||
} else {
|
||||
activeEditorPane.expand(resource);
|
||||
}
|
||||
}
|
||||
|
||||
// Set the review state
|
||||
changesViewService.setChangesetFilesReviewState([resource], review);
|
||||
}
|
||||
}
|
||||
|
||||
registerAction2(ChangesetReviewAction);
|
||||
@@ -39,6 +39,7 @@ import { ActiveSessionContextKeys } from '../common/changes.js';
|
||||
import { IChangesViewService } from '../common/changesViewService.js';
|
||||
import { ChangesActionsBar, ChangesActionsBarActionViewItem, CHANGES_HEADER_ACTIONS_ID } from './changesView.js';
|
||||
import { SessionChangesEditorInput } from './sessionChangesEditorInput.js';
|
||||
import { isEqual } from '../../../../base/common/resources.js';
|
||||
import { IAction } from '../../../../base/common/actions.js';
|
||||
import { IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js';
|
||||
import { IActionViewItem } from '../../../../base/browser/ui/actionbar/actionbar.js';
|
||||
@@ -282,6 +283,27 @@ export class SessionChangesEditor extends AbstractEditorWithViewState<IMultiDiff
|
||||
this.viewModel?.expandAll();
|
||||
}
|
||||
|
||||
public collapse(resource: URI): void {
|
||||
const item = this.viewModel?.items.read(undefined)
|
||||
.find(i => isEqual(i.modifiedUri, resource) || isEqual(i.originalUri, resource));
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.viewModel?.collapse(item);
|
||||
}
|
||||
|
||||
public expand(resource: URI): void {
|
||||
const item = this.viewModel?.items.read(undefined)
|
||||
.find(i => isEqual(i.modifiedUri, resource) || isEqual(i.originalUri, resource));
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.viewModel?.expand(item);
|
||||
}
|
||||
|
||||
|
||||
override setOptions(options: IMultiDiffEditorOptions | undefined): void {
|
||||
this._applyOptions(options);
|
||||
}
|
||||
|
||||
@@ -50,4 +50,6 @@ export interface IChangesViewService {
|
||||
|
||||
readonly viewModeObs: IObservable<ChangesViewMode>;
|
||||
setViewMode(mode: ChangesViewMode): void;
|
||||
|
||||
setChangesetFilesReviewState(resources: readonly URI[], reviewed: boolean): void;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,8 @@ export function diffToChange(file: ChangesetFile, mapUri?: (uri: URI) => URI): I
|
||||
// fetch the snapshot of the file *before* the session's edits.
|
||||
const originalUri = normalized.beforeContentUri ? map(normalized.beforeContentUri) : undefined;
|
||||
|
||||
// Extract reviewed status from meta
|
||||
// Extract reviewed status from meta. We
|
||||
// do this for backward compatibility.
|
||||
const meta = readChangesetFileMeta(file);
|
||||
|
||||
return {
|
||||
@@ -65,7 +66,7 @@ export function diffToChange(file: ChangesetFile, mapUri?: (uri: URI) => URI): I
|
||||
originalUri,
|
||||
insertions: file.edit?.diff?.added ?? 0,
|
||||
deletions: file.edit?.diff?.removed ?? 0,
|
||||
reviewed: meta?.reviewed
|
||||
reviewed: file.reviewed ?? meta?.reviewed
|
||||
} satisfies IChatSessionFileChange2;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,9 +14,10 @@ import { URI } from '../../../../../base/common/uri.js';
|
||||
import { localize } from '../../../../../nls.js';
|
||||
import { ChangesetOperationTargetKind } from '../../../../../platform/agentHost/common/state/protocol/channels-changeset/commands.js';
|
||||
import { ChangesetOperation, ChangesetOperationScope, type ChangesetFile, ChangesetOperationStatus } from '../../../../../platform/agentHost/common/state/protocol/state.js';
|
||||
import { ActionType } from '../../../../../platform/agentHost/common/state/sessionActions.js';
|
||||
import { buildDefaultChatUri, ChangesetStatus, Changeset, StateComponents, type ChangesetState, type ChatState, type ChatSummary, type SessionState } from '../../../../../platform/agentHost/common/state/sessionState.js';
|
||||
import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js';
|
||||
import { ISessionChangeset, ISessionChangesetOperation, ISessionChangesetOperationTarget, ISessionFileChange, SessionChangesetOperationScope, SessionChangesetOperationStatus, sessionFileChangesEqual } from '../../../../services/sessions/common/session.js';
|
||||
import { ISessionChangeset, ISessionChangesetCapabilities, ISessionChangesetOperation, ISessionChangesetOperationTarget, ISessionFileChange, SessionChangesetOperationScope, SessionChangesetOperationStatus, sessionFileChangesEqual } from '../../../../services/sessions/common/session.js';
|
||||
import { changesetFileToChange } from './agentHostDiffs.js';
|
||||
import { IAgentHostAdapterOptions } from './baseAgentHostSessionsProvider.js';
|
||||
|
||||
@@ -174,13 +175,21 @@ abstract class AbstractAgentHostChangeset implements ISessionChangeset {
|
||||
readonly changes: IObservable<readonly ISessionFileChange[]>;
|
||||
readonly operations: IObservable<readonly ISessionChangesetOperation[]>;
|
||||
|
||||
readonly capabilities: ISessionChangesetCapabilities;
|
||||
|
||||
protected abstract readonly channelUriObs: IObservable<URI | undefined>;
|
||||
protected abstract readonly changesetStateObs: IObservable<IObservable<ChangesetState | Error | undefined | null>>;
|
||||
private readonly _changesetFilesObs: IObservable<readonly ChangesetFile[] | undefined>;
|
||||
|
||||
constructor(
|
||||
changeset: Changeset,
|
||||
private readonly _options: IAgentHostAdapterOptions,
|
||||
private readonly _dialogService: IDialogService,
|
||||
) {
|
||||
this.capabilities = {
|
||||
review: changeset.capabilities?.review !== undefined
|
||||
} satisfies ISessionChangesetCapabilities;
|
||||
|
||||
this.isLoadingChanges = derived(reader => {
|
||||
const changesetState = this.changesetStateObs.read(reader).read(reader);
|
||||
|
||||
@@ -207,7 +216,7 @@ abstract class AbstractAgentHostChangeset implements ISessionChangeset {
|
||||
// Hold the raw `ChangesetFile[]` (with last-value semantics) so unchanged
|
||||
// files keep their reference across reducer updates, enabling the
|
||||
// per-file cache below to skip rebuilding them.
|
||||
const changesetFilesObs = derivedObservableWithCache<readonly ChangesetFile[] | undefined>(this, (reader, lastValue) => {
|
||||
this._changesetFilesObs = derivedObservableWithCache<readonly ChangesetFile[] | undefined>(this, (reader, lastValue) => {
|
||||
const changesetState = this.changesetStateObs.read(reader).read(reader);
|
||||
if (changesetState === null || changesetState instanceof Error) {
|
||||
return [];
|
||||
@@ -231,7 +240,7 @@ abstract class AbstractAgentHostChangeset implements ISessionChangeset {
|
||||
// `ChangesetFile` reference is unchanged so only changed files are
|
||||
// re-parsed and re-mapped.
|
||||
const mappedChangesObs = mapObservableArrayCached(this,
|
||||
changesetFilesObs.map(files => files ?? []),
|
||||
this._changesetFilesObs.map(files => files ?? []),
|
||||
file => changesetFileToChange(file, mapDiffUri));
|
||||
|
||||
const changesObs = derived<readonly ISessionFileChange[] | undefined>(this, reader => {
|
||||
@@ -299,6 +308,39 @@ abstract class AbstractAgentHostChangeset implements ISessionChangeset {
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
setReviewState(resources: readonly URI[], reviewed: boolean): void {
|
||||
if (!this.capabilities.review) {
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = this._options.getConnection();
|
||||
const channel = this.channelUriObs.get();
|
||||
if (!connection || !channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = resources.map(resource => {
|
||||
const file = this._changesetFilesObs.get()?.find(candidate => {
|
||||
const change = changesetFileToChange(candidate, this._options.mapDiffUri);
|
||||
return isEqual(change?.modifiedUri, resource) || isEqual(change?.originalUri, resource);
|
||||
});
|
||||
if (!file) {
|
||||
throw new Error(`Resource '${resource.toString()}' is not part of changeset '${this.id}'`);
|
||||
}
|
||||
return file.id;
|
||||
});
|
||||
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
connection.dispatch(channel.toString(), {
|
||||
type: ActionType.ChangesetFilesReviewChanged,
|
||||
files,
|
||||
reviewed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class AgentHostChangeset extends AbstractAgentHostChangeset {
|
||||
@@ -322,7 +364,7 @@ class AgentHostChangeset extends AbstractAgentHostChangeset {
|
||||
changesetSummary: Changeset & { isDefault: boolean },
|
||||
@IDialogService dialogService: IDialogService,
|
||||
) {
|
||||
super(options, dialogService);
|
||||
super(changesetSummary, options, dialogService);
|
||||
|
||||
this.channelUriObs = constObservable(URI.parse(changesetSummary.uriTemplate));
|
||||
|
||||
@@ -359,7 +401,7 @@ class AgentHostLastTurnChangeset extends AbstractAgentHostChangeset {
|
||||
changesetSummary: Changeset & { isDefault: boolean },
|
||||
@IDialogService dialogService: IDialogService,
|
||||
) {
|
||||
super(options, dialogService);
|
||||
super(changesetSummary, options, dialogService);
|
||||
|
||||
this.id = changesetSummary.changeKind;
|
||||
|
||||
|
||||
@@ -267,6 +267,9 @@ export interface ISessionChangeset {
|
||||
readonly originalCheckpointRef: IObservable<string | undefined>;
|
||||
/** Reference to the modified checkpoint for this changeset. */
|
||||
readonly modifiedCheckpointRef: IObservable<string | undefined>;
|
||||
/** The capabilities of this changeset. */
|
||||
readonly capabilities?: ISessionChangesetCapabilities;
|
||||
|
||||
/**
|
||||
* Invoke an operation declared in {@link operations}. `target` must be
|
||||
* provided for resource-scoped operations and omitted for changeset-
|
||||
@@ -274,6 +277,11 @@ export interface ISessionChangeset {
|
||||
* the corresponding {@link ISessionChangesetOperation.scopes}.
|
||||
*/
|
||||
invokeOperation(operationId: string, target?: ISessionChangesetOperationTarget): Promise<void>;
|
||||
|
||||
/**
|
||||
* Sets the review state for a list of resources when the changeset supports review.
|
||||
*/
|
||||
setReviewState?(resources: readonly URI[], reviewed: boolean): void;
|
||||
}
|
||||
|
||||
export type ISessionChangesetOperationTarget =
|
||||
@@ -326,6 +334,11 @@ export interface ISessionChangesetOperation {
|
||||
readonly confirmation?: string | IMarkdownString;
|
||||
}
|
||||
|
||||
export interface ISessionChangesetCapabilities {
|
||||
/** Whether the changeset supports review workflow. */
|
||||
readonly review?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A custom agent reference used by session-level selection. Mirrors the Agent
|
||||
* Host protocol's `AgentSelection` shape but lives in the sessions layer so the
|
||||
|
||||
@@ -123,6 +123,8 @@ class FixtureChangesViewService extends Disposable implements IChangesViewServic
|
||||
|
||||
setChangesetId(_changesetId: string | undefined): void { }
|
||||
|
||||
setChangesetFilesReviewState(_resources: readonly URI[], _reviewed: boolean): void { }
|
||||
|
||||
setViewMode(mode: ChangesViewMode): void {
|
||||
this.viewModeObs.set(mode, undefined);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user