Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { OmnibarService } from '../omnibar.service.js';
* @typedef {import('../../../types/new-tab.js').SubmitChatAction} SubmitChatAction
* @typedef {import('../../../types/new-tab.js').GetOpenTabsResponse} GetOpenTabsResponse
* @typedef {import('../../../types/new-tab.js').PageContext} PageContext
* @typedef {import('../../../types/new-tab.js').OmnibarPickerSource} OmnibarPickerSource
* @typedef {import('../../service.hooks.js').State<null, OmnibarConfig>} State
*/

Expand Down Expand Up @@ -79,6 +80,18 @@ export const OmnibarContext = createContext({
viewAllAiChats: () => {
throw new Error('must implement');
},
/** @type {(picker: OmnibarPickerSource) => void} */
pickerShown: () => {
throw new Error('must implement');
},
/** @type {(picker: OmnibarPickerSource) => void} */
upsellShown: () => {
throw new Error('must implement');
},
/** @type {(type: 'subscribe' | 'upgrade' | undefined, source: OmnibarPickerSource) => void} */
showUpsell: () => {
throw new Error('must implement');
},
/** @type {() => Promise<GetOpenTabsResponse>} */
getOpenTabs: () => {
throw new Error('must implement');
Expand Down Expand Up @@ -231,6 +244,30 @@ export function OmnibarProvider(props) {
[service],
);

/** @type {(picker: OmnibarPickerSource) => void} */
const pickerShown = useCallback(
(picker) => {
service.current?.pickerShown(picker);
},
[service],
);

/** @type {(picker: OmnibarPickerSource) => void} */
const upsellShown = useCallback(
(picker) => {
service.current?.upsellShown(picker);
},
[service],
);

/** @type {(type: 'subscribe' | 'upgrade' | undefined, source: OmnibarPickerSource) => void} */
const showUpsell = useCallback(
(type, source) => {
service.current?.showUpsell(type, source);
},
[service],
);

/** @type {() => Promise<GetOpenTabsResponse>} */
const getOpenTabs = useCallback(() => {
if (!service.current) throw new Error('Service not available');
Expand Down Expand Up @@ -264,6 +301,9 @@ export function OmnibarProvider(props) {
onAiChats,
openAiChat,
viewAllAiChats,
pickerShown,
upsellShown,
showUpsell,
getOpenTabs,
getTabContent,
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@
margin-left: auto;
}

/* Gated options (e.g. "Upgrade"): gray out the icon and label to signal they aren't selectable,
while keeping the trailing badge at full opacity. */
.itemDimmed > svg,
.itemDimmed .itemLabel {
opacity: 0.4;
}

.itemActive {
background: var(--ds-color-theme-control-fill-primary);
color: var(--ds-color-theme-text-primary);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ import styles from './Dropdown.module.css';
*
* @param {object} props
* @param {import('preact').ComponentChildren} [props.icon]
* @param {import('preact').ComponentChildren} [props.trailingIcon]
* @param {import('preact').ComponentChildren} [props.trailingIcon] - Rendered after the label. Callers own its accessibility (mark decorative icons `aria-hidden`; leave meaningful ones, e.g. an "Upgrade" badge, exposed).
* @param {string} props.name
* @param {string} [props.description]
* @param {boolean} [props.isSelected]
* @param {boolean} [props.isDimmed] - Grays the icon and label (e.g. gated options) while keeping the trailing badge legible
* @param {'option' | 'menuitemcheckbox' | 'menuitemradio' | 'menuitem'} props.role
* @param {() => void} props.onSelect
* @param {boolean} [props.ariaChecked]
Expand All @@ -36,6 +37,7 @@ export function DropdownItem({
name,
description,
isSelected = false,
isDimmed = false,
role,
ariaChecked,
ariaSelected,
Expand Down Expand Up @@ -69,7 +71,7 @@ export function DropdownItem({
aria-selected={ariaSelected}
aria-haspopup={ariaHasPopup}
aria-expanded={ariaExpanded}
class={cn(styles.item, isActive && styles.itemActive, isSelected && styles.itemSelected)}
class={cn(styles.item, isActive && styles.itemActive, isSelected && styles.itemSelected, isDimmed && styles.itemDimmed)}
onMouseOver={onMouseOver}
onMouseEnter={onHover}
onClick={onClick}
Expand All @@ -80,11 +82,7 @@ export function DropdownItem({
<span class={styles.itemName}>{name}</span>
{description && <span class={styles.itemDescription}>{description}</span>}
</div>
{trailingIcon && (
<span class={styles.trailingIcon} aria-hidden="true">
{trailingIcon}
</span>
)}
{trailingIcon && <span class={styles.trailingIcon}>{trailingIcon}</span>}
</li>
);
}
Original file line number Diff line number Diff line change
@@ -1,20 +1,45 @@
import { Fragment, h } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import cn from 'classnames';
import { useTypedTranslationWith } from '../../../../types';
import styles from './ModelSelector.module.css';
import { getModelIcon } from './Icons';

/**
* @typedef {import('../../../strings.json')} Strings
* @typedef {import('../../../../../types/new-tab.js').AIModelItem} AIModelItem
*/

/**
* Returns the badge label for a model row, or null when no badge should show.
*
* @param {AIModelItem} model
* @param {ReturnType<typeof useTypedTranslationWith<Strings>>['t']} t
* @returns {string | null}
*/
function getRowBadgeLabel(model, t) {
const tiers = model.accessTier ?? [];
if (tiers.length === 1 && tiers[0] === 'internal') return t('omnibar_modelBadgeInternal');
if (model.isBeta) return t('omnibar_modelBadgeBeta');
if (tiers.includes('free')) return null;
if (tiers.includes('plus')) return t('omnibar_modelBadgePlus');
if (tiers.includes('pro')) return t('omnibar_modelBadgePro');
return null;
}

/**
* @param {object} props
* @param {import('../../../../../types/new-tab.js').AIModelSections} props.sections
* @param {string} [props.selectedModelId]
* @param {import('../useDropdown.js').DropdownPosition} props.dropdownPos
* @param {(options: {restoreFocus: boolean}) => void} props.onClose
* @param {(id: string) => void} props.onSelect
* @param {(type?: 'subscribe' | 'upgrade') => void} props.onUpsell
* @param {string} props.ariaLabel
* @param {import('preact').RefObject<HTMLUListElement>} [props.dropdownRef]
*/
export function ModelDropdown({ sections, selectedModelId, dropdownPos, onClose, onSelect, ariaLabel, dropdownRef }) {
export function ModelDropdown({ sections, selectedModelId, dropdownPos, onClose, onSelect, onUpsell, ariaLabel, dropdownRef }) {
const { t } = useTypedTranslationWith(/** @type {Strings} */ ({}));
const allModels = sections.flatMap((section) => section.items);
const optionIndexById = new Map(allModels.map((model, index) => [model.id, index]));
const enabledModelIndices = allModels.reduce(
Expand Down Expand Up @@ -117,49 +142,77 @@ export function ModelDropdown({ sections, selectedModelId, dropdownPos, onClose,
onKeyDown={handleKeyDown}
onMouseLeave={clearActiveIndex}
>
{sections.map((section, sectionIndex) => (
<Fragment key={sectionIndex}>
{section.header && (
<Fragment>
<li role="separator" class={styles.modelSectionDivider} />
<li role="presentation" class={styles.modelSectionHeader}>
{section.header}
</li>
</Fragment>
)}
{section.items.map((model) => {
const Icon = getModelIcon(model.id);
const optionIndex = optionIndexById.get(model.id) ?? -1;
return (
<li
key={model.id}
id={getOptionId(optionIndex)}
role="option"
aria-selected={model.isEnabled ? model.id === selectedModelId : undefined}
aria-disabled={!model.isEnabled || undefined}
class={cn(
styles.modelOption,
model.isEnabled && activeIndex === optionIndex && styles.modelOptionActive,
!model.isEnabled && styles.modelOptionDisabled,
model.isEnabled && model.id === selectedModelId && styles.modelOptionSelected,
)}
onMouseOver={model.isEnabled ? () => setActiveIndex(optionIndex) : undefined}
onClick={
model.isEnabled
? (e) => {
e.stopPropagation();
onSelect(model.id);
}
: undefined
}
>
{Icon && <Icon />}
<span>{model.name}</span>
</li>
);
})}
</Fragment>
))}
{sections.map((section, sectionIndex) => {
const isUpsellSection = section.items.length > 0 && section.items.every((model) => !model.isEnabled);
const sectionUpsell = section.items.find((model) => model.upsell)?.upsell ?? 'subscribe';
return (
<Fragment key={sectionIndex}>
{isUpsellSection ? (
<Fragment>
<li role="separator" class={styles.modelSectionDivider} />
<li role="presentation" class={styles.modelUpsellHeader}>
<span class={styles.modelUpsellText}>{t('omnibar_subscriberExclusive')}</span>
<button
type="button"
class={styles.modelUpsellCta}
onClick={(e) => {
e.stopPropagation();
onUpsell(sectionUpsell);
}}
>
{sectionUpsell === 'upgrade' ? t('omnibar_upgrade') : t('omnibar_tryForFree')}
</button>
</li>
</Fragment>
) : (
section.header && (
<Fragment>
<li role="separator" class={styles.modelSectionDivider} />
<li role="presentation" class={styles.modelSectionHeader}>
{section.header}
</li>
</Fragment>
)
)}
{section.items.map((model) => {
const Icon = getModelIcon(model.id);
const optionIndex = optionIndexById.get(model.id) ?? -1;
const badgeLabel = getRowBadgeLabel(model, t);
return (
<li
key={model.id}
id={getOptionId(optionIndex)}
role="option"
aria-selected={model.isEnabled ? model.id === selectedModelId : undefined}
aria-disabled={!model.isEnabled || undefined}
class={cn(
styles.modelOption,
model.isEnabled && activeIndex === optionIndex && styles.modelOptionActive,
!model.isEnabled && styles.modelOptionDisabled,
model.isEnabled && model.id === selectedModelId && styles.modelOptionSelected,
)}
onMouseOver={model.isEnabled ? () => setActiveIndex(optionIndex) : undefined}
onClick={
model.isEnabled
? (e) => {
e.stopPropagation();
onSelect(model.id);
}
: undefined
}
>
{Icon && <Icon />}
<div class={styles.modelOptionLabel}>
<span class={styles.modelOptionName}>{model.name}</span>
{model.description && <span class={styles.modelOptionDescription}>{model.description}</span>}
</div>
{badgeLabel && <span class={styles.modelOptionBadge}>{badgeLabel}</span>}
</li>
);
})}
</Fragment>
);
})}
</ul>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ import styles from './ModelSelector.module.css';
* @param {import('./useModelSelector').ModelSelectorState} props.selector
* @param {import('../../../../../types/new-tab.js').AIModelItem|null} props.selectedModel
* @param {import('../../../../../types/new-tab.js').AIModelSections} props.aiModelSections
* @param {(type?: 'subscribe' | 'upgrade') => void} props.onUpsell
* @param {string} props.ariaLabel
*/
export function ModelSelector({ selector, selectedModel, aiModelSections, ariaLabel }) {
export function ModelSelector({ selector, selectedModel, aiModelSections, onUpsell, ariaLabel }) {
const { modelButtonRef, modelDropdownOpen, dropdownPos, dropdownRef, toggleDropdown, closeDropdown, selectModel } = selector;
/** @param {{ restoreFocus: boolean }} options */
const handleClose = ({ restoreFocus }) => {
Expand Down Expand Up @@ -45,6 +46,7 @@ export function ModelSelector({ selector, selectedModel, aiModelSections, ariaLa
dropdownPos={dropdownPos}
onClose={handleClose}
onSelect={selectModel}
onUpsell={onUpsell}
ariaLabel={ariaLabel}
/>
)}
Expand Down
Loading
Loading