Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions extensions/copilot/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
"onStartupFinished",
"onLanguageModelChat:copilot",
"onUri",
"onCommand:_github.copilot.chat.reportModelFeedbackSurvey",
"onFileSystem:ccreq",
"onFileSystem:ccsettings"
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { asContributionFactory, IExtensionContributionFactory } from '../../comm
import * as contextContribution from '../../context/vscode/context.contribution';
import { LifecycleTelemetryContrib } from '../../telemetry/common/lifecycleTelemetryContrib';
import { GithubTelemetryForwardingContrib } from '../../telemetry/vscode/githubTelemetryForwardingContrib';
import { ChatModelFeedbackSurveyForwardingContrib } from '../../telemetry/vscode/chatModelFeedbackSurveyForwardingContrib';

// ###############################################################################
// ### ###
Expand All @@ -21,6 +22,7 @@ const vscodeContributions: IExtensionContributionFactory[] = [
asContributionFactory(LifecycleTelemetryContrib),
asContributionFactory(NesActivationTelemetryContribution),
asContributionFactory(GithubTelemetryForwardingContrib),
asContributionFactory(ChatModelFeedbackSurveyForwardingContrib),
contextContribution,
];

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { commands } from 'vscode';
import { ITelemetryService, TelemetryEventMeasurements, TelemetryEventProperties } from '../../../platform/telemetry/common/telemetry';
import { Disposable } from '../../../util/vs/base/common/lifecycle';
import { IExtensionContribution } from '../../common/contributions';

/**
* Command the workbench invokes to report inline model feedback survey results.
*
* Keep in sync with `CHAT_MODEL_FEEDBACK_SURVEY_TELEMETRY_COMMAND_ID` in
* `src/vs/workbench/contrib/chat/common/feedbackSurvey/chatModelFeedbackSurveyTelemetry.ts`.
*/
const REPORT_SURVEY_COMMAND_ID = '_github.copilot.chat.reportModelFeedbackSurvey';

const TELEMETRY_EVENT_NAME = 'vscode.chatModelFeedbackSurvey';

const KNOWN_EVENT_KINDS: readonly string[] = ['shown', 'opened', 'step', 'submitted', 'dismissed'];

/** Mirrors the workbench payload in `chatModelFeedbackSurveyTelemetry.ts`. */
interface IChatModelFeedbackSurveyTelemetryEvent {
readonly kind: 'shown' | 'opened' | 'step' | 'submitted' | 'dismissed';
readonly surveyId: string;
readonly surveyInstanceId: string;
readonly stepCount: number;
readonly trigger?: 'manual' | 'chance' | 'modelSwitchedAway';
readonly stepId?: string;
readonly stepIndex?: number;
readonly answerId?: string;
readonly comment?: string;
readonly modelId?: string;
readonly resolvedModelId?: string;
readonly modeId?: string;
readonly harness?: string;
readonly sessionType?: string;
readonly requestId: string;
}

/**
* Forwards inline model feedback survey results to GitHub restricted telemetry.
*
* The workbench cannot reach that endpoint, so it hands each result over as a command, which
* also activates this extension so early results are not lost. Every event is sent enhanced,
* including the ones carrying no answer, so the whole funnel shares one consent boundary. When
* the user has not opted in to restricted telemetry the send is a no op.
*
* Core already has a sender for the same table in
* `src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts`, using the same enhanced
* ingestion key. It is node layer and lives in the agent host process with no channel to the
* renderer, so it cannot serve workbench events today. Exposing it to the renderer would let
* this contribution and `GithubTelemetryForwardingContrib` both go away.
*/
export class ChatModelFeedbackSurveyForwardingContrib extends Disposable implements IExtensionContribution {

constructor(
@ITelemetryService private readonly _telemetryService: ITelemetryService,
) {
super();

this._register(commands.registerCommand(REPORT_SURVEY_COMMAND_ID, (event: unknown) => {
this._report(event);
}));
}

private _report(event: unknown): void {
if (!isSurveyEvent(event)) {
return;
}

const properties: Record<string, string> = {
kind: event.kind,
surveyId: event.surveyId,
surveyInstanceId: event.surveyInstanceId,
};
const measurements: Record<string, number> = {
stepCount: event.stepCount,
};

addProperty(properties, 'trigger', event.trigger);
addProperty(properties, 'stepId', event.stepId);
addProperty(properties, 'answerId', event.answerId);
addProperty(properties, 'comment', event.comment);
addProperty(properties, 'modelId', event.modelId);
addProperty(properties, 'resolvedModelId', event.resolvedModelId);
addProperty(properties, 'modeId', event.modeId);
addProperty(properties, 'harness', event.harness);
addProperty(properties, 'sessionType', event.sessionType);
addProperty(properties, 'requestId', event.requestId);

if (typeof event.stepIndex === 'number') {
measurements.stepIndex = event.stepIndex;
}

const telemetryProperties: TelemetryEventProperties = properties;
const telemetryMeasurements: TelemetryEventMeasurements = measurements;
this._telemetryService.sendEnhancedGHTelemetryEvent(TELEMETRY_EVENT_NAME, telemetryProperties, telemetryMeasurements);
}
}

