From de11e521cb320e4a242e413b82891033fc68e8ac Mon Sep 17 00:00:00 2001 From: digitarald Date: Fri, 19 Jun 2026 18:12:40 -0700 Subject: [PATCH 01/13] Add in-editor PMF survey pane (POC) Moves in-product surveys from notification-based flows into an editor pane, following the same pattern as the issue reporter editor. - EditorInput + EditorPane architecture (singleton, readonly) - Segmented control (Q1) styled with --vscode-radio-* tokens - List-row selection (Q2/Q3) with native radios visually hidden inside full-width label elements, styled via :has(:checked) - Responsive container query (collapses 2-col to 1-col at 600px) - HC/focus-visible support for both segment and list-row patterns - Success animation with auto-close after 3s - Telemetry event for survey submission (survey/submit) - Works in both workbench and sessions/agent window - Testable via Developer: Open Survey command Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/sessions.desktop.main.ts | 3 + src/vs/sessions/sessions.web.main.ts | 1 + .../browser/media/surveyEditorPane.css | 258 ++++++++++++++++++ .../surveys/browser/survey.contribution.ts | 61 +++++ .../surveys/browser/surveyEditorInput.ts | 55 ++++ .../surveys/browser/surveyEditorPane.ts | 242 ++++++++++++++++ .../surveys/browser/surveyQuestions.ts | 90 ++++++ src/vs/workbench/workbench.desktop.main.ts | 3 + src/vs/workbench/workbench.web.main.ts | 3 + 9 files changed, 716 insertions(+) create mode 100644 src/vs/workbench/contrib/surveys/browser/media/surveyEditorPane.css create mode 100644 src/vs/workbench/contrib/surveys/browser/survey.contribution.ts create mode 100644 src/vs/workbench/contrib/surveys/browser/surveyEditorInput.ts create mode 100644 src/vs/workbench/contrib/surveys/browser/surveyEditorPane.ts create mode 100644 src/vs/workbench/contrib/surveys/browser/surveyQuestions.ts diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index dc7a37043fdab4..d998c37d03ae91 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -140,6 +140,9 @@ import '../workbench/contrib/extensions/electron-browser/extensions.contribution // Issues import '../workbench/contrib/issue/electron-browser/issue.contribution.js'; +// Surveys +import '../workbench/contrib/surveys/browser/survey.contribution.js'; + // Process Explorer import '../workbench/contrib/processExplorer/electron-browser/processExplorer.contribution.js'; diff --git a/src/vs/sessions/sessions.web.main.ts b/src/vs/sessions/sessions.web.main.ts index 4c540b143be929..014628cf156827 100644 --- a/src/vs/sessions/sessions.web.main.ts +++ b/src/vs/sessions/sessions.web.main.ts @@ -198,6 +198,7 @@ import '../workbench/contrib/terminal/browser/terminalInstanceService.js'; import '../workbench/contrib/tasks/browser/taskService.js'; import '../workbench/contrib/tags/browser/workspaceTagsService.js'; import '../workbench/contrib/issue/browser/issue.contribution.js'; +import '../workbench/contrib/surveys/browser/survey.contribution.js'; import '../workbench/contrib/splash/browser/splash.contribution.js'; import '../workbench/contrib/remote/browser/remoteStartEntry.contribution.js'; import '../workbench/contrib/processExplorer/browser/processExplorer.web.contribution.js'; diff --git a/src/vs/workbench/contrib/surveys/browser/media/surveyEditorPane.css b/src/vs/workbench/contrib/surveys/browser/media/surveyEditorPane.css new file mode 100644 index 00000000000000..e7691b07ec15c9 --- /dev/null +++ b/src/vs/workbench/contrib/surveys/browser/media/surveyEditorPane.css @@ -0,0 +1,258 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.survey-editor-pane { + height: 100%; + overflow: auto; + display: flex; + justify-content: center; + align-items: flex-start; + padding: 40px 20px; +} + +.survey-editor-pane .survey-form { + container-type: inline-size; + max-width: 720px; + width: 100%; +} + +/* Header */ + +.survey-editor-pane .survey-title { + font-size: 18px; + font-weight: 600; + margin-bottom: 4px; + display: flex; + align-items: center; + gap: 8px; +} + +.survey-editor-pane .survey-title .survey-title-icon { + color: var(--vscode-focusBorder); +} + +.survey-editor-pane .survey-description { + color: var(--vscode-descriptionForeground); + margin-bottom: 28px; +} + +/* Questions */ + +.survey-editor-pane .survey-question { + margin-bottom: 24px; +} + +.survey-editor-pane .survey-question-label { + font-weight: 600; + margin-bottom: 8px; + display: block; +} + +/* Segmented control — uses --vscode-radio-* tokens from the Radio widget palette */ + +.survey-editor-pane .survey-segment-group { + display: flex; + border: 1px solid var(--vscode-radio-inactiveBorder, var(--vscode-input-border, transparent)); + border-radius: var(--vscode-cornerRadius-small, 4px); + overflow: hidden; +} + +.survey-editor-pane .survey-segment-group .survey-segment-input { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.survey-editor-pane .survey-segment-group .survey-segment-label { + flex: 1; + text-align: center; + padding: 8px 12px; + cursor: pointer; + border-right: 1px solid var(--vscode-radio-inactiveBorder, var(--vscode-input-border, transparent)); + color: var(--vscode-radio-inactiveForeground, inherit); + background-color: var(--vscode-radio-inactiveBackground, transparent); + transition: background-color 0.1s; + user-select: none; +} + +.survey-editor-pane .survey-segment-group .survey-segment-label:last-of-type { + border-right: none; +} + +.survey-editor-pane .survey-segment-group .survey-segment-label:hover { + background-color: var(--vscode-radio-inactiveHoverBackground, var(--vscode-list-hoverBackground)); +} + +.survey-editor-pane .survey-segment-group .survey-segment-input:checked + .survey-segment-label { + background-color: var(--vscode-radio-activeBackground, var(--vscode-list-activeSelectionBackground)); + color: var(--vscode-radio-activeForeground, var(--vscode-list-activeSelectionForeground)); + border-color: var(--vscode-radio-activeBorder, transparent); +} + +.survey-editor-pane .survey-segment-group .survey-segment-input:focus-visible + .survey-segment-label { + outline: 2px solid var(--vscode-focusBorder); + outline-offset: -2px; + z-index: 1; +} + +/* HC: distinct border on selected segment */ +.hc-black .survey-editor-pane .survey-segment-group .survey-segment-input:checked + .survey-segment-label, +.hc-light .survey-editor-pane .survey-segment-group .survey-segment-input:checked + .survey-segment-label { + outline: 1px solid var(--vscode-contrastActiveBorder, transparent); + outline-offset: -1px; +} + +/* List-row selection — full-width clickable rows with hidden native radios */ + +.survey-editor-pane .survey-list-group { + display: grid; + grid-template-columns: 1fr; + gap: 4px; +} + +.survey-editor-pane .survey-list-group.columns-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.survey-editor-pane .survey-list-option { + display: block; + padding: 7px 10px; + border-radius: var(--vscode-cornerRadius-small, 4px); + border-left: 2px solid transparent; + cursor: pointer; + transition: background-color 0.1s; + user-select: none; +} + +.survey-editor-pane .survey-list-option .survey-list-input { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.survey-editor-pane .survey-list-option:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.survey-editor-pane .survey-list-option:has(.survey-list-input:checked) { + background-color: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); + border-left-color: var(--vscode-focusBorder); +} + +.survey-editor-pane .survey-list-option:has(.survey-list-input:focus-visible) { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +/* HC: distinct border on selected list row */ +.hc-black .survey-editor-pane .survey-list-option:has(.survey-list-input:checked), +.hc-light .survey-editor-pane .survey-list-option:has(.survey-list-input:checked) { + outline: 1px solid var(--vscode-contrastActiveBorder); + outline-offset: -1px; +} + +/* Submit */ + +.survey-editor-pane .survey-submit-row { + margin-top: 28px; +} + +.survey-editor-pane .survey-submit-button { + padding: 8px 20px; + border: none; + border-radius: var(--vscode-cornerRadius-small, 4px); + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); + font-size: inherit; + font-family: inherit; + font-weight: 600; + cursor: pointer; + transition: background-color 0.1s; +} + +.survey-editor-pane .survey-submit-button:hover { + background-color: var(--vscode-button-hoverBackground); +} + +.survey-editor-pane .survey-submit-button:disabled { + opacity: 0.5; + cursor: default; +} + +/* Success / "sent" state */ + +.survey-editor-pane .survey-success { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 60px 20px; + animation: survey-success-appear 0.3s ease-out; +} + +.survey-editor-pane .survey-success .survey-success-icon { + font-size: 32px; + color: var(--vscode-charts-green); + margin-bottom: 12px; +} + +.survey-editor-pane .survey-success .survey-success-message { + font-size: 14px; + font-weight: 600; + margin-bottom: 4px; +} + +.survey-editor-pane .survey-success .survey-success-detail { + color: var(--vscode-descriptionForeground); +} + +@keyframes survey-success-appear { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + @keyframes survey-success-appear { + from { opacity: 0; } + to { opacity: 1; } + } +} + +/* Responsive: collapse to single column on narrow editors */ + +@container (max-width: 600px) { + .survey-editor-pane .survey-list-group.columns-2 { + grid-template-columns: 1fr; + } + + .survey-editor-pane .survey-segment-group { + flex-wrap: wrap; + } + + .survey-editor-pane .survey-segment-group .survey-segment-label { + flex-basis: 33%; + flex-grow: 1; + } +} diff --git a/src/vs/workbench/contrib/surveys/browser/survey.contribution.ts b/src/vs/workbench/contrib/surveys/browser/survey.contribution.ts new file mode 100644 index 00000000000000..95cc95790ec242 --- /dev/null +++ b/src/vs/workbench/contrib/surveys/browser/survey.contribution.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize, localize2 } from '../../../../nls.js'; +import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; +import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../browser/editor.js'; +import { EditorExtensions } from '../../../common/editor.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js'; +import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; +import { SurveyEditorInput } from './surveyEditorInput.js'; +import { SurveyEditorPane } from './surveyEditorPane.js'; +import { CopilotPMFSurvey } from './surveyQuestions.js'; + +// Register editor pane +Registry.as(EditorExtensions.EditorPane).registerEditorPane( + EditorPaneDescriptor.create( + SurveyEditorPane, + SurveyEditorPane.ID, + localize('surveyEditorPaneTitle', "Survey") + ), + [new SyncDescriptor(SurveyEditorInput)] +); + +// Register test command +class OpenSurveyAction extends Action2 { + static readonly ID = 'workbench.action.openSurvey'; + + constructor() { + super({ + id: OpenSurveyAction.ID, + title: localize2('openSurvey', "Open Survey"), + category: Categories.Developer, + f1: true, + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const instantiationService = accessor.get(IInstantiationService); + const editorService = accessor.get(IEditorService); + const editorGroupsService = accessor.get(IEditorGroupsService); + const environmentService = accessor.get(IWorkbenchEnvironmentService); + + const input = instantiationService.createInstance(SurveyEditorInput, CopilotPMFSurvey); + + // In the sessions window, open in the main editor part (not modal) + const preferredGroup = environmentService.isSessionsWindow + ? editorGroupsService.mainPart.activeGroup + : undefined; + + await editorService.openEditor(input, { pinned: true }, preferredGroup); + } +} + +registerAction2(OpenSurveyAction); diff --git a/src/vs/workbench/contrib/surveys/browser/surveyEditorInput.ts b/src/vs/workbench/contrib/surveys/browser/surveyEditorInput.ts new file mode 100644 index 00000000000000..6864bb2f2d5736 --- /dev/null +++ b/src/vs/workbench/contrib/surveys/browser/surveyEditorInput.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../base/common/uri.js'; +import { EditorInput } from '../../../common/editor/editorInput.js'; +import { EditorInputCapabilities } from '../../../common/editor.js'; +import { localize } from '../../../../nls.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { ISurveyDefinition } from './surveyQuestions.js'; + +const surveyIcon = registerIcon('survey', Codicon.feedback, localize('surveyIcon', "Icon for the survey editor.")); + +export class SurveyEditorInput extends EditorInput { + + static readonly ID = 'workbench.input.survey'; + static readonly RESOURCE = URI.from({ scheme: 'vscode-survey', path: 'survey' }); + + constructor( + readonly survey: ISurveyDefinition, + ) { + super(); + } + + override get typeId(): string { + return SurveyEditorInput.ID; + } + + override get editorId(): string | undefined { + return this.typeId; + } + + override get resource(): URI | undefined { + return SurveyEditorInput.RESOURCE; + } + + override getName(): string { + return this.survey.title; + } + + override getIcon(): ThemeIcon | undefined { + return surveyIcon; + } + + override matches(other: EditorInput | unknown): boolean { + return other instanceof SurveyEditorInput && other.survey.id === this.survey.id; + } + + override get capabilities(): EditorInputCapabilities { + return EditorInputCapabilities.Singleton | EditorInputCapabilities.Readonly; + } +} diff --git a/src/vs/workbench/contrib/surveys/browser/surveyEditorPane.ts b/src/vs/workbench/contrib/surveys/browser/surveyEditorPane.ts new file mode 100644 index 00000000000000..03ab708614c5a8 --- /dev/null +++ b/src/vs/workbench/contrib/surveys/browser/surveyEditorPane.ts @@ -0,0 +1,242 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/surveyEditorPane.css'; +import { $, addDisposableListener, append, clearNode } from '../../../../base/browser/dom.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { IStorageService } from '../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { EditorPane } from '../../../browser/parts/editor/editorPane.js'; +import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js'; +import { IEditorOpenContext } from '../../../common/editor.js'; +import { EditorInput } from '../../../common/editor/editorInput.js'; +import { IEditorOptions } from '../../../../platform/editor/common/editor.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { ISurveyDefinition, ISurveyQuestion, ISurveyRadioQuestion, ISurveySegmentQuestion, SurveyQuestionType } from './surveyQuestions.js'; +import { SurveyEditorInput } from './surveyEditorInput.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; + +export class SurveyEditorPane extends EditorPane { + + static readonly ID = 'workbench.editor.survey'; + + private container: HTMLElement | undefined; + private readonly inputDisposables = this._register(new DisposableStore()); + private answers: Map = new Map(); + private renderNonce = 0; + + constructor( + group: IEditorGroup, + @ITelemetryService telemetryService: ITelemetryService, + @IThemeService themeService: IThemeService, + @IStorageService storageService: IStorageService, + @IEditorService private readonly editorService: IEditorService, + ) { + super(SurveyEditorPane.ID, group, telemetryService, themeService, storageService); + } + + protected override createEditor(parent: HTMLElement): void { + this.container = append(parent, $('div.survey-editor-pane')); + } + + override async setInput( + input: SurveyEditorInput, + options: IEditorOptions | undefined, + context: IEditorOpenContext, + token: CancellationToken, + ): Promise { + await super.setInput(input, options, context, token); + if (token.isCancellationRequested || !this.container) { + return; + } + + this.resetState(); + this.renderForm(this.container, input.survey); + } + + override clearInput(): void { + this.resetState(); + super.clearInput(); + } + + private resetState(): void { + this.inputDisposables.clear(); + this.answers.clear(); + this.renderNonce++; + if (this.container) { + clearNode(this.container); + } + } + + private renderForm(container: HTMLElement, survey: ISurveyDefinition): void { + const form = append(container, $('div.survey-form')); + + // Title with icon + const title = append(form, $('div.survey-title')); + const titleIcon = append(title, $('span.survey-title-icon')); + titleIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.sparkle)); + const titleText = append(title, $('span')); + titleText.textContent = survey.title; + + const description = append(form, $('div.survey-description')); + description.textContent = survey.description; + + // Questions + for (const question of survey.questions) { + this.renderQuestion(form, question); + } + + // Submit button + const submitRow = append(form, $('div.survey-submit-row')); + const submitButton = append(submitRow, $('button.survey-submit-button')) as HTMLButtonElement; + submitButton.textContent = localize('survey.submitFeedback', "Submit feedback"); + submitButton.type = 'button'; + submitButton.disabled = true; + + const updateSubmitState = () => { + submitButton.disabled = this.answers.size < survey.questions.length + || [...this.answers.values()].some(v => v.length === 0); + }; + + this.inputDisposables.add(addDisposableListener(submitButton, 'click', () => { + submitButton.disabled = true; + this.handleSubmit(container, survey); + })); + + this.inputDisposables.add(addDisposableListener(form, 'change', () => { + updateSubmitState(); + })); + } + + private renderQuestion(parent: HTMLElement, question: ISurveyQuestion): void { + const questionEl = append(parent, $('div.survey-question')); + + const labelId = `survey-q-${this.renderNonce}-${question.id}`; + const label = append(questionEl, $('div.survey-question-label')); + label.id = labelId; + label.textContent = question.label; + + const namePrefix = `${this.renderNonce}-${question.id}`; + + switch (question.type) { + case SurveyQuestionType.Segment: + this.renderSegmentQuestion(questionEl, question, labelId, namePrefix); + break; + case SurveyQuestionType.Radio: + this.renderListQuestion(questionEl, question, labelId, namePrefix); + break; + } + } + + private renderSegmentQuestion(parent: HTMLElement, question: ISurveySegmentQuestion, labelId: string, namePrefix: string): void { + const group = append(parent, $('div.survey-segment-group')); + group.setAttribute('role', 'radiogroup'); + group.setAttribute('aria-labelledby', labelId); + + for (const option of question.options) { + const radio = append(group, $('input.survey-segment-input')) as HTMLInputElement; + radio.type = 'radio'; + radio.name = namePrefix; + radio.value = option; + radio.id = `survey-seg-${namePrefix}-${option.replace(/\s+/g, '-').toLowerCase()}`; + + const optionLabel = append(group, $('label.survey-segment-label')) as HTMLLabelElement; + optionLabel.htmlFor = radio.id; + optionLabel.textContent = option; + + this.inputDisposables.add(addDisposableListener(radio, 'change', () => { + if (radio.checked) { + this.answers.set(question.id, [option]); + } + })); + } + } + + private renderListQuestion(parent: HTMLElement, question: ISurveyRadioQuestion, labelId: string, namePrefix: string): void { + const group = append(parent, $('div.survey-list-group')); + group.setAttribute('role', 'radiogroup'); + group.setAttribute('aria-labelledby', labelId); + + if (question.columns === 2) { + group.classList.add('columns-2'); + } + + for (const option of question.options) { + const optionLabel = append(group, $('label.survey-list-option')) as HTMLLabelElement; + + const radio = append(optionLabel, $('input.survey-list-input')) as HTMLInputElement; + radio.type = 'radio'; + radio.name = namePrefix; + radio.value = option; + + const text = append(optionLabel, $('span')); + text.textContent = option; + + this.inputDisposables.add(addDisposableListener(radio, 'change', () => { + if (radio.checked) { + this.answers.set(question.id, [option]); + } + })); + } + } + + private handleSubmit(container: HTMLElement, survey: ISurveyDefinition): void { + // Snapshot answers + const answersSnapshot: Record = {}; + for (const [key, value] of this.answers) { + answersSnapshot[key] = [...value]; + } + + type SurveySubmitEvent = { + surveyId: string; + answers: string; + }; + type SurveySubmitClassification = { + surveyId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The survey identifier.' }; + answers: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'JSON-encoded survey answers (all values are string arrays).' }; + owner: 'digitarald'; + comment: 'Tracks in-product survey submissions for product-market fit analysis.'; + }; + this.telemetryService.publicLog2('survey/submit', { + surveyId: survey.id, + answers: JSON.stringify(answersSnapshot), + }); + + const submittedInput = this.input; + this.showSuccess(container, submittedInput); + } + + private showSuccess(container: HTMLElement, submittedInput: EditorInput | undefined): void { + clearNode(container); + + const success = append(container, $('div.survey-success')); + + const icon = append(success, $('div.survey-success-icon')); + icon.classList.add(...ThemeIcon.asClassNameArray(Codicon.checkAll)); + + const message = append(success, $('div.survey-success-message')); + message.textContent = localize('survey.success.message', "Response sent"); + + const detail = append(success, $('div.survey-success-detail')); + detail.textContent = localize('survey.success.detail', "Your answer helps us understand who needs this most. Thank you."); + + // Auto-close after 3 seconds + const timeout = setTimeout(() => { + if (submittedInput) { + this.editorService.closeEditor({ editor: submittedInput, groupId: this.group.id }); + } + }, 3000); + + this.inputDisposables.add({ dispose: () => clearTimeout(timeout) }); + } + + override layout(): void { + // no-op: CSS handles sizing + } +} diff --git a/src/vs/workbench/contrib/surveys/browser/surveyQuestions.ts b/src/vs/workbench/contrib/surveys/browser/surveyQuestions.ts new file mode 100644 index 00000000000000..703e3f8b2f08d4 --- /dev/null +++ b/src/vs/workbench/contrib/surveys/browser/surveyQuestions.ts @@ -0,0 +1,90 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export const enum SurveyQuestionType { + Segment = 'segment', + Radio = 'radio', +} + +export interface ISurveySegmentQuestion { + readonly type: SurveyQuestionType.Segment; + readonly id: string; + readonly label: string; + readonly options: readonly string[]; +} + +export interface ISurveyRadioQuestion { + readonly type: SurveyQuestionType.Radio; + readonly id: string; + readonly label: string; + readonly options: readonly string[]; + readonly columns?: number; +} + +export type ISurveyQuestion = ISurveySegmentQuestion | ISurveyRadioQuestion; + +export interface ISurveyDefinition { + readonly id: string; + readonly title: string; + readonly description: string; + readonly questions: readonly ISurveyQuestion[]; +} + +/** + * Product-Market Fit survey for GitHub Copilot. + * Based on the Sean Ellis "very disappointed" test. + */ +export const CopilotPMFSurvey: ISurveyDefinition = { + id: 'copilot-pmf', + title: 'Help Us Improve GitHub Copilot', + description: 'This short survey helps us understand how well Copilot fits into your workflow.', + questions: [ + { + type: SurveyQuestionType.Segment, + id: 'disappointment', + label: 'How disappointed would you be if you could no longer use Copilot?', + options: [ + 'Not at all', + 'Slightly', + 'Somewhat', + 'Very', + 'Extremely', + ], + }, + { + type: SurveyQuestionType.Radio, + id: 'main-benefit', + label: 'What has Copilot helped you with most recently?', + columns: 2, + options: [ + 'Shipping changes faster', + 'Getting unstuck on bugs', + 'Making multi-file changes', + 'Automating repetitive work', + 'Understanding the codebase', + 'Planning an approach', + 'Improving or reviewing code', + 'I haven\'t gotten clear value yet', + ], + }, + { + type: SurveyQuestionType.Radio, + id: 'blockers', + label: 'What most gets in your way?', + columns: 2, + options: [ + 'Output is hard to trust', + 'Missing repo or project context', + 'Struggles with bigger tasks', + 'Too much time reviewing', + 'Too much steering needed', + 'Too slow / breaks flow', + 'Setup or integrations are hard', + 'Security or permissions friction', + 'Limits, cost, or billing', + ], + }, + ], +}; diff --git a/src/vs/workbench/workbench.desktop.main.ts b/src/vs/workbench/workbench.desktop.main.ts index 315ae418e38653..2fc6c8a3ff0fb5 100644 --- a/src/vs/workbench/workbench.desktop.main.ts +++ b/src/vs/workbench/workbench.desktop.main.ts @@ -131,6 +131,9 @@ import './contrib/extensions/electron-browser/devtoolsExtensionHost.contribution // Issues import './contrib/issue/electron-browser/issue.contribution.js'; +// Surveys +import './contrib/surveys/browser/survey.contribution.js'; + // Process Explorer import './contrib/processExplorer/electron-browser/processExplorer.contribution.js'; diff --git a/src/vs/workbench/workbench.web.main.ts b/src/vs/workbench/workbench.web.main.ts index 1c0767c714b458..76016a43a398a0 100644 --- a/src/vs/workbench/workbench.web.main.ts +++ b/src/vs/workbench/workbench.web.main.ts @@ -172,6 +172,9 @@ import './contrib/tags/browser/workspaceTagsService.js'; // Issues import './contrib/issue/browser/issue.contribution.js'; +// Surveys +import './contrib/surveys/browser/survey.contribution.js'; + // Splash import './contrib/splash/browser/splash.contribution.js'; From 0a4d5ab49171e4c2c9c72f3fee23bc846049ae7e Mon Sep 17 00:00:00 2001 From: digitarald Date: Fri, 19 Jun 2026 19:10:54 -0700 Subject: [PATCH 02/13] Address PR feedback: accessibility, localization, design tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gate 'Open Survey' command behind IsDevelopmentContext (dev builds only) - Localize all user-facing strings (title, description, questions, options) - Use stable option IDs for telemetry instead of locale-dependent labels - Replace custom submit button CSS with shared Button widget - Add aria-hidden on decorative icons (sparkle, checkAll) - Add role=status + aria-live=polite on success container - Announce 'Response sent' via status() for screen readers - Move focus to success container after submit - Use --vscode-strokeThickness token for border widths - Snap padding to spacing ramp (7px → 8px) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/media/surveyEditorPane.css | 28 +------- .../surveys/browser/survey.contribution.ts | 2 + .../surveys/browser/surveyEditorPane.ts | 58 ++++++++++------- .../surveys/browser/surveyQuestions.ts | 65 ++++++++++--------- 4 files changed, 77 insertions(+), 76 deletions(-) diff --git a/src/vs/workbench/contrib/surveys/browser/media/surveyEditorPane.css b/src/vs/workbench/contrib/surveys/browser/media/surveyEditorPane.css index e7691b07ec15c9..6fa6e542991cb8 100644 --- a/src/vs/workbench/contrib/surveys/browser/media/surveyEditorPane.css +++ b/src/vs/workbench/contrib/surveys/browser/media/surveyEditorPane.css @@ -54,7 +54,7 @@ .survey-editor-pane .survey-segment-group { display: flex; - border: 1px solid var(--vscode-radio-inactiveBorder, var(--vscode-input-border, transparent)); + border: var(--vscode-strokeThickness) solid var(--vscode-radio-inactiveBorder, var(--vscode-input-border, transparent)); border-radius: var(--vscode-cornerRadius-small, 4px); overflow: hidden; } @@ -76,7 +76,7 @@ text-align: center; padding: 8px 12px; cursor: pointer; - border-right: 1px solid var(--vscode-radio-inactiveBorder, var(--vscode-input-border, transparent)); + border-right: var(--vscode-strokeThickness) solid var(--vscode-radio-inactiveBorder, var(--vscode-input-border, transparent)); color: var(--vscode-radio-inactiveForeground, inherit); background-color: var(--vscode-radio-inactiveBackground, transparent); transition: background-color 0.1s; @@ -124,7 +124,7 @@ .survey-editor-pane .survey-list-option { display: block; - padding: 7px 10px; + padding: 8px 10px; border-radius: var(--vscode-cornerRadius-small, 4px); border-left: 2px solid transparent; cursor: pointer; @@ -172,28 +172,6 @@ margin-top: 28px; } -.survey-editor-pane .survey-submit-button { - padding: 8px 20px; - border: none; - border-radius: var(--vscode-cornerRadius-small, 4px); - background-color: var(--vscode-button-background); - color: var(--vscode-button-foreground); - font-size: inherit; - font-family: inherit; - font-weight: 600; - cursor: pointer; - transition: background-color 0.1s; -} - -.survey-editor-pane .survey-submit-button:hover { - background-color: var(--vscode-button-hoverBackground); -} - -.survey-editor-pane .survey-submit-button:disabled { - opacity: 0.5; - cursor: default; -} - /* Success / "sent" state */ .survey-editor-pane .survey-success { diff --git a/src/vs/workbench/contrib/surveys/browser/survey.contribution.ts b/src/vs/workbench/contrib/surveys/browser/survey.contribution.ts index 95cc95790ec242..43d46c2747da85 100644 --- a/src/vs/workbench/contrib/surveys/browser/survey.contribution.ts +++ b/src/vs/workbench/contrib/surveys/browser/survey.contribution.ts @@ -6,6 +6,7 @@ import { localize, localize2 } from '../../../../nls.js'; import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { IsDevelopmentContext } from '../../../../platform/contextkey/common/contextkeys.js'; import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; @@ -38,6 +39,7 @@ class OpenSurveyAction extends Action2 { title: localize2('openSurvey', "Open Survey"), category: Categories.Developer, f1: true, + precondition: IsDevelopmentContext, }); } diff --git a/src/vs/workbench/contrib/surveys/browser/surveyEditorPane.ts b/src/vs/workbench/contrib/surveys/browser/surveyEditorPane.ts index 03ab708614c5a8..fc9e71f54c7d81 100644 --- a/src/vs/workbench/contrib/surveys/browser/surveyEditorPane.ts +++ b/src/vs/workbench/contrib/surveys/browser/surveyEditorPane.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import './media/surveyEditorPane.css'; +import { status } from '../../../../base/browser/ui/aria/aria.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; import { $, addDisposableListener, append, clearNode } from '../../../../base/browser/dom.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Codicon } from '../../../../base/common/codicons.js'; @@ -17,6 +19,7 @@ import { IEditorGroup } from '../../../services/editor/common/editorGroupsServic import { IEditorOpenContext } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; import { IEditorOptions } from '../../../../platform/editor/common/editor.js'; +import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { ISurveyDefinition, ISurveyQuestion, ISurveyRadioQuestion, ISurveySegmentQuestion, SurveyQuestionType } from './surveyQuestions.js'; import { SurveyEditorInput } from './surveyEditorInput.js'; @@ -77,10 +80,11 @@ export class SurveyEditorPane extends EditorPane { private renderForm(container: HTMLElement, survey: ISurveyDefinition): void { const form = append(container, $('div.survey-form')); - // Title with icon + // Title with decorative icon const title = append(form, $('div.survey-title')); const titleIcon = append(title, $('span.survey-title-icon')); titleIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.sparkle)); + titleIcon.setAttribute('aria-hidden', 'true'); const titleText = append(title, $('span')); titleText.textContent = survey.title; @@ -92,20 +96,19 @@ export class SurveyEditorPane extends EditorPane { this.renderQuestion(form, question); } - // Submit button + // Submit button (shared Button widget for consistent theming) const submitRow = append(form, $('div.survey-submit-row')); - const submitButton = append(submitRow, $('button.survey-submit-button')) as HTMLButtonElement; - submitButton.textContent = localize('survey.submitFeedback', "Submit feedback"); - submitButton.type = 'button'; - submitButton.disabled = true; + const submitButton = this.inputDisposables.add(new Button(submitRow, { ...defaultButtonStyles })); + submitButton.label = localize('survey.submitFeedback', "Submit feedback"); + submitButton.enabled = false; const updateSubmitState = () => { - submitButton.disabled = this.answers.size < survey.questions.length - || [...this.answers.values()].some(v => v.length === 0); + submitButton.enabled = this.answers.size >= survey.questions.length + && ![...this.answers.values()].some(v => v.length === 0); }; - this.inputDisposables.add(addDisposableListener(submitButton, 'click', () => { - submitButton.disabled = true; + this.inputDisposables.add(submitButton.onDidClick(() => { + submitButton.enabled = false; this.handleSubmit(container, survey); })); @@ -139,20 +142,21 @@ export class SurveyEditorPane extends EditorPane { group.setAttribute('role', 'radiogroup'); group.setAttribute('aria-labelledby', labelId); - for (const option of question.options) { + for (let i = 0; i < question.options.length; i++) { + const option = question.options[i]; const radio = append(group, $('input.survey-segment-input')) as HTMLInputElement; radio.type = 'radio'; radio.name = namePrefix; - radio.value = option; - radio.id = `survey-seg-${namePrefix}-${option.replace(/\s+/g, '-').toLowerCase()}`; + radio.value = option.id; + radio.id = `survey-seg-${namePrefix}-${i}`; const optionLabel = append(group, $('label.survey-segment-label')) as HTMLLabelElement; optionLabel.htmlFor = radio.id; - optionLabel.textContent = option; + optionLabel.textContent = option.label; this.inputDisposables.add(addDisposableListener(radio, 'change', () => { if (radio.checked) { - this.answers.set(question.id, [option]); + this.answers.set(question.id, [option.id]); } })); } @@ -167,27 +171,28 @@ export class SurveyEditorPane extends EditorPane { group.classList.add('columns-2'); } - for (const option of question.options) { + for (let i = 0; i < question.options.length; i++) { + const option = question.options[i]; const optionLabel = append(group, $('label.survey-list-option')) as HTMLLabelElement; const radio = append(optionLabel, $('input.survey-list-input')) as HTMLInputElement; radio.type = 'radio'; radio.name = namePrefix; - radio.value = option; + radio.value = option.id; const text = append(optionLabel, $('span')); - text.textContent = option; + text.textContent = option.label; this.inputDisposables.add(addDisposableListener(radio, 'change', () => { if (radio.checked) { - this.answers.set(question.id, [option]); + this.answers.set(question.id, [option.id]); } })); } } private handleSubmit(container: HTMLElement, survey: ISurveyDefinition): void { - // Snapshot answers + // Snapshot answers at submit time const answersSnapshot: Record = {}; for (const [key, value] of this.answers) { answersSnapshot[key] = [...value]; @@ -199,7 +204,7 @@ export class SurveyEditorPane extends EditorPane { }; type SurveySubmitClassification = { surveyId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The survey identifier.' }; - answers: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'JSON-encoded survey answers (all values are string arrays).' }; + answers: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'JSON-encoded survey answers keyed by question ID with stable option ID arrays.' }; owner: 'digitarald'; comment: 'Tracks in-product survey submissions for product-market fit analysis.'; }; @@ -216,16 +221,25 @@ export class SurveyEditorPane extends EditorPane { clearNode(container); const success = append(container, $('div.survey-success')); + success.setAttribute('role', 'status'); + success.setAttribute('aria-live', 'polite'); const icon = append(success, $('div.survey-success-icon')); icon.classList.add(...ThemeIcon.asClassNameArray(Codicon.checkAll)); + icon.setAttribute('aria-hidden', 'true'); + const successMessage = localize('survey.success.message', "Response sent"); const message = append(success, $('div.survey-success-message')); - message.textContent = localize('survey.success.message', "Response sent"); + message.textContent = successMessage; const detail = append(success, $('div.survey-success-detail')); detail.textContent = localize('survey.success.detail', "Your answer helps us understand who needs this most. Thank you."); + // Announce to screen readers and move focus to success container + status(successMessage); + success.tabIndex = -1; + success.focus(); + // Auto-close after 3 seconds const timeout = setTimeout(() => { if (submittedInput) { diff --git a/src/vs/workbench/contrib/surveys/browser/surveyQuestions.ts b/src/vs/workbench/contrib/surveys/browser/surveyQuestions.ts index 703e3f8b2f08d4..d322717fa0a5c1 100644 --- a/src/vs/workbench/contrib/surveys/browser/surveyQuestions.ts +++ b/src/vs/workbench/contrib/surveys/browser/surveyQuestions.ts @@ -3,23 +3,30 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { localize } from '../../../../nls.js'; + export const enum SurveyQuestionType { Segment = 'segment', Radio = 'radio', } +export interface ISurveyOption { + readonly id: string; + readonly label: string; +} + export interface ISurveySegmentQuestion { readonly type: SurveyQuestionType.Segment; readonly id: string; readonly label: string; - readonly options: readonly string[]; + readonly options: readonly ISurveyOption[]; } export interface ISurveyRadioQuestion { readonly type: SurveyQuestionType.Radio; readonly id: string; readonly label: string; - readonly options: readonly string[]; + readonly options: readonly ISurveyOption[]; readonly columns?: number; } @@ -38,52 +45,52 @@ export interface ISurveyDefinition { */ export const CopilotPMFSurvey: ISurveyDefinition = { id: 'copilot-pmf', - title: 'Help Us Improve GitHub Copilot', - description: 'This short survey helps us understand how well Copilot fits into your workflow.', + title: localize('survey.copilotPmf.title', "Help Us Improve GitHub Copilot"), + description: localize('survey.copilotPmf.description', "This short survey helps us understand how well Copilot fits into your workflow."), questions: [ { type: SurveyQuestionType.Segment, id: 'disappointment', - label: 'How disappointed would you be if you could no longer use Copilot?', + label: localize('survey.copilotPmf.q1', "How disappointed would you be if you could no longer use Copilot?"), options: [ - 'Not at all', - 'Slightly', - 'Somewhat', - 'Very', - 'Extremely', + { id: 'not-at-all', label: localize('survey.copilotPmf.q1.notAtAll', "Not at all") }, + { id: 'slightly', label: localize('survey.copilotPmf.q1.slightly', "Slightly") }, + { id: 'somewhat', label: localize('survey.copilotPmf.q1.somewhat', "Somewhat") }, + { id: 'very', label: localize('survey.copilotPmf.q1.very', "Very") }, + { id: 'extremely', label: localize('survey.copilotPmf.q1.extremely', "Extremely") }, ], }, { type: SurveyQuestionType.Radio, id: 'main-benefit', - label: 'What has Copilot helped you with most recently?', + label: localize('survey.copilotPmf.q2', "What has Copilot helped you with most recently?"), columns: 2, options: [ - 'Shipping changes faster', - 'Getting unstuck on bugs', - 'Making multi-file changes', - 'Automating repetitive work', - 'Understanding the codebase', - 'Planning an approach', - 'Improving or reviewing code', - 'I haven\'t gotten clear value yet', + { id: 'shipping-faster', label: localize('survey.copilotPmf.q2.shippingFaster', "Shipping changes faster") }, + { id: 'getting-unstuck', label: localize('survey.copilotPmf.q2.gettingUnstuck', "Getting unstuck on bugs") }, + { id: 'multi-file', label: localize('survey.copilotPmf.q2.multiFile', "Making multi-file changes") }, + { id: 'automating', label: localize('survey.copilotPmf.q2.automating', "Automating repetitive work") }, + { id: 'understanding', label: localize('survey.copilotPmf.q2.understanding', "Understanding the codebase") }, + { id: 'planning', label: localize('survey.copilotPmf.q2.planning', "Planning an approach") }, + { id: 'reviewing', label: localize('survey.copilotPmf.q2.reviewing', "Improving or reviewing code") }, + { id: 'no-clear-value', label: localize('survey.copilotPmf.q2.noClearValue', "I haven't gotten clear value yet") }, ], }, { type: SurveyQuestionType.Radio, id: 'blockers', - label: 'What most gets in your way?', + label: localize('survey.copilotPmf.q3', "What most gets in your way?"), columns: 2, options: [ - 'Output is hard to trust', - 'Missing repo or project context', - 'Struggles with bigger tasks', - 'Too much time reviewing', - 'Too much steering needed', - 'Too slow / breaks flow', - 'Setup or integrations are hard', - 'Security or permissions friction', - 'Limits, cost, or billing', + { id: 'trust', label: localize('survey.copilotPmf.q3.trust', "Output is hard to trust") }, + { id: 'context', label: localize('survey.copilotPmf.q3.context', "Missing repo or project context") }, + { id: 'bigger-tasks', label: localize('survey.copilotPmf.q3.biggerTasks', "Struggles with bigger tasks") }, + { id: 'reviewing-time', label: localize('survey.copilotPmf.q3.reviewingTime', "Too much time reviewing") }, + { id: 'steering', label: localize('survey.copilotPmf.q3.steering', "Too much steering needed") }, + { id: 'slow', label: localize('survey.copilotPmf.q3.slow', "Too slow / breaks flow") }, + { id: 'setup', label: localize('survey.copilotPmf.q3.setup', "Setup or integrations are hard") }, + { id: 'security', label: localize('survey.copilotPmf.q3.security', "Security or permissions friction") }, + { id: 'cost', label: localize('survey.copilotPmf.q3.cost', "Limits, cost, or billing") }, ], }, ], From 52e2173e61d1f4e546a2a2825792397a75ed6782 Mon Sep 17 00:00:00 2001 From: digitarald Date: Fri, 19 Jun 2026 20:04:32 -0700 Subject: [PATCH 03/13] Fix responsive layout and accessibility Responsive: - Remove broken flex-wrap on segment control (caused 2x2+1 orphan grid) - At narrow widths, segment stacks vertically (flex-direction: column) with full-width items and proper border-radius per edge - Segment labels get min-width: 0 + text-overflow: ellipsis so they shrink gracefully in horizontal mode without wrapping - Remove overflow: hidden from segment group (was clipping focus rings) Accessibility: - Add role=form + aria-label on the editor pane container - Increase auto-close timeout to 5s (gives screen readers time to read) - Add position: relative on focus-visible segments for z-index stacking - Segment labels get explicit border-radius on first/last (no overflow hidden needed) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/media/surveyEditorPane.css | 30 +++++++++++++++---- .../surveys/browser/surveyEditorPane.ts | 6 ++-- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/vs/workbench/contrib/surveys/browser/media/surveyEditorPane.css b/src/vs/workbench/contrib/surveys/browser/media/surveyEditorPane.css index 6fa6e542991cb8..0ffd9d8643a014 100644 --- a/src/vs/workbench/contrib/surveys/browser/media/surveyEditorPane.css +++ b/src/vs/workbench/contrib/surveys/browser/media/surveyEditorPane.css @@ -56,7 +56,6 @@ display: flex; border: var(--vscode-strokeThickness) solid var(--vscode-radio-inactiveBorder, var(--vscode-input-border, transparent)); border-radius: var(--vscode-cornerRadius-small, 4px); - overflow: hidden; } .survey-editor-pane .survey-segment-group .survey-segment-input { @@ -73,18 +72,27 @@ .survey-editor-pane .survey-segment-group .survey-segment-label { flex: 1; + min-width: 0; text-align: center; - padding: 8px 12px; + padding: 8px 6px; cursor: pointer; border-right: var(--vscode-strokeThickness) solid var(--vscode-radio-inactiveBorder, var(--vscode-input-border, transparent)); color: var(--vscode-radio-inactiveForeground, inherit); background-color: var(--vscode-radio-inactiveBackground, transparent); transition: background-color 0.1s; user-select: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.survey-editor-pane .survey-segment-group .survey-segment-label:first-of-type { + border-radius: var(--vscode-cornerRadius-small, 4px) 0 0 var(--vscode-cornerRadius-small, 4px); } .survey-editor-pane .survey-segment-group .survey-segment-label:last-of-type { border-right: none; + border-radius: 0 var(--vscode-cornerRadius-small, 4px) var(--vscode-cornerRadius-small, 4px) 0; } .survey-editor-pane .survey-segment-group .survey-segment-label:hover { @@ -101,6 +109,7 @@ outline: 2px solid var(--vscode-focusBorder); outline-offset: -2px; z-index: 1; + position: relative; } /* HC: distinct border on selected segment */ @@ -226,11 +235,22 @@ } .survey-editor-pane .survey-segment-group { - flex-wrap: wrap; + flex-direction: column; } .survey-editor-pane .survey-segment-group .survey-segment-label { - flex-basis: 33%; - flex-grow: 1; + border-right: none; + border-bottom: var(--vscode-strokeThickness) solid var(--vscode-radio-inactiveBorder, var(--vscode-input-border, transparent)); + text-align: left; + padding: 8px 12px; + } + + .survey-editor-pane .survey-segment-group .survey-segment-label:first-of-type { + border-radius: var(--vscode-cornerRadius-small, 4px) var(--vscode-cornerRadius-small, 4px) 0 0; + } + + .survey-editor-pane .survey-segment-group .survey-segment-label:last-of-type { + border-bottom: none; + border-radius: 0 0 var(--vscode-cornerRadius-small, 4px) var(--vscode-cornerRadius-small, 4px); } } diff --git a/src/vs/workbench/contrib/surveys/browser/surveyEditorPane.ts b/src/vs/workbench/contrib/surveys/browser/surveyEditorPane.ts index fc9e71f54c7d81..df2843c21614b0 100644 --- a/src/vs/workbench/contrib/surveys/browser/surveyEditorPane.ts +++ b/src/vs/workbench/contrib/surveys/browser/surveyEditorPane.ts @@ -46,6 +46,8 @@ export class SurveyEditorPane extends EditorPane { protected override createEditor(parent: HTMLElement): void { this.container = append(parent, $('div.survey-editor-pane')); + this.container.setAttribute('role', 'form'); + this.container.setAttribute('aria-label', localize('survey.pane.ariaLabel', "Survey")); } override async setInput( @@ -240,12 +242,12 @@ export class SurveyEditorPane extends EditorPane { success.tabIndex = -1; success.focus(); - // Auto-close after 3 seconds + // Auto-close after 5 seconds (longer than visual to allow screen readers to finish) const timeout = setTimeout(() => { if (submittedInput) { this.editorService.closeEditor({ editor: submittedInput, groupId: this.group.id }); } - }, 3000); + }, 5000); this.inputDisposables.add({ dispose: () => clearTimeout(timeout) }); } From 3aba62f1655df939ac72ff1f3530b8567fb9e276 Mon Sep 17 00:00:00 2001 From: digitarald Date: Sat, 20 Jun 2026 12:10:20 -0700 Subject: [PATCH 04/13] Fix review findings: focus, URI, error handling, a11y Engineering: - Override focus() to move focus to first radio when survey opens - Make resource URI dynamic (vscode-survey:/{surveyId}) to prevent collisions across multiple survey definitions - Guard closeEditor in auto-close timeout with .catch(onUnexpectedError) Accessibility: - Add aria-required=true on all radio groups - Fix HC focus-visible vs checked outline conflict: checked uses 1px solid contrastActiveBorder, focus-visible uses 2px dashed focusBorder - Add completion hint ('Answer all questions to submit') with aria-describedby on the submit button; hides once all answered Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/media/surveyEditorPane.css | 20 +++++++++++++ .../surveys/browser/surveyEditorInput.ts | 3 +- .../surveys/browser/surveyEditorPane.ts | 30 +++++++++++++++++-- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/surveys/browser/media/surveyEditorPane.css b/src/vs/workbench/contrib/surveys/browser/media/surveyEditorPane.css index 0ffd9d8643a014..1f9fcf4f797bb0 100644 --- a/src/vs/workbench/contrib/surveys/browser/media/surveyEditorPane.css +++ b/src/vs/workbench/contrib/surveys/browser/media/surveyEditorPane.css @@ -119,6 +119,13 @@ outline-offset: -1px; } +/* HC: focus must be visually distinct from checked */ +.hc-black .survey-editor-pane .survey-segment-group .survey-segment-input:focus-visible + .survey-segment-label, +.hc-light .survey-editor-pane .survey-segment-group .survey-segment-input:focus-visible + .survey-segment-label { + outline: 2px dashed var(--vscode-focusBorder); + outline-offset: -2px; +} + /* List-row selection — full-width clickable rows with hidden native radios */ .survey-editor-pane .survey-list-group { @@ -175,12 +182,25 @@ outline-offset: -1px; } +/* HC: focus must be visually distinct from checked */ +.hc-black .survey-editor-pane .survey-list-option:has(.survey-list-input:focus-visible), +.hc-light .survey-editor-pane .survey-list-option:has(.survey-list-input:focus-visible) { + outline: 2px dashed var(--vscode-focusBorder); + outline-offset: -1px; +} + /* Submit */ .survey-editor-pane .survey-submit-row { margin-top: 28px; } +.survey-editor-pane .survey-submit-hint { + margin-top: 8px; + color: var(--vscode-descriptionForeground); + font-size: 12px; +} + /* Success / "sent" state */ .survey-editor-pane .survey-success { diff --git a/src/vs/workbench/contrib/surveys/browser/surveyEditorInput.ts b/src/vs/workbench/contrib/surveys/browser/surveyEditorInput.ts index 6864bb2f2d5736..0119845469d261 100644 --- a/src/vs/workbench/contrib/surveys/browser/surveyEditorInput.ts +++ b/src/vs/workbench/contrib/surveys/browser/surveyEditorInput.ts @@ -17,7 +17,6 @@ const surveyIcon = registerIcon('survey', Codicon.feedback, localize('surveyIcon export class SurveyEditorInput extends EditorInput { static readonly ID = 'workbench.input.survey'; - static readonly RESOURCE = URI.from({ scheme: 'vscode-survey', path: 'survey' }); constructor( readonly survey: ISurveyDefinition, @@ -34,7 +33,7 @@ export class SurveyEditorInput extends EditorInput { } override get resource(): URI | undefined { - return SurveyEditorInput.RESOURCE; + return URI.from({ scheme: 'vscode-survey', path: `/${this.survey.id}` }); } override getName(): string { diff --git a/src/vs/workbench/contrib/surveys/browser/surveyEditorPane.ts b/src/vs/workbench/contrib/surveys/browser/surveyEditorPane.ts index df2843c21614b0..adff42c0d6ac68 100644 --- a/src/vs/workbench/contrib/surveys/browser/surveyEditorPane.ts +++ b/src/vs/workbench/contrib/surveys/browser/surveyEditorPane.ts @@ -9,6 +9,7 @@ import { Button } from '../../../../base/browser/ui/button/button.js'; import { $, addDisposableListener, append, clearNode } from '../../../../base/browser/dom.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Codicon } from '../../../../base/common/codicons.js'; +import { onUnexpectedError } from '../../../../base/common/errors.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { localize } from '../../../../nls.js'; import { IStorageService } from '../../../../platform/storage/common/storage.js'; @@ -30,6 +31,7 @@ export class SurveyEditorPane extends EditorPane { static readonly ID = 'workbench.editor.survey'; private container: HTMLElement | undefined; + private firstInput: HTMLInputElement | undefined; private readonly inputDisposables = this._register(new DisposableStore()); private answers: Map = new Map(); private renderNonce = 0; @@ -73,6 +75,7 @@ export class SurveyEditorPane extends EditorPane { private resetState(): void { this.inputDisposables.clear(); this.answers.clear(); + this.firstInput = undefined; this.renderNonce++; if (this.container) { clearNode(this.container); @@ -104,9 +107,17 @@ export class SurveyEditorPane extends EditorPane { submitButton.label = localize('survey.submitFeedback', "Submit feedback"); submitButton.enabled = false; + const hintId = `survey-hint-${this.renderNonce}`; + const hint = append(submitRow, $('div.survey-submit-hint')); + hint.id = hintId; + hint.textContent = localize('survey.submitHint', "Answer all questions to submit"); + submitButton.element.setAttribute('aria-describedby', hintId); + const updateSubmitState = () => { - submitButton.enabled = this.answers.size >= survey.questions.length + const allAnswered = this.answers.size >= survey.questions.length && ![...this.answers.values()].some(v => v.length === 0); + submitButton.enabled = allAnswered; + hint.style.display = allAnswered ? 'none' : ''; }; this.inputDisposables.add(submitButton.onDidClick(() => { @@ -143,6 +154,7 @@ export class SurveyEditorPane extends EditorPane { const group = append(parent, $('div.survey-segment-group')); group.setAttribute('role', 'radiogroup'); group.setAttribute('aria-labelledby', labelId); + group.setAttribute('aria-required', 'true'); for (let i = 0; i < question.options.length; i++) { const option = question.options[i]; @@ -152,6 +164,10 @@ export class SurveyEditorPane extends EditorPane { radio.value = option.id; radio.id = `survey-seg-${namePrefix}-${i}`; + if (!this.firstInput) { + this.firstInput = radio; + } + const optionLabel = append(group, $('label.survey-segment-label')) as HTMLLabelElement; optionLabel.htmlFor = radio.id; optionLabel.textContent = option.label; @@ -168,6 +184,7 @@ export class SurveyEditorPane extends EditorPane { const group = append(parent, $('div.survey-list-group')); group.setAttribute('role', 'radiogroup'); group.setAttribute('aria-labelledby', labelId); + group.setAttribute('aria-required', 'true'); if (question.columns === 2) { group.classList.add('columns-2'); @@ -182,6 +199,10 @@ export class SurveyEditorPane extends EditorPane { radio.name = namePrefix; radio.value = option.id; + if (!this.firstInput) { + this.firstInput = radio; + } + const text = append(optionLabel, $('span')); text.textContent = option.label; @@ -245,13 +266,18 @@ export class SurveyEditorPane extends EditorPane { // Auto-close after 5 seconds (longer than visual to allow screen readers to finish) const timeout = setTimeout(() => { if (submittedInput) { - this.editorService.closeEditor({ editor: submittedInput, groupId: this.group.id }); + this.editorService.closeEditor({ editor: submittedInput, groupId: this.group.id }).catch(onUnexpectedError); } }, 5000); this.inputDisposables.add({ dispose: () => clearTimeout(timeout) }); } + override focus(): void { + super.focus(); + this.firstInput?.focus(); + } + override layout(): void { // no-op: CSS handles sizing } From 84e34a95105f2825ab8cd25bb4ba562bd1cd866b Mon Sep 17 00:00:00 2001 From: digitarald Date: Sat, 20 Jun 2026 12:14:08 -0700 Subject: [PATCH 05/13] fix: prevent segment control wrapping with explicit flex-wrap: nowrap Add position: relative to contain absolutely-positioned radio inputs, and flex-wrap: nowrap to ensure 5 segment items always stay in a single row at widths above the 600px container-query breakpoint. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/surveys/browser/media/surveyEditorPane.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/vs/workbench/contrib/surveys/browser/media/surveyEditorPane.css b/src/vs/workbench/contrib/surveys/browser/media/surveyEditorPane.css index 1f9fcf4f797bb0..dfc5baa045e005 100644 --- a/src/vs/workbench/contrib/surveys/browser/media/surveyEditorPane.css +++ b/src/vs/workbench/contrib/surveys/browser/media/surveyEditorPane.css @@ -53,7 +53,9 @@ /* Segmented control — uses --vscode-radio-* tokens from the Radio widget palette */ .survey-editor-pane .survey-segment-group { + position: relative; display: flex; + flex-wrap: nowrap; border: var(--vscode-strokeThickness) solid var(--vscode-radio-inactiveBorder, var(--vscode-input-border, transparent)); border-radius: var(--vscode-cornerRadius-small, 4px); } From 52882a57c4069362cf6749fdf22581dfe09cbad8 Mon Sep 17 00:00:00 2001 From: digitarald Date: Sat, 20 Jun 2026 12:59:44 -0700 Subject: [PATCH 06/13] fix: list selection style, compact spacing, scroll visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove one-sided border-radius + left-accent on selected list rows; use full rectangular border matching VS Code's native list selection - Reduce row padding (8px→6px), gap (4px→2px), question margin (24→20px) and submit margin (28→20px) for a more compact layout - Add 60px bottom padding to ensure the Submit button is always reachable when scrolling - Split container queries: segment stacks at 560px, list grid collapses to single-column at 480px (was 600px for both — too aggressive) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/media/surveyEditorPane.css | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/vs/workbench/contrib/surveys/browser/media/surveyEditorPane.css b/src/vs/workbench/contrib/surveys/browser/media/surveyEditorPane.css index dfc5baa045e005..c47d9c262e0d26 100644 --- a/src/vs/workbench/contrib/surveys/browser/media/surveyEditorPane.css +++ b/src/vs/workbench/contrib/surveys/browser/media/surveyEditorPane.css @@ -9,7 +9,7 @@ display: flex; justify-content: center; align-items: flex-start; - padding: 40px 20px; + padding: 40px 20px 60px; } .survey-editor-pane .survey-form { @@ -41,7 +41,7 @@ /* Questions */ .survey-editor-pane .survey-question { - margin-bottom: 24px; + margin-bottom: 20px; } .survey-editor-pane .survey-question-label { @@ -133,7 +133,7 @@ .survey-editor-pane .survey-list-group { display: grid; grid-template-columns: 1fr; - gap: 4px; + gap: 2px; } .survey-editor-pane .survey-list-group.columns-2 { @@ -142,9 +142,8 @@ .survey-editor-pane .survey-list-option { display: block; - padding: 8px 10px; - border-radius: var(--vscode-cornerRadius-small, 4px); - border-left: 2px solid transparent; + padding: 6px 10px; + border: 1px solid transparent; cursor: pointer; transition: background-color 0.1s; user-select: none; @@ -169,7 +168,7 @@ .survey-editor-pane .survey-list-option:has(.survey-list-input:checked) { background-color: var(--vscode-list-activeSelectionBackground); color: var(--vscode-list-activeSelectionForeground); - border-left-color: var(--vscode-focusBorder); + border-color: var(--vscode-focusBorder); } .survey-editor-pane .survey-list-option:has(.survey-list-input:focus-visible) { @@ -194,7 +193,7 @@ /* Submit */ .survey-editor-pane .survey-submit-row { - margin-top: 28px; + margin-top: 20px; } .survey-editor-pane .survey-submit-hint { @@ -249,13 +248,9 @@ } } -/* Responsive: collapse to single column on narrow editors */ - -@container (max-width: 600px) { - .survey-editor-pane .survey-list-group.columns-2 { - grid-template-columns: 1fr; - } +/* Responsive: segment stacks vertically on narrow editors */ +@container (max-width: 560px) { .survey-editor-pane .survey-segment-group { flex-direction: column; } @@ -276,3 +271,11 @@ border-radius: 0 0 var(--vscode-cornerRadius-small, 4px) var(--vscode-cornerRadius-small, 4px); } } + +/* Responsive: list grid collapses to single column on very narrow editors */ + +@container (max-width: 480px) { + .survey-editor-pane .survey-list-group.columns-2 { + grid-template-columns: 1fr; + } +} From 751042b26cb081a408e49158951d04e4e1b8a06a Mon Sep 17 00:00:00 2001 From: digitarald Date: Sat, 20 Jun 2026 13:27:30 -0700 Subject: [PATCH 07/13] Address review: accessibility help, design tokens, compact styling - Add SurveyAccessibilityHelp with AccessibleViewProviderId.Survey and AccessibilityVerbositySettingId.Survey for screen reader discovery - Use var(--vscode-strokeThickness) for list-option border - Use var(--vscode-codiconFontSize) + transform: scale(2) for success icon - Reduce list-option padding to 6px 10px (on spacing ramp) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../accessibility/browser/accessibleView.ts | 1 + .../browser/accessibilityConfiguration.ts | 7 ++++- .../browser/media/surveyEditorPane.css | 8 +++-- .../surveys/browser/survey.contribution.ts | 29 +++++++++++++++++++ 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/vs/platform/accessibility/browser/accessibleView.ts b/src/vs/platform/accessibility/browser/accessibleView.ts index b06f0c2ce6542e..109b4d8703aa86 100644 --- a/src/vs/platform/accessibility/browser/accessibleView.ts +++ b/src/vs/platform/accessibility/browser/accessibleView.ts @@ -47,6 +47,7 @@ export const enum AccessibleViewProviderId { OutputFindHelp = 'outputFindHelp', ProblemsFilterHelp = 'problemsFilterHelp', SessionsChat = 'sessionsChat', + Survey = 'survey', } export const enum AccessibleViewType { diff --git a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts index 2e66c87a17c578..b7aba2326e6bc8 100644 --- a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts +++ b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts @@ -69,7 +69,8 @@ export const enum AccessibilityVerbositySettingId { SourceControl = 'accessibility.verbosity.sourceControl', Find = 'accessibility.verbosity.find', SessionsChat = 'accessibility.verbosity.sessionsChat', - ChatQuestionCarousel = 'accessibility.verbosity.chatQuestionCarousel' + ChatQuestionCarousel = 'accessibility.verbosity.chatQuestionCarousel', + Survey = 'accessibility.verbosity.survey' } const baseVerbosityProperty: IConfigurationPropertySchema = { @@ -215,6 +216,10 @@ const configuration: IConfigurationNode = { description: localize('verbosity.chatQuestionCarousel', 'Provide information about how to navigate and interact with the chat question carousel, including how to focus the terminal when applicable.'), ...baseVerbosityProperty }, + [AccessibilityVerbositySettingId.Survey]: { + description: localize('verbosity.survey', 'Provide information about how to navigate and interact with the survey editor pane.'), + ...baseVerbosityProperty + }, 'accessibility.signalOptions.volume': { 'description': localize('accessibility.signalOptions.volume', "The volume of the sounds in percent (0-100)."), 'type': 'number', diff --git a/src/vs/workbench/contrib/surveys/browser/media/surveyEditorPane.css b/src/vs/workbench/contrib/surveys/browser/media/surveyEditorPane.css index c47d9c262e0d26..b9f61286560aaa 100644 --- a/src/vs/workbench/contrib/surveys/browser/media/surveyEditorPane.css +++ b/src/vs/workbench/contrib/surveys/browser/media/surveyEditorPane.css @@ -143,7 +143,7 @@ .survey-editor-pane .survey-list-option { display: block; padding: 6px 10px; - border: 1px solid transparent; + border: var(--vscode-strokeThickness) solid transparent; cursor: pointer; transition: background-color 0.1s; user-select: none; @@ -215,9 +215,11 @@ } .survey-editor-pane .survey-success .survey-success-icon { - font-size: 32px; + font-size: var(--vscode-codiconFontSize, 16px); + transform: scale(2); + transform-origin: center; color: var(--vscode-charts-green); - margin-bottom: 12px; + margin-bottom: 24px; } .survey-editor-pane .survey-success .survey-success-message { diff --git a/src/vs/workbench/contrib/surveys/browser/survey.contribution.ts b/src/vs/workbench/contrib/surveys/browser/survey.contribution.ts index 43d46c2747da85..67ba6f6eb54915 100644 --- a/src/vs/workbench/contrib/surveys/browser/survey.contribution.ts +++ b/src/vs/workbench/contrib/surveys/browser/survey.contribution.ts @@ -5,12 +5,16 @@ import { localize, localize2 } from '../../../../nls.js'; import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; +import { AccessibleContentProvider, AccessibleViewProviderId, AccessibleViewType } from '../../../../platform/accessibility/browser/accessibleView.js'; +import { AccessibleViewRegistry, IAccessibleViewImplementation } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { IsDevelopmentContext } from '../../../../platform/contextkey/common/contextkeys.js'; import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; +import { AccessibilityVerbositySettingId } from '../../accessibility/browser/accessibilityConfiguration.js'; import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../browser/editor.js'; +import { ActiveEditorContext } from '../../../common/contextkeys.js'; import { EditorExtensions } from '../../../common/editor.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js'; @@ -61,3 +65,28 @@ class OpenSurveyAction extends Action2 { } registerAction2(OpenSurveyAction); + +// Accessibility help for the survey pane +class SurveyAccessibilityHelp implements IAccessibleViewImplementation { + readonly priority = 100; + readonly name = 'survey'; + readonly type = AccessibleViewType.Help; + readonly when = ActiveEditorContext.isEqualTo(SurveyEditorPane.ID); + + getProvider(_accessor: ServicesAccessor) { + const helpText = [ + localize('survey.help.overview', "You are in a survey form. Use Tab to move between questions and options."), + localize('survey.help.select', "Use arrow keys within a question to navigate between options, and Space or Enter to select."), + localize('survey.help.submit', "Tab to the Submit button and press Enter once all questions are answered."), + ].join('\n'); + return new AccessibleContentProvider( + AccessibleViewProviderId.Survey, + { type: AccessibleViewType.Help }, + () => helpText, + () => { /* focus returns to survey pane automatically */ }, + AccessibilityVerbositySettingId.Survey, + ); + } +} + +AccessibleViewRegistry.register(new SurveyAccessibilityHelp()); From 465a6db27c1a98f55eae0855d2b81a02a39c22ee Mon Sep 17 00:00:00 2001 From: digitarald Date: Sat, 20 Jun 2026 15:08:44 -0700 Subject: [PATCH 08/13] feat: track source signal, programmatic command, structured telemetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 'source' field to SurveyEditorInput so the triggering feature (completions, panel.agent, agent.codeEdit) is captured with the response - Register '_workbench.action.openCopilotSurvey' command for the Copilot extension to open the in-editor survey instead of external URL - Restructure telemetry: extract pmfScore, primaryBenefit, primaryFriction as top-level fields for direct Kusto querying (keep JSON blob for forward-compat) - Rename question IDs: main-benefit→primary-benefit, blockers→primary-friction - Add pmfQuestionId to ISurveyDefinition schema Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../surveys/browser/survey.contribution.ts | 38 ++++++++++++------- .../surveys/browser/surveyEditorInput.ts | 2 + .../surveys/browser/surveyEditorPane.ts | 22 ++++++++++- .../surveys/browser/surveyQuestions.ts | 7 +++- 4 files changed, 52 insertions(+), 17 deletions(-) diff --git a/src/vs/workbench/contrib/surveys/browser/survey.contribution.ts b/src/vs/workbench/contrib/surveys/browser/survey.contribution.ts index 67ba6f6eb54915..db11458a1d0389 100644 --- a/src/vs/workbench/contrib/surveys/browser/survey.contribution.ts +++ b/src/vs/workbench/contrib/surveys/browser/survey.contribution.ts @@ -8,6 +8,7 @@ import { Categories } from '../../../../platform/action/common/actionCommonCateg import { AccessibleContentProvider, AccessibleViewProviderId, AccessibleViewType } from '../../../../platform/accessibility/browser/accessibleView.js'; import { AccessibleViewRegistry, IAccessibleViewImplementation } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { CommandsRegistry } from '../../../../platform/commands/common/commands.js'; import { IsDevelopmentContext } from '../../../../platform/contextkey/common/contextkeys.js'; import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; @@ -33,7 +34,7 @@ Registry.as(EditorExtensions.EditorPane).registerEditorPane [new SyncDescriptor(SurveyEditorInput)] ); -// Register test command +// Register test command (developer-only, visible in command palette) class OpenSurveyAction extends Action2 { static readonly ID = 'workbench.action.openSurvey'; @@ -48,24 +49,33 @@ class OpenSurveyAction extends Action2 { } override async run(accessor: ServicesAccessor): Promise { - const instantiationService = accessor.get(IInstantiationService); - const editorService = accessor.get(IEditorService); - const editorGroupsService = accessor.get(IEditorGroupsService); - const environmentService = accessor.get(IWorkbenchEnvironmentService); - - const input = instantiationService.createInstance(SurveyEditorInput, CopilotPMFSurvey); - - // In the sessions window, open in the main editor part (not modal) - const preferredGroup = environmentService.isSessionsWindow - ? editorGroupsService.mainPart.activeGroup - : undefined; - - await editorService.openEditor(input, { pinned: true }, preferredGroup); + return openSurveyEditor(accessor); } } registerAction2(OpenSurveyAction); +// Programmatic command for extensions to trigger the survey (e.g. from Copilot survey service) +CommandsRegistry.registerCommand('_workbench.action.openCopilotSurvey', (accessor: ServicesAccessor, source?: string) => { + return openSurveyEditor(accessor, source); +}); + +function openSurveyEditor(accessor: ServicesAccessor, source?: string): Promise { + const instantiationService = accessor.get(IInstantiationService); + const editorService = accessor.get(IEditorService); + const editorGroupsService = accessor.get(IEditorGroupsService); + const environmentService = accessor.get(IWorkbenchEnvironmentService); + + const input = instantiationService.createInstance(SurveyEditorInput, CopilotPMFSurvey, source); + + // In the sessions window, open in the main editor part (not modal) + const preferredGroup = environmentService.isSessionsWindow + ? editorGroupsService.mainPart.activeGroup + : undefined; + + return editorService.openEditor(input, { pinned: true }, preferredGroup).then(() => undefined); +} + // Accessibility help for the survey pane class SurveyAccessibilityHelp implements IAccessibleViewImplementation { readonly priority = 100; diff --git a/src/vs/workbench/contrib/surveys/browser/surveyEditorInput.ts b/src/vs/workbench/contrib/surveys/browser/surveyEditorInput.ts index 0119845469d261..016effdc7a1f62 100644 --- a/src/vs/workbench/contrib/surveys/browser/surveyEditorInput.ts +++ b/src/vs/workbench/contrib/surveys/browser/surveyEditorInput.ts @@ -20,6 +20,8 @@ export class SurveyEditorInput extends EditorInput { constructor( readonly survey: ISurveyDefinition, + /** The Copilot feature source that triggered this survey (e.g. 'completions', 'panel.agent', 'agent.codeEdit'). */ + readonly source?: string, ) { super(); } diff --git a/src/vs/workbench/contrib/surveys/browser/surveyEditorPane.ts b/src/vs/workbench/contrib/surveys/browser/surveyEditorPane.ts index adff42c0d6ac68..49141adb9f7adb 100644 --- a/src/vs/workbench/contrib/surveys/browser/surveyEditorPane.ts +++ b/src/vs/workbench/contrib/surveys/browser/surveyEditorPane.ts @@ -221,18 +221,38 @@ export class SurveyEditorPane extends EditorPane { answersSnapshot[key] = [...value]; } + // Extract the PMF score as a top-level measure if defined + const pmfScore = survey.pmfQuestionId + ? (answersSnapshot[survey.pmfQuestionId]?.[0] ?? '') + : ''; + + // Get the source from the input (what feature triggered the survey) + const source = (this.input as SurveyEditorInput)?.source ?? ''; + type SurveySubmitEvent = { surveyId: string; + source: string; + pmfScore: string; + primaryBenefit: string; + primaryFriction: string; answers: string; }; type SurveySubmitClassification = { surveyId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The survey identifier.' }; - answers: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'JSON-encoded survey answers keyed by question ID with stable option ID arrays.' }; + source: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The Copilot feature source that triggered the survey (e.g. completions, panel.agent, agent.codeEdit).' }; + pmfScore: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The PMF disappointment score: not-at-all, slightly, somewhat, very, extremely.' }; + primaryBenefit: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The primary value driver selected by the user.' }; + primaryFriction: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The primary friction point selected by the user.' }; + answers: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'JSON-encoded full survey answers keyed by question ID with stable option ID arrays.' }; owner: 'digitarald'; comment: 'Tracks in-product survey submissions for product-market fit analysis.'; }; this.telemetryService.publicLog2('survey/submit', { surveyId: survey.id, + source, + pmfScore, + primaryBenefit: answersSnapshot['primary-benefit']?.[0] ?? '', + primaryFriction: answersSnapshot['primary-friction']?.[0] ?? '', answers: JSON.stringify(answersSnapshot), }); diff --git a/src/vs/workbench/contrib/surveys/browser/surveyQuestions.ts b/src/vs/workbench/contrib/surveys/browser/surveyQuestions.ts index d322717fa0a5c1..3e08a4c9e20e0b 100644 --- a/src/vs/workbench/contrib/surveys/browser/surveyQuestions.ts +++ b/src/vs/workbench/contrib/surveys/browser/surveyQuestions.ts @@ -37,6 +37,8 @@ export interface ISurveyDefinition { readonly title: string; readonly description: string; readonly questions: readonly ISurveyQuestion[]; + /** The question ID whose answer is the primary PMF score (reported as a top-level telemetry measure). */ + readonly pmfQuestionId?: string; } /** @@ -47,6 +49,7 @@ export const CopilotPMFSurvey: ISurveyDefinition = { id: 'copilot-pmf', title: localize('survey.copilotPmf.title', "Help Us Improve GitHub Copilot"), description: localize('survey.copilotPmf.description', "This short survey helps us understand how well Copilot fits into your workflow."), + pmfQuestionId: 'disappointment', questions: [ { type: SurveyQuestionType.Segment, @@ -62,7 +65,7 @@ export const CopilotPMFSurvey: ISurveyDefinition = { }, { type: SurveyQuestionType.Radio, - id: 'main-benefit', + id: 'primary-benefit', label: localize('survey.copilotPmf.q2', "What has Copilot helped you with most recently?"), columns: 2, options: [ @@ -78,7 +81,7 @@ export const CopilotPMFSurvey: ISurveyDefinition = { }, { type: SurveyQuestionType.Radio, - id: 'blockers', + id: 'primary-friction', label: localize('survey.copilotPmf.q3', "What most gets in your way?"), columns: 2, options: [ From 4701adc54ea85cd0562158df086855769f61c3af Mon Sep 17 00:00:00 2001 From: digitarald Date: Sat, 20 Jun 2026 15:18:27 -0700 Subject: [PATCH 09/13] fix: pmfScore as numeric 0-4, update extension to open in-editor survey - pmfScore is now a number (0=not at all, 4=extremely) with isMeasurement:true for direct Kusto aggregation - no JSON blob - Update Copilot SurveyService.promptSurvey() to call '_workbench.action.openCopilotSurvey' command instead of opening external URL - works in both main workbench and agent window - Remove unused IAuthenticationService, IEnvService, SURVEY_URI from SurveyService (no longer needed without external URL) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../survey/vscode/surveyServiceImpl.ts | 26 ++----------------- .../surveys/browser/surveyEditorPane.ts | 20 +++++++------- 2 files changed, 13 insertions(+), 33 deletions(-) diff --git a/extensions/copilot/src/platform/survey/vscode/surveyServiceImpl.ts b/extensions/copilot/src/platform/survey/vscode/surveyServiceImpl.ts index 26b87930d08680..e2bbfd1f6ccf17 100644 --- a/extensions/copilot/src/platform/survey/vscode/surveyServiceImpl.ts +++ b/extensions/copilot/src/platform/survey/vscode/surveyServiceImpl.ts @@ -4,15 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; import { l10n } from 'vscode'; -import { Uri } from '../../../vscodeTypes'; -import { IAuthenticationService } from '../../authentication/common/authentication'; -import { IEnvService } from '../../env/common/envService'; import { IVSCodeExtensionContext } from '../../extContext/common/extensionContext'; import { IExperimentationService } from '../../telemetry/common/nullExperimentationService'; import { ITelemetryService } from '../../telemetry/common/telemetry'; import { ISurveyService } from '../common/surveyService'; -const SURVEY_URI = 'https://aka.ms/vscode-gh-copilot'; const USAGE_DATA_KEY = 'survey.usage'; const NEXT_SURVEY_DATE_KEY = 'survey.nextSurveyDate'; const DAYS_14 = 14 * 24 * 60 * 60 * 1000; @@ -33,7 +29,6 @@ interface UsageData { export class SurveyService implements ISurveyService { readonly _serviceBrand: undefined; - private readonly surveyUri: vscode.Uri; private debounceTimeout: ReturnType | undefined; private readonly inactiveTimeout: ReturnType; private lastSource: string | null = null; @@ -43,11 +38,8 @@ export class SurveyService implements ISurveyService { constructor( @ITelemetryService private readonly telemetryService: ITelemetryService, @IVSCodeExtensionContext private readonly vscodeExtensionContext: IVSCodeExtensionContext, - @IEnvService private readonly envService: IEnvService, @IExperimentationService private readonly experimentationService: IExperimentationService, - @IAuthenticationService private readonly authenticationService: IAuthenticationService, ) { - this.surveyUri = Uri.parse(SURVEY_URI); this.sessionSeed = Math.random(); // Inactive survey check only runs once @@ -219,22 +211,8 @@ export class SurveyService implements ISurveyService { }); if (accepted) { - const copilotToken = await this.authenticationService.getCopilotToken(); - const params: Record = { - m: this.envService.machineId, - s: this.envService.sessionId, - k: copilotToken.sku ?? '', - d: usage.activeDays.length.toString(), - f: firstSeenInDays.toString(), - v: this.envService.getVersion(), - l: language, - src: source, - type: surveyType - }; - const surveyUriWithParams = this.surveyUri.with({ - query: new URLSearchParams(params).toString(), - }); - vscode.env.openExternal(surveyUriWithParams); + // Open in-editor survey pane with the source context + vscode.commands.executeCommand('_workbench.action.openCopilotSurvey', source); } else if (postponed) { await this.updateNextSurveyDate(DAYS_LATER); } diff --git a/src/vs/workbench/contrib/surveys/browser/surveyEditorPane.ts b/src/vs/workbench/contrib/surveys/browser/surveyEditorPane.ts index 49141adb9f7adb..e07e06dbd65fe4 100644 --- a/src/vs/workbench/contrib/surveys/browser/surveyEditorPane.ts +++ b/src/vs/workbench/contrib/surveys/browser/surveyEditorPane.ts @@ -221,10 +221,15 @@ export class SurveyEditorPane extends EditorPane { answersSnapshot[key] = [...value]; } - // Extract the PMF score as a top-level measure if defined - const pmfScore = survey.pmfQuestionId - ? (answersSnapshot[survey.pmfQuestionId]?.[0] ?? '') - : ''; + // PMF score as numeric 0-4 (index in the options array, low=not disappointed, high=extremely) + let pmfScore = -1; + if (survey.pmfQuestionId) { + const pmfQuestion = survey.questions.find(q => q.id === survey.pmfQuestionId); + const pmfAnswer = answersSnapshot[survey.pmfQuestionId]?.[0]; + if (pmfQuestion && pmfAnswer) { + pmfScore = pmfQuestion.options.findIndex(o => o.id === pmfAnswer); + } + } // Get the source from the input (what feature triggered the survey) const source = (this.input as SurveyEditorInput)?.source ?? ''; @@ -232,18 +237,16 @@ export class SurveyEditorPane extends EditorPane { type SurveySubmitEvent = { surveyId: string; source: string; - pmfScore: string; + pmfScore: number; primaryBenefit: string; primaryFriction: string; - answers: string; }; type SurveySubmitClassification = { surveyId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The survey identifier.' }; source: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The Copilot feature source that triggered the survey (e.g. completions, panel.agent, agent.codeEdit).' }; - pmfScore: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The PMF disappointment score: not-at-all, slightly, somewhat, very, extremely.' }; + pmfScore: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'PMF disappointment score 0-4 (0=not at all, 1=slightly, 2=somewhat, 3=very, 4=extremely disappointed).' }; primaryBenefit: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The primary value driver selected by the user.' }; primaryFriction: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The primary friction point selected by the user.' }; - answers: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'JSON-encoded full survey answers keyed by question ID with stable option ID arrays.' }; owner: 'digitarald'; comment: 'Tracks in-product survey submissions for product-market fit analysis.'; }; @@ -253,7 +256,6 @@ export class SurveyEditorPane extends EditorPane { pmfScore, primaryBenefit: answersSnapshot['primary-benefit']?.[0] ?? '', primaryFriction: answersSnapshot['primary-friction']?.[0] ?? '', - answers: JSON.stringify(answersSnapshot), }); const submittedInput = this.input; From 6b0e9649d820ae55701f3a592bbf0afb2cda7de5 Mon Sep 17 00:00:00 2001 From: digitarald Date: Sun, 21 Jun 2026 10:23:02 -0700 Subject: [PATCH 10/13] fix: sanitize source arg, restore focus, handle singleton re-open - Validate command source against allowlist (telemetry safety) - Add runtime guard for dev command in stable builds - Make SurveyEditorInput.source mutable via updateSource() - Update existing input's source when singleton is re-opened - Restore focus to survey pane when closing a11y help - Await command execution in extension with error handling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../survey/vscode/surveyServiceImpl.ts | 6 ++- .../surveys/browser/survey.contribution.ts | 39 ++++++++++++++++--- .../surveys/browser/surveyEditorInput.ts | 14 ++++++- 3 files changed, 52 insertions(+), 7 deletions(-) diff --git a/extensions/copilot/src/platform/survey/vscode/surveyServiceImpl.ts b/extensions/copilot/src/platform/survey/vscode/surveyServiceImpl.ts index e2bbfd1f6ccf17..99657ec24466bf 100644 --- a/extensions/copilot/src/platform/survey/vscode/surveyServiceImpl.ts +++ b/extensions/copilot/src/platform/survey/vscode/surveyServiceImpl.ts @@ -212,7 +212,11 @@ export class SurveyService implements ISurveyService { if (accepted) { // Open in-editor survey pane with the source context - vscode.commands.executeCommand('_workbench.action.openCopilotSurvey', source); + try { + await vscode.commands.executeCommand('_workbench.action.openCopilotSurvey', source); + } catch { + // Command may be unavailable in older VS Code versions - silently ignore + } } else if (postponed) { await this.updateNextSurveyDate(DAYS_LATER); } diff --git a/src/vs/workbench/contrib/surveys/browser/survey.contribution.ts b/src/vs/workbench/contrib/surveys/browser/survey.contribution.ts index db11458a1d0389..ecff348623ccf0 100644 --- a/src/vs/workbench/contrib/surveys/browser/survey.contribution.ts +++ b/src/vs/workbench/contrib/surveys/browser/survey.contribution.ts @@ -12,6 +12,7 @@ import { CommandsRegistry } from '../../../../platform/commands/common/commands. import { IsDevelopmentContext } from '../../../../platform/contextkey/common/contextkeys.js'; import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { AccessibilityVerbositySettingId } from '../../accessibility/browser/accessibilityConfiguration.js'; import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../browser/editor.js'; @@ -49,15 +50,34 @@ class OpenSurveyAction extends Action2 { } override async run(accessor: ServicesAccessor): Promise { - return openSurveyEditor(accessor); + const productService = accessor.get(IProductService); + if (productService.quality === 'stable') { + return; // Never open dev survey in stable builds + } + return openSurveyEditor(accessor, 'dev-command'); } } registerAction2(OpenSurveyAction); +// Known survey sources (allowlist for telemetry safety) +const KNOWN_SURVEY_SOURCES = new Set([ + 'completions', 'panel', 'inline-chat', 'terminal', + 'panel.agent', 'agent.codeEdit', 'agent.ask', 'agent.generate', + 'sessions', +]); + +function sanitizeSurveySource(source: unknown): string { + if (typeof source !== 'string') { + return 'unknown'; + } + const trimmed = source.trim().slice(0, 64); + return KNOWN_SURVEY_SOURCES.has(trimmed) ? trimmed : 'unknown'; +} + // Programmatic command for extensions to trigger the survey (e.g. from Copilot survey service) -CommandsRegistry.registerCommand('_workbench.action.openCopilotSurvey', (accessor: ServicesAccessor, source?: string) => { - return openSurveyEditor(accessor, source); +CommandsRegistry.registerCommand('_workbench.action.openCopilotSurvey', (accessor: ServicesAccessor, source?: unknown) => { + return openSurveyEditor(accessor, sanitizeSurveySource(source)); }); function openSurveyEditor(accessor: ServicesAccessor, source?: string): Promise { @@ -68,6 +88,14 @@ function openSurveyEditor(accessor: ServicesAccessor, source?: string): Promise< const input = instantiationService.createInstance(SurveyEditorInput, CopilotPMFSurvey, source); + // If the same survey is already open (singleton match), update its source + for (const editor of editorService.editors) { + if (editor instanceof SurveyEditorInput && editor.matches(input)) { + editor.updateSource(source); + break; + } + } + // In the sessions window, open in the main editor part (not modal) const preferredGroup = environmentService.isSessionsWindow ? editorGroupsService.mainPart.activeGroup @@ -83,7 +111,8 @@ class SurveyAccessibilityHelp implements IAccessibleViewImplementation { readonly type = AccessibleViewType.Help; readonly when = ActiveEditorContext.isEqualTo(SurveyEditorPane.ID); - getProvider(_accessor: ServicesAccessor) { + getProvider(accessor: ServicesAccessor) { + const editorService = accessor.get(IEditorService); const helpText = [ localize('survey.help.overview', "You are in a survey form. Use Tab to move between questions and options."), localize('survey.help.select', "Use arrow keys within a question to navigate between options, and Space or Enter to select."), @@ -93,7 +122,7 @@ class SurveyAccessibilityHelp implements IAccessibleViewImplementation { AccessibleViewProviderId.Survey, { type: AccessibleViewType.Help }, () => helpText, - () => { /* focus returns to survey pane automatically */ }, + () => { editorService.activeEditorPane?.focus(); }, AccessibilityVerbositySettingId.Survey, ); } diff --git a/src/vs/workbench/contrib/surveys/browser/surveyEditorInput.ts b/src/vs/workbench/contrib/surveys/browser/surveyEditorInput.ts index 016effdc7a1f62..cffca2ac74251a 100644 --- a/src/vs/workbench/contrib/surveys/browser/surveyEditorInput.ts +++ b/src/vs/workbench/contrib/surveys/browser/surveyEditorInput.ts @@ -18,12 +18,24 @@ export class SurveyEditorInput extends EditorInput { static readonly ID = 'workbench.input.survey'; + private _source: string | undefined; + constructor( readonly survey: ISurveyDefinition, /** The Copilot feature source that triggered this survey (e.g. 'completions', 'panel.agent', 'agent.codeEdit'). */ - readonly source?: string, + source?: string, ) { super(); + this._source = source; + } + + get source(): string | undefined { + return this._source; + } + + /** Update the source when re-triggered while already open. */ + updateSource(source: string | undefined): void { + this._source = source; } override get typeId(): string { From 5a7f871603d29e31040b24e545f84c430dc42eef Mon Sep 17 00:00:00 2001 From: digitarald Date: Sun, 21 Jun 2026 15:40:11 -0700 Subject: [PATCH 11/13] generalize survey telemetry with telemetryKey mappings Questions now declare their own telemetry field via telemetryKey and asMeasurement properties. The pane iterates these at submit time instead of hardcoding field names. Telemetry fields: - score (number): primary measure, option index - primaryBenefit (string): value driver option ID - primaryFriction (string): friction point option ID - programmingExperience (number): experience bracket index Added Q4: programming experience (5 brackets, 0-4 numeric measure). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../surveys/browser/surveyEditorPane.ts | 50 +++++++++++++------ .../surveys/browser/surveyQuestions.ts | 40 +++++++++++---- 2 files changed, 65 insertions(+), 25 deletions(-) diff --git a/src/vs/workbench/contrib/surveys/browser/surveyEditorPane.ts b/src/vs/workbench/contrib/surveys/browser/surveyEditorPane.ts index e07e06dbd65fe4..e9c08aa3b10bd0 100644 --- a/src/vs/workbench/contrib/surveys/browser/surveyEditorPane.ts +++ b/src/vs/workbench/contrib/surveys/browser/surveyEditorPane.ts @@ -221,13 +221,28 @@ export class SurveyEditorPane extends EditorPane { answersSnapshot[key] = [...value]; } - // PMF score as numeric 0-4 (index in the options array, low=not disappointed, high=extremely) - let pmfScore = -1; - if (survey.pmfQuestionId) { - const pmfQuestion = survey.questions.find(q => q.id === survey.pmfQuestionId); - const pmfAnswer = answersSnapshot[survey.pmfQuestionId]?.[0]; - if (pmfQuestion && pmfAnswer) { - pmfScore = pmfQuestion.options.findIndex(o => o.id === pmfAnswer); + // Build telemetry from question telemetryKey declarations + let score = -1; + let primaryBenefit = ''; + let primaryFriction = ''; + let programmingExperience = -1; + + for (const question of survey.questions) { + if (!question.telemetryKey) { + continue; + } + const answer = answersSnapshot[question.id]?.[0] ?? ''; + if (question.asMeasurement) { + const index = answer ? question.options.findIndex(o => o.id === answer) : -1; + switch (question.telemetryKey) { + case 'score': score = index; break; + case 'programmingExperience': programmingExperience = index; break; + } + } else { + switch (question.telemetryKey) { + case 'primaryBenefit': primaryBenefit = answer; break; + case 'primaryFriction': primaryFriction = answer; break; + } } } @@ -237,25 +252,28 @@ export class SurveyEditorPane extends EditorPane { type SurveySubmitEvent = { surveyId: string; source: string; - pmfScore: number; + score: number; primaryBenefit: string; primaryFriction: string; + programmingExperience: number; }; type SurveySubmitClassification = { surveyId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The survey identifier.' }; - source: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The Copilot feature source that triggered the survey (e.g. completions, panel.agent, agent.codeEdit).' }; - pmfScore: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'PMF disappointment score 0-4 (0=not at all, 1=slightly, 2=somewhat, 3=very, 4=extremely disappointed).' }; - primaryBenefit: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The primary value driver selected by the user.' }; - primaryFriction: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The primary friction point selected by the user.' }; + source: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The feature source that triggered the survey (e.g. completions, panel.agent, nps).' }; + score: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Primary score as option index (e.g. PMF disappointment 0-4, NPS 0-10). -1 if not answered.' }; + primaryBenefit: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The primary value driver option ID selected by the user, empty if not applicable.' }; + primaryFriction: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The primary friction point option ID selected by the user, empty if not applicable.' }; + programmingExperience: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Programming experience bracket as option index (0=<3yr, 1=3-5yr, 2=6-9yr, 3=10-19yr, 4=20+yr). -1 if not answered.' }; owner: 'digitarald'; - comment: 'Tracks in-product survey submissions for product-market fit analysis.'; + comment: 'Tracks in-product survey submissions. Fields are populated based on the survey definition telemetryKey mappings.'; }; this.telemetryService.publicLog2('survey/submit', { surveyId: survey.id, source, - pmfScore, - primaryBenefit: answersSnapshot['primary-benefit']?.[0] ?? '', - primaryFriction: answersSnapshot['primary-friction']?.[0] ?? '', + score, + primaryBenefit, + primaryFriction, + programmingExperience, }); const submittedInput = this.input; diff --git a/src/vs/workbench/contrib/surveys/browser/surveyQuestions.ts b/src/vs/workbench/contrib/surveys/browser/surveyQuestions.ts index 3e08a4c9e20e0b..6e83f97c6d77c7 100644 --- a/src/vs/workbench/contrib/surveys/browser/surveyQuestions.ts +++ b/src/vs/workbench/contrib/surveys/browser/surveyQuestions.ts @@ -15,18 +15,25 @@ export interface ISurveyOption { readonly label: string; } -export interface ISurveySegmentQuestion { - readonly type: SurveyQuestionType.Segment; +interface ISurveyQuestionBase { readonly id: string; readonly label: string; readonly options: readonly ISurveyOption[]; + /** + * The telemetry field name this answer maps to in the `survey/submit` event. + * When set, the selected option ID (or numeric index if {@link asMeasurement} is true) is emitted under this key. + */ + readonly telemetryKey?: string; + /** When true, the answer is logged as a numeric index into the options array (0-based) with `isMeasurement`. */ + readonly asMeasurement?: boolean; +} + +export interface ISurveySegmentQuestion extends ISurveyQuestionBase { + readonly type: SurveyQuestionType.Segment; } -export interface ISurveyRadioQuestion { +export interface ISurveyRadioQuestion extends ISurveyQuestionBase { readonly type: SurveyQuestionType.Radio; - readonly id: string; - readonly label: string; - readonly options: readonly ISurveyOption[]; readonly columns?: number; } @@ -37,8 +44,6 @@ export interface ISurveyDefinition { readonly title: string; readonly description: string; readonly questions: readonly ISurveyQuestion[]; - /** The question ID whose answer is the primary PMF score (reported as a top-level telemetry measure). */ - readonly pmfQuestionId?: string; } /** @@ -49,11 +54,12 @@ export const CopilotPMFSurvey: ISurveyDefinition = { id: 'copilot-pmf', title: localize('survey.copilotPmf.title', "Help Us Improve GitHub Copilot"), description: localize('survey.copilotPmf.description', "This short survey helps us understand how well Copilot fits into your workflow."), - pmfQuestionId: 'disappointment', questions: [ { type: SurveyQuestionType.Segment, id: 'disappointment', + telemetryKey: 'score', + asMeasurement: true, label: localize('survey.copilotPmf.q1', "How disappointed would you be if you could no longer use Copilot?"), options: [ { id: 'not-at-all', label: localize('survey.copilotPmf.q1.notAtAll', "Not at all") }, @@ -66,6 +72,7 @@ export const CopilotPMFSurvey: ISurveyDefinition = { { type: SurveyQuestionType.Radio, id: 'primary-benefit', + telemetryKey: 'primaryBenefit', label: localize('survey.copilotPmf.q2', "What has Copilot helped you with most recently?"), columns: 2, options: [ @@ -82,6 +89,7 @@ export const CopilotPMFSurvey: ISurveyDefinition = { { type: SurveyQuestionType.Radio, id: 'primary-friction', + telemetryKey: 'primaryFriction', label: localize('survey.copilotPmf.q3', "What most gets in your way?"), columns: 2, options: [ @@ -96,5 +104,19 @@ export const CopilotPMFSurvey: ISurveyDefinition = { { id: 'cost', label: localize('survey.copilotPmf.q3.cost', "Limits, cost, or billing") }, ], }, + { + type: SurveyQuestionType.Radio, + id: 'programming-experience', + telemetryKey: 'programmingExperience', + asMeasurement: true, + label: localize('survey.copilotPmf.q4', "How long have you been programming?"), + options: [ + { id: 'less-than-3', label: localize('survey.copilotPmf.q4.lessThan3', "Less than 3 years") }, + { id: '3-to-5', label: localize('survey.copilotPmf.q4.3to5', "3 - 5 years") }, + { id: '6-to-9', label: localize('survey.copilotPmf.q4.6to9', "6 - 9 years") }, + { id: '10-to-19', label: localize('survey.copilotPmf.q4.10to19', "10 - 19 years") }, + { id: '20-plus', label: localize('survey.copilotPmf.q4.20plus', "20+ years") }, + ], + }, ], }; From f551fb025aaf434da57279709b18500af2af0ad5 Mon Sep 17 00:00:00 2001 From: digitarald Date: Sun, 21 Jun 2026 17:28:23 -0700 Subject: [PATCH 12/13] fix: prefix-based source validation, cooldown recovery, escape options - Source allowlist now uses prefix matching (inline.*, panel.*, agent.*) to accept real Copilot sources like inline.codeEdit, panel.agent - Extension resets cooldown to DAYS_LATER on command failure so users get re-prompted instead of losing 90 days silently - Added 'Something else' escape hatch to Q2 (benefit) and Q3 (friction) so users are not forced into inaccurate answers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../platform/survey/vscode/surveyServiceImpl.ts | 3 ++- .../surveys/browser/survey.contribution.ts | 16 +++++++++------- .../contrib/surveys/browser/surveyQuestions.ts | 2 ++ 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/extensions/copilot/src/platform/survey/vscode/surveyServiceImpl.ts b/extensions/copilot/src/platform/survey/vscode/surveyServiceImpl.ts index 99657ec24466bf..b53c19f653af6b 100644 --- a/extensions/copilot/src/platform/survey/vscode/surveyServiceImpl.ts +++ b/extensions/copilot/src/platform/survey/vscode/surveyServiceImpl.ts @@ -215,7 +215,8 @@ export class SurveyService implements ISurveyService { try { await vscode.commands.executeCommand('_workbench.action.openCopilotSurvey', source); } catch { - // Command may be unavailable in older VS Code versions - silently ignore + // Command unavailable — reset cooldown so user can be prompted again + await this.updateNextSurveyDate(DAYS_LATER); } } else if (postponed) { await this.updateNextSurveyDate(DAYS_LATER); diff --git a/src/vs/workbench/contrib/surveys/browser/survey.contribution.ts b/src/vs/workbench/contrib/surveys/browser/survey.contribution.ts index ecff348623ccf0..67a31c2229f4e8 100644 --- a/src/vs/workbench/contrib/surveys/browser/survey.contribution.ts +++ b/src/vs/workbench/contrib/surveys/browser/survey.contribution.ts @@ -60,19 +60,21 @@ class OpenSurveyAction extends Action2 { registerAction2(OpenSurveyAction); -// Known survey sources (allowlist for telemetry safety) -const KNOWN_SURVEY_SOURCES = new Set([ - 'completions', 'panel', 'inline-chat', 'terminal', - 'panel.agent', 'agent.codeEdit', 'agent.ask', 'agent.generate', - 'sessions', -]); +// Known survey source prefixes (validated for telemetry safety) +const KNOWN_SOURCE_PREFIXES = [ + 'completions', 'panel.', 'inline.', 'terminal', + 'agent.', 'sessions', 'nps', 'churn', 'dev-command', +]; function sanitizeSurveySource(source: unknown): string { if (typeof source !== 'string') { return 'unknown'; } const trimmed = source.trim().slice(0, 64); - return KNOWN_SURVEY_SOURCES.has(trimmed) ? trimmed : 'unknown'; + if (KNOWN_SOURCE_PREFIXES.some(prefix => trimmed === prefix || trimmed.startsWith(prefix))) { + return trimmed; + } + return 'unknown'; } // Programmatic command for extensions to trigger the survey (e.g. from Copilot survey service) diff --git a/src/vs/workbench/contrib/surveys/browser/surveyQuestions.ts b/src/vs/workbench/contrib/surveys/browser/surveyQuestions.ts index 6e83f97c6d77c7..193da633b23efc 100644 --- a/src/vs/workbench/contrib/surveys/browser/surveyQuestions.ts +++ b/src/vs/workbench/contrib/surveys/browser/surveyQuestions.ts @@ -84,6 +84,7 @@ export const CopilotPMFSurvey: ISurveyDefinition = { { id: 'planning', label: localize('survey.copilotPmf.q2.planning', "Planning an approach") }, { id: 'reviewing', label: localize('survey.copilotPmf.q2.reviewing', "Improving or reviewing code") }, { id: 'no-clear-value', label: localize('survey.copilotPmf.q2.noClearValue', "I haven't gotten clear value yet") }, + { id: 'other', label: localize('survey.copilotPmf.q2.other', "Something else") }, ], }, { @@ -102,6 +103,7 @@ export const CopilotPMFSurvey: ISurveyDefinition = { { id: 'setup', label: localize('survey.copilotPmf.q3.setup', "Setup or integrations are hard") }, { id: 'security', label: localize('survey.copilotPmf.q3.security', "Security or permissions friction") }, { id: 'cost', label: localize('survey.copilotPmf.q3.cost', "Limits, cost, or billing") }, + { id: 'other', label: localize('survey.copilotPmf.q3.other', "Something else") }, ], }, { From bbf5921e05d00256d83404be5275d9e6ac83daa6 Mon Sep 17 00:00:00 2001 From: digitarald Date: Sun, 21 Jun 2026 17:37:57 -0700 Subject: [PATCH 13/13] fix: convert Q4 to segment control, increase bottom padding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Programming experience now renders as a segment band with compressed labels (<3 yr, 3-5 yr, 6-9 yr, 10-19 yr, 20+ yr) — saves vertical space and matches the PMF score visual pattern - Increased container bottom padding from 60px to 80px to prevent submit button from being clipped when scrolled Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../surveys/browser/media/surveyEditorPane.css | 2 +- .../contrib/surveys/browser/surveyQuestions.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/vs/workbench/contrib/surveys/browser/media/surveyEditorPane.css b/src/vs/workbench/contrib/surveys/browser/media/surveyEditorPane.css index b9f61286560aaa..49bdbb33a62da7 100644 --- a/src/vs/workbench/contrib/surveys/browser/media/surveyEditorPane.css +++ b/src/vs/workbench/contrib/surveys/browser/media/surveyEditorPane.css @@ -9,7 +9,7 @@ display: flex; justify-content: center; align-items: flex-start; - padding: 40px 20px 60px; + padding: 40px 20px 80px; } .survey-editor-pane .survey-form { diff --git a/src/vs/workbench/contrib/surveys/browser/surveyQuestions.ts b/src/vs/workbench/contrib/surveys/browser/surveyQuestions.ts index 193da633b23efc..2c83876a189db7 100644 --- a/src/vs/workbench/contrib/surveys/browser/surveyQuestions.ts +++ b/src/vs/workbench/contrib/surveys/browser/surveyQuestions.ts @@ -107,17 +107,17 @@ export const CopilotPMFSurvey: ISurveyDefinition = { ], }, { - type: SurveyQuestionType.Radio, + type: SurveyQuestionType.Segment, id: 'programming-experience', telemetryKey: 'programmingExperience', asMeasurement: true, label: localize('survey.copilotPmf.q4', "How long have you been programming?"), options: [ - { id: 'less-than-3', label: localize('survey.copilotPmf.q4.lessThan3', "Less than 3 years") }, - { id: '3-to-5', label: localize('survey.copilotPmf.q4.3to5', "3 - 5 years") }, - { id: '6-to-9', label: localize('survey.copilotPmf.q4.6to9', "6 - 9 years") }, - { id: '10-to-19', label: localize('survey.copilotPmf.q4.10to19', "10 - 19 years") }, - { id: '20-plus', label: localize('survey.copilotPmf.q4.20plus', "20+ years") }, + { id: 'less-than-3', label: localize('survey.copilotPmf.q4.lessThan3', "<3 yr") }, + { id: '3-to-5', label: localize('survey.copilotPmf.q4.3to5', "3-5 yr") }, + { id: '6-to-9', label: localize('survey.copilotPmf.q4.6to9', "6-9 yr") }, + { id: '10-to-19', label: localize('survey.copilotPmf.q4.10to19', "10-19 yr") }, + { id: '20-plus', label: localize('survey.copilotPmf.q4.20plus', "20+ yr") }, ], }, ],