Skip to content

Commit d9637b3

Browse files
benibenjCopilot
andauthored
sessions: measure and unblock V3 onboarding GitHub personalization (#334696)
The V3 new-session onboarding personalizes its 3 prompt options with GitHub items, falling back to standard options. Telemetry from experiment f716301b showed 61% of impressions timed out with zero GitHub options, and the existing events could not measure partial success: `fallbackReason` was only 'none' when all 3 slots were GitHub, so 2 real issues logged identically to zero. Telemetry (additive; existing field names and values unchanged): - promptStrategy gains gitHubOptionCount, candidatesFound, lookupDurationMs, per-stage durations and outcomes, and timedOutStage. - promptOptionInteraction gains optionIndex and optionKindsShown, so per-option CTR is computable rather than confounded with availability. - Both gain a shared impressionId, replacing lossy DevDeviceId joins. - New 'noComposer' fallback reason splits the infra-bug meaning out of the overloaded 'noCandidate'. - Bounded categories and counts only: no titles, URLs, numbers, repository names or prompt text. Latency, without raising the 10s total budget: - Publish issue candidates before the linkage lookup, which only filters out issues that already have linked pull requests and so must never hide them. - Keep review-thread enrichment off the critical path; it produced the least picked option while being the most expensive stage. - Rebalance sub-budgets to 4000/1500/3000 so the concurrent chains fit. - Stream options into the composer: paint the standard options immediately and replace slots as lookups land, instead of blocking first paint on the lookup. INewSessionPromptOptionsController.resolve gains an optional progress callback that reports whether an update was rendered, so the composer can refuse late updates while the user is acting on the options and telemetry still describes what was actually shown. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent aee3160 commit d9637b3

6 files changed

Lines changed: 788 additions & 70 deletions

File tree

src/vs/sessions/contrib/chat/browser/newChatInput.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation
456456
private _promptOptionsState: NewSessionPromptOptionsState | undefined;
457457
private _promptOptionsController: INewSessionPromptOptionsController | undefined;
458458
private _promptOptionsDismissed = false;
459+
private _promptOptionsSelected = false;
459460

460461
// Send button
461462
private _sendButton: Button | undefined;
@@ -677,6 +678,9 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation
677678

678679
this._promptOptionsWidget.value = this.instantiationService.createInstance(NewSessionPromptOptionsWidget, chatInputContainer, {
679680
selectOption: async (option, expectedInput, animate) => {
681+
// Mark the selection as it begins: the widget only reports it once the prompt
682+
// animation completes, and a streamed update in between would clear it.
683+
this._promptOptionsSelected = true;
680684
this.focus();
681685
const inserted = animate
682686
? await this.animatePrompt(option.prompt, NEW_SESSION_PROMPT_TYPING_DURATION_MS, option.placeholder, CancellationToken.None, expectedInput)
@@ -1762,13 +1766,15 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation
17621766
this._cancelPromptOptionsRefresh(false);
17631767
this._promptOptionsController = controller;
17641768
this._promptOptionsDismissed = false;
1769+
this._promptOptionsSelected = false;
17651770
}
17661771

17671772
preparePromptOptionsRefresh(): boolean {
17681773
if (!this._promptOptionsController || this._promptOptionsDismissed) {
17691774
return false;
17701775
}
17711776
this._cancelPromptOptionsRefresh();
1777+
this._promptOptionsSelected = false;
17721778
this.showPromptOptions({ kind: 'loading' });
17731779
return true;
17741780
}
@@ -1809,9 +1815,17 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation
18091815
}
18101816
const cts = new CancellationTokenSource(token);
18111817
this._promptOptionsRefresh.value = cts;
1818+
// Options resolved after a first paint must not replace ones the user is already working with:
1819+
// re-rendering clears the selection and moves focus out of the options.
1820+
const canRerender = () => !this._promptOptionsSelected && !this._promptOptionsWidget.value?.hasFocusedOption();
18121821
let state: NewSessionPromptOptionsState;
18131822
try {
1814-
state = await controller.resolve(cts.token);
1823+
state = await controller.resolve(cts.token, progressState => {
1824+
if (this._promptOptionsRefresh.value !== cts || cts.token.isCancellationRequested || !canRerender()) {
1825+
return false;
1826+
}
1827+
return this.showPromptOptions(progressState);
1828+
});
18151829
} catch (error) {
18161830
if (this._promptOptionsRefresh.value === cts) {
18171831
this._promptOptionsRefresh.clear();
@@ -1831,6 +1845,9 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation
18311845
return false;
18321846
}
18331847
this._promptOptionsRefresh.clear();
1848+
if (!canRerender()) {
1849+
return true;
1850+
}
18341851
return this.showPromptOptions(state);
18351852
}
18361853

src/vs/sessions/contrib/chat/browser/newSessionComposerService.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,23 @@ export const enum NewSessionWorkspacePreselectionSource {
3636
Unknown = 'unknown',
3737
}
3838

39+
/**
40+
* Reports an intermediate prompt-option state while {@link INewSessionPromptOptionsController.resolve}
41+
* is still running, so options can be rendered before the controller has settled on its final set.
42+
*
43+
* Returns whether the state was rendered. Implementers may refuse a late update, for example while
44+
* the user is already acting on the options on screen.
45+
*/
46+
export type NewSessionPromptOptionsProgress = (state: NewSessionPromptOptionsState) => boolean;
47+
3948
export interface INewSessionPromptOptionsController {
40-
resolve(token: CancellationToken): Promise<NewSessionPromptOptionsState>;
49+
/**
50+
* Resolves the final prompt options. Single-shot per refresh.
51+
*
52+
* @param progress Optional sink for intermediate states. Implementers may ignore it; callers that
53+
* pass it must tolerate it never being invoked and must apply the returned state regardless.
54+
*/
55+
resolve(token: CancellationToken, progress?: NewSessionPromptOptionsProgress): Promise<NewSessionPromptOptionsState>;
4156
onDidSelectOption(option: INewSessionPromptOption): void;
4257
onDidClose(): void;
4358
}

src/vs/sessions/contrib/chat/browser/newSessionPromptOptions.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,11 @@ export class NewSessionPromptOptionsWidget extends Disposable {
122122
this._updateButtons();
123123
}
124124

125+
/** Whether focus is inside the rendered options, where re-rendering would move focus to the body. */
126+
hasFocusedOption(): boolean {
127+
return dom.isAncestorOfActiveElement(this._optionsContainer);
128+
}
129+
125130
shouldClearInputForRefresh(): boolean {
126131
const selectedOption = this._buttons.find(candidate => candidate.option.id === this._selectedOptionId)?.option;
127132
return this._selecting || this._inputValue.length === 0 || matchesGeneratedPrompt(selectedOption, this._inputValue);

src/vs/sessions/contrib/chat/test/browser/newSessionPromptOptions.test.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ import { mock } from '../../../../../base/test/common/mock.js';
1313
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
1414
import { IHoverService } from '../../../../../platform/hover/browser/hover.js';
1515
import { NewChatInputWidget } from '../../browser/newChatInput.js';
16-
import { INewSessionPromptOption, INewSessionPromptOptionsController, NewSessionPromptOptionsState } from '../../browser/newSessionComposerService.js';
16+
import { INewSessionPromptOption, INewSessionPromptOptionsController, NewSessionPromptOptionsProgress, NewSessionPromptOptionsState } from '../../browser/newSessionComposerService.js';
1717
import { NewSessionPromptOptionsWidget } from '../../browser/newSessionPromptOptions.js';
1818

1919
interface IPromptOptionsRefreshHarness {
2020
readonly _promptOptionsRefresh: MutableDisposable<CancellationTokenSource>;
2121
readonly _promptOptionsController: INewSessionPromptOptionsController;
22+
_promptOptionsSelected: boolean;
23+
readonly _promptOptionsWidget: { readonly value: { hasFocusedOption(): boolean } | undefined };
2224
preparePromptOptionsRefresh(): boolean;
2325
showPromptOptions(state: NewSessionPromptOptionsState | undefined): boolean;
2426
}
@@ -234,6 +236,8 @@ suite('NewSessionPromptOptionsWidget', () => {
234236
const refresh = disposables.add(new MutableDisposable<CancellationTokenSource>());
235237
const harness: IPromptOptionsRefreshHarness = {
236238
_promptOptionsRefresh: refresh,
239+
_promptOptionsSelected: false,
240+
_promptOptionsWidget: { value: undefined },
237241
_promptOptionsController: {
238242
resolve: token => {
239243
tokens.push(token);
@@ -272,6 +276,79 @@ suite('NewSessionPromptOptionsWidget', () => {
272276
});
273277
});
274278

279+
test('starts selectOption before reporting the selection so consumers can guard the in-flight window', async () => {
280+
const container = document.createElement('div');
281+
const events: string[] = [];
282+
const inserting = new DeferredPromise<boolean>();
283+
const widget = disposables.add(new NewSessionPromptOptionsWidget(container, {
284+
selectOption: async () => {
285+
events.push('selectOption');
286+
return inserting.p;
287+
},
288+
onDidSelectOption: () => events.push('onDidSelectOption'),
289+
onDidClose: () => undefined,
290+
}, new TestHoverService()));
291+
292+
widget.setState({ kind: 'resolved', options: [option('feature', 'Implement a feature')] });
293+
widget.element.querySelector<HTMLElement>('.monaco-button.new-session-prompt-option')?.click();
294+
await timeout(0);
295+
const duringInsertion = [...events];
296+
inserting.complete(true);
297+
await timeout(0);
298+
299+
assert.deepStrictEqual({ duringInsertion, afterInsertion: events }, {
300+
duringInsertion: ['selectOption'],
301+
afterInsertion: ['selectOption', 'onDidSelectOption'],
302+
});
303+
});
304+
305+
test('applies streamed prompt options until the user acts on them', async () => {
306+
const result = new DeferredPromise<NewSessionPromptOptionsState>();
307+
const states: NewSessionPromptOptionsState[] = [];
308+
const refresh = disposables.add(new MutableDisposable<CancellationTokenSource>());
309+
let reportProgress: NewSessionPromptOptionsProgress | undefined;
310+
const harness: IPromptOptionsRefreshHarness = {
311+
_promptOptionsRefresh: refresh,
312+
_promptOptionsSelected: false,
313+
_promptOptionsWidget: { value: undefined },
314+
_promptOptionsController: {
315+
resolve: (_token, progress) => {
316+
reportProgress = progress;
317+
return result.p;
318+
},
319+
onDidSelectOption: () => undefined,
320+
onDidClose: () => undefined,
321+
},
322+
preparePromptOptionsRefresh: () => {
323+
states.push({ kind: 'loading' });
324+
return true;
325+
},
326+
showPromptOptions: state => {
327+
if (state) {
328+
states.push(state);
329+
}
330+
return true;
331+
},
332+
};
333+
334+
const refreshing = refreshPromptOptions.call(harness);
335+
const applied = [
336+
reportProgress?.({ kind: 'resolved', options: [option('feature', 'Implement a feature')] }),
337+
(harness._promptOptionsSelected = true, reportProgress?.({ kind: 'resolved', options: [option('bug', 'Fix a bug')] })),
338+
];
339+
result.complete({ kind: 'resolved', options: [option('ci', 'Fix CI')] });
340+
341+
assert.deepStrictEqual({
342+
shown: await refreshing,
343+
applied,
344+
states: states.map(state => state.kind === 'loading' ? 'loading' : state.options[0].id),
345+
}, {
346+
shown: true,
347+
applied: [true, false],
348+
states: ['loading', 'feature'],
349+
});
350+
});
351+
275352
test('replaces a generated prompt immediately', () => {
276353
let value = 'old prompt';
277354
let placeholder: string | undefined;
@@ -312,6 +389,8 @@ suite('NewSessionPromptOptionsWidget', () => {
312389
const refresh = disposables.add(new MutableDisposable<CancellationTokenSource>());
313390
const harness: IPromptOptionsRefreshHarness = {
314391
_promptOptionsRefresh: refresh,
392+
_promptOptionsSelected: false,
393+
_promptOptionsWidget: { value: undefined },
315394
_promptOptionsController: {
316395
resolve: () => result.p,
317396
onDidSelectOption: () => undefined,
@@ -346,6 +425,8 @@ suite('NewSessionPromptOptionsWidget', () => {
346425
let resolveCount = 0;
347426
const harness: IPromptOptionsRefreshHarness = {
348427
_promptOptionsRefresh: disposables.add(new MutableDisposable<CancellationTokenSource>()),
428+
_promptOptionsSelected: false,
429+
_promptOptionsWidget: { value: undefined },
349430
_promptOptionsController: {
350431
resolve: async () => {
351432
resolveCount++;

0 commit comments

Comments
 (0)