function isSurveyEvent(event: unknown): event is IChatModelFeedbackSurveyTelemetryEvent {
if (typeof event !== 'object' || event === null) {
return false;
}
const candidate = event as IChatModelFeedbackSurveyTelemetryEvent;
return KNOWN_EVENT_KINDS.includes(candidate.kind)
&& typeof candidate.surveyId === 'string'
&& typeof candidate.surveyInstanceId === 'string'
&& typeof candidate.stepCount === 'number'
&& typeof candidate.requestId === 'string';
}

function addProperty(properties: Record<string, string>, key: string, value: string | undefined): void {
if (typeof value === 'string' && value.length > 0) {
properties[key] = value;
}
}
5 changes: 3 additions & 2 deletions src/vs/base/browser/ui/toolbar/toolbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,8 +213,9 @@ export class ToolBar extends Disposable {
return this.element;
}

focus(): void {
this.actionBar.focus();
/** Focuses the item at `index`, or the first item when no index is given. */
focus(index?: number): void {
this.actionBar.focus(index);
}

getItemsWidth(): number {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import * as dom from '../../../../../base/browser/dom.js';
import { Codicon } from '../../../../../base/common/codicons.js';
import { Disposable, markAsSingleton } from '../../../../../base/common/lifecycle.js';
import { ThemeIcon } from '../../../../../base/common/themables.js';
import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js';
import { localize2 } from '../../../../../nls.js';
import { IAction } from '../../../../../base/common/actions.js';
import { IActionViewItemService } from '../../../../../platform/actions/browser/actionViewItemService.js';
import { MenuEntryActionViewItem } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js';
import { Action2, MenuId, MenuItemAction, registerAction2 } from '../../../../../platform/actions/common/actions.js';
import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js';
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
import { IWorkbenchContribution } from '../../../../common/contributions.js';
import { ChatContextKeys } from '../../common/actions/chatContextKeys.js';
import { isResponseVM } from '../../common/model/chatViewModel.js';
import { IChatModelFeedbackSurveyService } from '../feedbackSurvey/chatModelFeedbackSurveyService.js';
import '../feedbackSurvey/media/chatModelFeedbackSurvey.css';
import { CHAT_CATEGORY } from './chatActions.js';

export const ChatModelFeedbackSurveyActionId = 'workbench.action.chat.openModelFeedbackSurvey';

const thumbsUpIconClasses = ThemeIcon.asClassNameArray(Codicon.thumbsup);
const thumbsDownIconClasses = ThemeIcon.asClassNameArray(Codicon.thumbsdown);

/**
* The combined thumbs up and down control that stands in for the helpful and unhelpful actions
* while a survey applies. It is neutral and opens the survey rather than recording a vote.
*/
class ChatModelFeedbackSurveyActionViewItem extends MenuEntryActionViewItem {

override render(container: HTMLElement): void {
super.render(container);

if (!this.element || !this.label) {
return;
}

// The label is the focusable anchor that carries the accessible name, so the styling and
// the icons hang off it rather than off the outer list item.
this.label.classList.add('chat-feedback-survey-pill');
this.resetLabel();

const icons = dom.append(this.label, dom.$('.chat-feedback-survey-pill-icons'));
icons.setAttribute('aria-hidden', 'true');
for (const iconClasses of [thumbsUpIconClasses, thumbsDownIconClasses]) {
const icon = dom.append(icons, dom.$('.chat-feedback-survey-pill-icon'));
icon.classList.add(...iconClasses);
}
}

protected override updateClass(): void {
super.updateClass();
this.resetLabel();
}

/**
* The base item paints one icon onto the label, so that has to be cleared before drawing two.
* The label keeps its `aria-label` and only the icons are hidden from screen readers.
*/
private resetLabel(): void {
if (!this.label) {
return;
}
this.label.classList.remove('icon', ...thumbsUpIconClasses, ...thumbsDownIconClasses);
this.label.style.backgroundImage = '';
this.label.textContent = '';
}
}

export class ChatModelFeedbackSurveyActionRendering extends Disposable implements IWorkbenchContribution {

static readonly ID = 'chat.modelFeedbackSurveyActionRendering';

constructor(
@IActionViewItemService actionViewItemService: IActionViewItemService,
@IInstantiationService instantiationService: IInstantiationService,
) {
super();

const disposable = this._register(actionViewItemService.register(MenuId.ChatMessageFooter, ChatModelFeedbackSurveyActionId, (action, options) => {
if (!(action instanceof MenuItemAction)) {
return undefined;
}
return instantiationService.createInstance(ChatModelFeedbackSurveyActionViewItem, action, options);
}));

markAsSingleton(disposable);
}
}

/** The part of a toolbar needed to find and focus one of its actions. */
export interface IFeedbackSurveyToolBar {
getItemsLength(): number;
getItemAction(index: number): IAction | undefined;
focus(index?: number): void;
}

/**
* Puts focus on the feedback control in `toolbar`, used when the survey panel it opened is torn
* down. Falls back to the toolbar itself when the control is not currently shown.
*/
export function focusChatModelFeedbackSurveyAction(toolbar: IFeedbackSurveyToolBar): void {
for (let i = 0; i < toolbar.getItemsLength(); i++) {
if (toolbar.getItemAction(i)?.id === ChatModelFeedbackSurveyActionId) {
toolbar.focus(i);
return;
}
}
toolbar.focus();
}

export function registerChatModelFeedbackSurveyActions(): void {
registerAction2(class OpenModelFeedbackSurveyAction extends Action2 {
constructor() {
super({
id: ChatModelFeedbackSurveyActionId,
title: localize2('chat.feedbackSurvey.open.label', "Give Feedback"),
f1: false,
category: CHAT_CATEGORY,
icon: Codicon.thumbsup,
toggled: ChatContextKeys.responseFeedbackSurveyOpen,
Comment thread
lramos15 marked this conversation as resolved.
menu: [{
id: MenuId.ChatMessageFooter,
group: 'navigation',
order: 2,
// The survey service checks these too, so a shown report is never sent for a
// control that cannot render. This drops the vote actions'
// `lockedToCodingAgent.negate()` because every agent host session is locked to
// its agent, which would make the `harnesses` selector unreachable.
when: ContextKeyExpr.and(
ChatContextKeys.responseHasFeedbackSurvey,
ChatContextKeys.isResponse,
ChatContextKeys.responseHasError.negate(),
ContextKeyExpr.has('config.telemetry.feedback.enabled'),
),
}],
});
}

run(accessor: ServicesAccessor, ...args: unknown[]): void {
const item = args[0];
if (!isResponseVM(item)) {
return;
}
accessor.get(IChatModelFeedbackSurveyService).toggle(item);
}
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export function registerChatTitleActions() {
id: MenuId.ChatMessageFooter,
group: 'navigation',
order: 2,
when: ContextKeyExpr.and(ChatContextKeys.extensionParticipantRegistered, ChatContextKeys.isResponse, ChatContextKeys.responseHasError.negate(), ContextKeyExpr.has(enableFeedbackConfig), ChatContextKeys.lockedToCodingAgent.negate())
when: ContextKeyExpr.and(ChatContextKeys.extensionParticipantRegistered, ChatContextKeys.isResponse, ChatContextKeys.responseHasError.negate(), ContextKeyExpr.has(enableFeedbackConfig), ChatContextKeys.lockedToCodingAgent.negate(), ChatContextKeys.responseHasFeedbackSurvey.negate())
}, {
id: MENU_INLINE_CHAT_WIDGET_SECONDARY,
group: 'navigation',
Expand Down Expand Up @@ -90,7 +90,7 @@ export function registerChatTitleActions() {
id: MenuId.ChatMessageFooter,
group: 'navigation',
order: 3,
when: ContextKeyExpr.and(ChatContextKeys.extensionParticipantRegistered, ChatContextKeys.isResponse, ContextKeyExpr.has(enableFeedbackConfig), ChatContextKeys.lockedToCodingAgent.negate())
when: ContextKeyExpr.and(ChatContextKeys.extensionParticipantRegistered, ChatContextKeys.isResponse, ContextKeyExpr.has(enableFeedbackConfig), ChatContextKeys.lockedToCodingAgent.negate(), ChatContextKeys.responseHasFeedbackSurvey.negate())
}, {
id: MENU_INLINE_CHAT_WIDGET_SECONDARY,
group: 'navigation',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ import { CodeBlockActionRendering, registerChatCodeBlockActions, registerChatCod
import { ChatContextContributions } from './actions/chatContext.js';
import { registerChatContextActions } from './actions/chatContextActions.js';
import { ChatCopyActionRendering, registerChatCopyActions } from './actions/chatCopyActions.js';
import { ChatModelFeedbackSurveyActionRendering, registerChatModelFeedbackSurveyActions } from './actions/chatModelFeedbackSurveyActions.js';
import { ChatModelFeedbackSurveyService, IChatModelFeedbackSurveyService } from './feedbackSurvey/chatModelFeedbackSurveyService.js';
import { ChatModelFeedbackSurveyPromptContribution } from './feedbackSurvey/chatModelFeedbackSurveyPromptContribution.js';
import { registerChatDeveloperActions } from './actions/chatDeveloperActions.js';
import { registerChatElicitationActions } from './actions/chatElicitationActions.js';
import { registerChatExecuteActions } from './actions/chatExecuteActions.js';
Expand Down Expand Up @@ -3013,6 +3016,8 @@ registerWorkbenchContribution2(ChatPromptFilesExtensionPointHandler.ID, ChatProm
registerWorkbenchContribution2(ChatCompatibilityNotifier.ID, ChatCompatibilityNotifier, WorkbenchPhase.Eventually);
registerWorkbenchContribution2(CodeBlockActionRendering.ID, CodeBlockActionRendering, WorkbenchPhase.BlockRestore);
registerWorkbenchContribution2(ChatCopyActionRendering.ID, ChatCopyActionRendering, WorkbenchPhase.BlockRestore);
registerWorkbenchContribution2(ChatModelFeedbackSurveyActionRendering.ID, ChatModelFeedbackSurveyActionRendering, WorkbenchPhase.BlockRestore);
registerWorkbenchContribution2(ChatModelFeedbackSurveyPromptContribution.ID, ChatModelFeedbackSurveyPromptContribution, WorkbenchPhase.Eventually);
registerWorkbenchContribution2(ChatImplicitContextContribution.ID, ChatImplicitContextContribution, WorkbenchPhase.Eventually);
registerWorkbenchContribution2(ChatViewsWelcomeHandler.ID, ChatViewsWelcomeHandler, WorkbenchPhase.BlockStartup);
registerWorkbenchContribution2(ChatGettingStartedContribution.ID, ChatGettingStartedContribution, WorkbenchPhase.Eventually);
Expand Down Expand Up @@ -3055,6 +3060,7 @@ registerWorkbenchContribution2(TranscriptContextAttachmentWidgetContribution.ID,
registerChatActions();
registerChatAccessibilityActions();
registerChatCopyActions();
registerChatModelFeedbackSurveyActions();
registerChatOpenAgentDebugPanelAction();
registerChatCodeBlockActions();
registerChatCodeCompareBlockActions();
Expand Down Expand Up @@ -3096,6 +3102,7 @@ registerSingleton(IChatWidgetService, ChatWidgetService, InstantiationType.Delay
registerSingleton(IChatPasteTargetService, ChatPasteTargetService, InstantiationType.Delayed);
registerSingleton(IChatSideChatService, ChatSideChatService, InstantiationType.Delayed);
registerSingleton(IChatRequestOriginService, ChatRequestOriginService, InstantiationType.Delayed);
registerSingleton(IChatModelFeedbackSurveyService, ChatModelFeedbackSurveyService, InstantiationType.Delayed);
registerSingleton(IChatPetService, ChatPetService, InstantiationType.Delayed);
registerSingleton(IQuickChatService, QuickChatService, InstantiationType.Delayed);
registerSingleton(IChatAccessibilityService, ChatAccessibilityService, InstantiationType.Delayed);
Expand Down
Loading
Loading