Skip to content

Commit 8aafda2

Browse files
committed
feat(ntp): omnibar picker impression + upsell source telemetry (FE)
Add omnibar_modelPickerShown / omnibar_reasoningPickerShown notifications, fired once when the user opens the model selector or reasoning-effort picker (picker impressions). Add a `source` param ("model" | "reasoning") to the existing omnibar_showSubscriptionUpsell / omnibar_showSubscriptionUpgrade notifications so native can attribute the subscription funnel origin. Frontend only. Native (macOS/Windows) must add the corresponding handlers before this is shipped, so this lands on a dedicated branch pending native coordination.
1 parent 31a59b3 commit 8aafda2

15 files changed

Lines changed: 284 additions & 33 deletions

special-pages/pages/new-tab/app/omnibar/components/OmnibarProvider.js

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { OmnibarService } from '../omnibar.service.js';
1414
* @typedef {import('../../../types/new-tab.js').SubmitChatAction} SubmitChatAction
1515
* @typedef {import('../../../types/new-tab.js').GetOpenTabsResponse} GetOpenTabsResponse
1616
* @typedef {import('../../../types/new-tab.js').PageContext} PageContext
17+
* @typedef {import('../../../types/new-tab.js').OmnibarPickerSource} OmnibarPickerSource
1718
* @typedef {import('../../service.hooks.js').State<null, OmnibarConfig>} State
1819
*/
1920

@@ -79,7 +80,15 @@ export const OmnibarContext = createContext({
7980
viewAllAiChats: () => {
8081
throw new Error('must implement');
8182
},
82-
/** @type {(type?: 'subscribe' | 'upgrade') => void} */
83+
/** @type {(picker: OmnibarPickerSource) => void} */
84+
pickerShown: () => {
85+
throw new Error('must implement');
86+
},
87+
/** @type {(picker: OmnibarPickerSource) => void} */
88+
upsellShown: () => {
89+
throw new Error('must implement');
90+
},
91+
/** @type {(type: 'subscribe' | 'upgrade' | undefined, source: OmnibarPickerSource) => void} */
8392
showUpsell: () => {
8493
throw new Error('must implement');
8594
},
@@ -235,10 +244,26 @@ export function OmnibarProvider(props) {
235244
[service],
236245
);
237246

238-
/** @type {(type?: 'subscribe' | 'upgrade') => void} */
247+
/** @type {(picker: OmnibarPickerSource) => void} */
248+
const pickerShown = useCallback(
249+
(picker) => {
250+
service.current?.pickerShown(picker);
251+
},
252+
[service],
253+
);
254+
255+
/** @type {(picker: OmnibarPickerSource) => void} */
256+
const upsellShown = useCallback(
257+
(picker) => {
258+
service.current?.upsellShown(picker);
259+
},
260+
[service],
261+
);
262+
263+
/** @type {(type: 'subscribe' | 'upgrade' | undefined, source: OmnibarPickerSource) => void} */
239264
const showUpsell = useCallback(
240-
(type) => {
241-
service.current?.showUpsell(type);
265+
(type, source) => {
266+
service.current?.showUpsell(type, source);
242267
},
243268
[service],
244269
);
@@ -276,6 +301,8 @@ export function OmnibarProvider(props) {
276301
onAiChats,
277302
openAiChat,
278303
viewAllAiChats,
304+
pickerShown,
305+
upsellShown,
279306
showUpsell,
280307
getOpenTabs,
281308
getTabContent,

special-pages/pages/new-tab/app/omnibar/components/chat-tools/model-selector/ModelSelectorTool.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,19 @@ import { ModelSelector } from './ModelSelector';
1212

1313
export function ModelSelectorTool() {
1414
const { t } = useTypedTranslationWith(/** @type {Strings} */ ({}));
15-
const { showUpsell } = useContext(OmnibarContext);
15+
const { showUpsell, pickerShown, upsellShown } = useContext(OmnibarContext);
1616
const { selectedModel, aiModelSections, allModels, setSelectedModelId } = useSelectedModel();
1717

18+
// The upsell CTA renders only for a fully-gated section (mirrors ModelDropdown's `isUpsellSection`).
19+
const hasUpsell = aiModelSections.some((section) => section.items.length > 0 && section.items.every((model) => !model.isEnabled));
20+
1821
const selector = useModelSelector({
1922
allModels,
2023
onModelChange: setSelectedModelId,
24+
onOpen: () => {
25+
pickerShown('model');
26+
if (hasUpsell) upsellShown('model');
27+
},
2128
});
2229

2330
if (aiModelSections.length === 0) return null;
@@ -27,7 +34,7 @@ export function ModelSelectorTool() {
2734
selector={selector}
2835
selectedModel={selectedModel}
2936
aiModelSections={aiModelSections}
30-
onUpsell={showUpsell}
37+
onUpsell={(type) => showUpsell(type, 'model')}
3138
ariaLabel={t('omnibar_modelSelectorLabel')}
3239
/>
3340
);

special-pages/pages/new-tab/app/omnibar/components/chat-tools/model-selector/useModelSelector.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ import { useDropdown } from '../useDropdown';
1010
* @param {object} options
1111
* @param {AIModelItem[]} options.allModels
1212
* @param {(id: string) => void} [options.onModelChange] - Called when the user selects a model, to persist the choice
13+
* @param {() => void} [options.onOpen] - Called when the dropdown opens, to report a picker impression
1314
*/
14-
export function useModelSelector({ allModels, onModelChange }) {
15-
const dropdown = useDropdown({ align: 'right' });
15+
export function useModelSelector({ allModels, onModelChange, onOpen }) {
16+
const dropdown = useDropdown({ align: 'right', onOpen });
1617

1718
/** @param {string} id */
1819
const selectModel = (id) => {

special-pages/pages/new-tab/app/omnibar/components/chat-tools/reasoning-picker/ReasoningPicker.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,24 @@ import styles from './ReasoningPicker.module.css';
2525
* @param {ReasoningEffort|null} props.selectedEffort
2626
* @param {(effort: ReasoningEffort) => void} props.onSelect
2727
* @param {(type?: 'subscribe' | 'upgrade') => void} props.onUpsell
28+
* @param {() => void} [props.onOpen] - Called when the dropdown opens, to report a picker impression
2829
* @param {string} props.ariaLabel
2930
* @param {string} props.buttonLabel
3031
* @param {string} props.tryForFreeLabel
3132
* @param {string} props.upgradeLabel
3233
*/
33-
export function ReasoningPicker({ options, selectedEffort, onSelect, onUpsell, ariaLabel, buttonLabel, tryForFreeLabel, upgradeLabel }) {
34-
const { isOpen, dropdownPos, buttonRef, dropdownRef, toggle, close } = useDropdown({ align: 'right' });
34+
export function ReasoningPicker({
35+
options,
36+
selectedEffort,
37+
onSelect,
38+
onUpsell,
39+
onOpen,
40+
ariaLabel,
41+
buttonLabel,
42+
tryForFreeLabel,
43+
upgradeLabel,
44+
}) {
45+
const { isOpen, dropdownPos, buttonRef, dropdownRef, toggle, close } = useDropdown({ align: 'right', onOpen });
3546

3647
/** @param {{ restoreFocus: boolean }} opts */
3748
const handleClose = ({ restoreFocus }) => {

special-pages/pages/new-tab/app/omnibar/components/chat-tools/reasoning-picker/ReasoningPickerTool.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ function getReasoningIcon(id) {
3737

3838
export function ReasoningPickerTool() {
3939
const { t } = useTypedTranslationWith(/** @type {Strings} */ ({}));
40-
const { showUpsell } = useContext(OmnibarContext);
40+
const { showUpsell, pickerShown, upsellShown } = useContext(OmnibarContext);
4141
const { reasoningEfforts, selectedEffort, setSelectedReasoningEffort } = useSelectedReasoningEffort();
4242

4343
const options = reasoningEfforts.map((effort) => ({
@@ -53,14 +53,21 @@ export function ReasoningPickerTool() {
5353
return null;
5454
}
5555

56+
// The upsell badge renders for any gated (unavailable) option.
57+
const hasUpsell = options.some((option) => option.status === 'unavailable');
58+
5659
const selectedOption = options.find((option) => option.id === selectedEffort);
5760

5861
return (
5962
<ReasoningPicker
6063
options={options}
6164
selectedEffort={selectedEffort}
6265
onSelect={setSelectedReasoningEffort}
63-
onUpsell={showUpsell}
66+
onUpsell={(type) => showUpsell(type, 'reasoning')}
67+
onOpen={() => {
68+
pickerShown('reasoning');
69+
if (hasUpsell) upsellShown('reasoning');
70+
}}
6471
ariaLabel={t('omnibar_reasoningPickerLabel')}
6572
buttonLabel={selectedOption?.name ?? t('omnibar_reasoningPickerLabel')}
6673
tryForFreeLabel={t('omnibar_reasoningTryForFree')}

special-pages/pages/new-tab/app/omnibar/components/chat-tools/useDropdown.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,9 @@ function computePosition(buttonRect, cbRect, align) {
5353
*
5454
* @param {object} [options]
5555
* @param {'left' | 'right'} [options.align] - Horizontal alignment of the dropdown relative to the button. Defaults to 'left'.
56+
* @param {() => void} [options.onOpen] - Invoked once each time the dropdown transitions from closed to open. Used to report picker-impression telemetry.
5657
*/
57-
export function useDropdown({ align = 'left' } = {}) {
58+
export function useDropdown({ align = 'left', onOpen } = {}) {
5859
const [isOpen, setIsOpen] = useState(false);
5960
const [dropdownPos, setDropdownPos] = useState(/** @type {DropdownPosition|null} */ (null));
6061
const buttonRef = useRef(/** @type {HTMLButtonElement|null} */ (null));
@@ -78,6 +79,7 @@ export function useDropdown({ align = 'left' } = {}) {
7879
const cbRect = cb?.getBoundingClientRect() ?? null;
7980
setDropdownPos(computePosition(rect, cbRect, align));
8081
setIsOpen(true);
82+
onOpen?.();
8183

8284
/** @param {MouseEvent} e */
8385
const handleClickOutside = (e) => {

special-pages/pages/new-tab/app/omnibar/integration-tests/omnibar.spec.js

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1759,7 +1759,27 @@ test.describe('omnibar widget', () => {
17591759

17601760
await omnibar.modelUpsellButton().click();
17611761

1762-
await ntp.mocks.waitForCallCount({ method: 'omnibar_showSubscriptionUpsell', count: 1 });
1762+
await omnibar.expectMethodCalledWith('omnibar_showSubscriptionUpsell', { source: 'model' });
1763+
});
1764+
1765+
test('fires model-picker and upsell impressions when the model selector is opened', async ({ page }, workerInfo) => {
1766+
const ntp = NewtabPage.create(page, workerInfo);
1767+
const omnibar = new OmnibarPage(ntp);
1768+
await ntp.reducedMotion();
1769+
1770+
// Default mock: the advanced section is entirely gated, so the upsell CTA is visible on open.
1771+
await ntp.openPage({ additional: { omnibar: true, 'omnibar.enableAiChatTools': 'true' } });
1772+
await omnibar.ready();
1773+
1774+
await omnibar.aiTab().click();
1775+
await omnibar.expectMode('ai');
1776+
1777+
await omnibar.modelSelectorButton().click();
1778+
1779+
const calls = await ntp.mocks.waitForCallCount({ method: 'telemetryEvent', count: 2 });
1780+
const params = calls.map((call) => call.payload.params);
1781+
expect(params).toContainEqual({ attributes: { name: 'omnibar_picker', value: { picker: 'model' } } });
1782+
expect(params).toContainEqual({ attributes: { name: 'omnibar_upsell', value: { picker: 'model' } } });
17631783
});
17641784

17651785
test('does not show the upsell when all models are available', async ({ page }, workerInfo) => {
@@ -1777,6 +1797,12 @@ test.describe('omnibar widget', () => {
17771797

17781798
await omnibar.modelSelectorButton().click();
17791799
await expect(omnibar.modelUpsellButton()).toHaveCount(0);
1800+
1801+
// Picker impression still fires, but the upsell impression must not (no gated option visible).
1802+
const calls = await ntp.mocks.waitForCallCount({ method: 'telemetryEvent', count: 1 });
1803+
const params = calls.map((call) => call.payload.params);
1804+
expect(params).toContainEqual({ attributes: { name: 'omnibar_picker', value: { picker: 'model' } } });
1805+
expect(params).not.toContainEqual({ attributes: { name: 'omnibar_upsell', value: { picker: 'model' } } });
17801806
});
17811807
});
17821808

@@ -2042,7 +2068,7 @@ test.describe('omnibar widget', () => {
20422068

20432069
await gatedOption.click();
20442070

2045-
await ntp.mocks.waitForCallCount({ method: 'omnibar_showSubscriptionUpsell', count: 1 });
2071+
await omnibar.expectMethodCalledWith('omnibar_showSubscriptionUpsell', { source: 'reasoning' });
20462072
});
20472073

20482074
test('an upgrade-gated reasoning-effort option shows "Upgrade" and opens the subscription upgrade', async ({
@@ -2071,7 +2097,33 @@ test.describe('omnibar widget', () => {
20712097

20722098
await gatedOption.click();
20732099

2074-
await ntp.mocks.waitForCallCount({ method: 'omnibar_showSubscriptionUpgrade', count: 1 });
2100+
await omnibar.expectMethodCalledWith('omnibar_showSubscriptionUpgrade', { source: 'reasoning' });
2101+
});
2102+
2103+
test('fires reasoning-picker and upsell impressions when the reasoning picker is opened', async ({ page }, workerInfo) => {
2104+
const ntp = NewtabPage.create(page, workerInfo);
2105+
const omnibar = new OmnibarPage(ntp);
2106+
await ntp.reducedMotion();
2107+
2108+
// claude-haiku-4-5 has a gated 'extended' effort, so the upsell CTA is visible on open.
2109+
await ntp.openPage({
2110+
additional: {
2111+
omnibar: true,
2112+
'omnibar.enableAiChatTools': 'true',
2113+
'omnibar.selectedModelId': 'claude-haiku-4-5',
2114+
},
2115+
});
2116+
await omnibar.ready();
2117+
2118+
await omnibar.aiTab().click();
2119+
await omnibar.expectMode('ai');
2120+
2121+
await omnibar.reasoningPickerButton().click();
2122+
2123+
const calls = await ntp.mocks.waitForCallCount({ method: 'telemetryEvent', count: 2 });
2124+
const params = calls.map((call) => call.payload.params);
2125+
expect(params).toContainEqual({ attributes: { name: 'omnibar_picker', value: { picker: 'reasoning' } } });
2126+
expect(params).toContainEqual({ attributes: { name: 'omnibar_upsell', value: { picker: 'reasoning' } } });
20752127
});
20762128
});
20772129

special-pages/pages/new-tab/app/omnibar/mocks/omnibar.mock-transport.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ export function omnibarMockTransport() {
219219
case 'omnibar_openSuggestion':
220220
case 'omnibar_submitSearch':
221221
case 'omnibar_submitChat':
222+
case 'telemetryEvent':
222223
console.warn('notification (no-op in mock)', msg.method, msg.params);
223224
break;
224225
case 'omnibar_showSubscriptionUpsell':

special-pages/pages/new-tab/app/omnibar/omnibar.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,41 @@ title: Omnibar Widget
257257
}
258258
```
259259

260+
### Picker impressions — `telemetryEvent`
261+
Impression telemetry is sent via the shared `telemetryEvent` notification (not a bespoke message), so native only needs to add a name case + pixel mapping. Fired once per open (closed→open transition). Native maps `value.picker` to the platform-specific subscription funnel origin and fires the impression pixel.
262+
263+
- **Picker shown**`{ attributes: { name: "omnibar_picker", value: { picker: "model" | "reasoning" } } }`, sent when the model or reasoning picker opens.
264+
- **Upsell shown**`{ attributes: { name: "omnibar_upsell", value: { picker: "model" | "reasoning" } } }`, sent when the conditional subscription upsell CTA is visible on open (only when a gated option exists).
265+
266+
Example payload:
267+
```json
268+
{
269+
"attributes": { "name": "omnibar_picker", "value": { "picker": "model" } }
270+
}
271+
```
272+
273+
### `omnibar_showSubscriptionUpsell`
274+
- {@link "NewTab Messages".OmnibarShowSubscriptionUpsellNotification}
275+
- Sent when the user taps "Try for free" on a subscription-gated model or reasoning-effort option.
276+
- requires `source``"model"` or `"reasoning"`, identifying the picker the upsell was triggered from. Native maps `source` to the subscription funnel origin (and infers `flow_type`); the frontend does not send origin or flow information.
277+
- example payload:
278+
```json
279+
{
280+
"source": "model"
281+
}
282+
```
283+
284+
### `omnibar_showSubscriptionUpgrade`
285+
- {@link "NewTab Messages".OmnibarShowSubscriptionUpgradeNotification}
286+
- Sent when a subscriber taps "Upgrade" on a model or reasoning-effort option gated behind a higher tier.
287+
- requires `source``"model"` or `"reasoning"`, identifying the picker the upgrade was triggered from. Native maps `source` to the subscription funnel origin; the frontend does not send origin or flow information.
288+
- example payload:
289+
```json
290+
{
291+
"source": "reasoning"
292+
}
293+
```
294+
260295
## Suggestion Types
261296

262297
The omnibar supports various types of suggestions:

special-pages/pages/new-tab/app/omnibar/omnibar.service.js

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { OmnibarAiChatsService } from './omnibar.ai-chats.service.js';
1212
* @typedef {import("../../types/new-tab.js").SubmitChatAction} SubmitChatAction
1313
* @typedef {import("../../types/new-tab.js").GetOpenTabsResponse} GetOpenTabsResponse
1414
* @typedef {import("../../types/new-tab.js").PageContext} PageContext
15+
* @typedef {import("../../types/new-tab.js").OmnibarPickerSource} OmnibarPickerSource
1516
*/
1617

1718
export class OmnibarService {
@@ -213,31 +214,53 @@ export class OmnibarService {
213214
this.ntp.messaging.notify('omnibar_viewAllAIChats', params);
214215
}
215216

217+
/**
218+
* Report that the user opened a chat-tool picker (impression), via the shared
219+
* `telemetryEvent` message. Native maps `picker` to the platform-specific
220+
* subscription funnel origin and fires the picker-impression pixel.
221+
* @param {OmnibarPickerSource} picker
222+
*/
223+
pickerShown(picker) {
224+
this.ntp.telemetryEvent({ attributes: { name: 'omnibar_picker', value: { picker } } });
225+
}
226+
227+
/**
228+
* Report that the conditional subscription upsell CTA became visible inside a picker
229+
* (impression), via the shared `telemetryEvent` message.
230+
* @param {OmnibarPickerSource} picker
231+
*/
232+
upsellShown(picker) {
233+
this.ntp.telemetryEvent({ attributes: { name: 'omnibar_upsell', value: { picker } } });
234+
}
235+
216236
/**
217237
* Ask native to present the subscription upsell (e.g. when the user taps
218238
* "Try for free" on a gated model or reasoning-effort option).
239+
* @param {OmnibarPickerSource} source - The picker the upsell was triggered from.
219240
*/
220-
showSubscriptionUpsell() {
221-
this.ntp.messaging.notify('omnibar_showSubscriptionUpsell', {});
241+
showSubscriptionUpsell(source) {
242+
this.ntp.messaging.notify('omnibar_showSubscriptionUpsell', { source });
222243
}
223244

224245
/**
225246
* Ask native to present the subscription upgrade flow (e.g. when a subscriber
226247
* taps "Upgrade" on a model or reasoning-effort option gated behind a higher tier).
248+
* @param {OmnibarPickerSource} source - The picker the upgrade was triggered from.
227249
*/
228-
showSubscriptionUpgrade() {
229-
this.ntp.messaging.notify('omnibar_showSubscriptionUpgrade', {});
250+
showSubscriptionUpgrade(source) {
251+
this.ntp.messaging.notify('omnibar_showSubscriptionUpgrade', { source });
230252
}
231253

232254
/**
233255
* Route a gated item's upsell to the correct native flow.
234-
* @param {'subscribe' | 'upgrade'} [type]
256+
* @param {'subscribe' | 'upgrade' | undefined} type
257+
* @param {OmnibarPickerSource} source - The picker the upsell was triggered from.
235258
*/
236-
showUpsell(type) {
259+
showUpsell(type, source) {
237260
if (type === 'upgrade') {
238-
this.showSubscriptionUpgrade();
261+
this.showSubscriptionUpgrade(source);
239262
} else {
240-
this.showSubscriptionUpsell();
263+
this.showSubscriptionUpsell(source);
241264
}
242265
}
243266

0 commit comments

Comments
 (0)