Skip to content

Commit a053eb8

Browse files
authored
NTP: Add permanent “Ask Duck.ai” suggestion to end of suggestions list (#1939)
* Rough in an "Ask AI" suggestion * Update unit tests to use SuggestionModel * Add tests for AI chat suggestion * Group AI chat suggestions into a separate section * Localize "Switch to Tab" badge * Make border extend to full width of dropdown * Remove CSS leftover from testing * Remove unused Suggestion typedef import * Update snapshot tests * Fix Omnibar.js formatting * Add key prop to SuggestionsListItem in map rendering
1 parent 40e9683 commit a053eb8

File tree

12 files changed

+480
-143
lines changed

12 files changed

+480
-143
lines changed

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,17 @@ export function Omnibar({ mode, setMode, enableAi, tabId }) {
7373
<div class={styles.popup}>
7474
<ResizingContainer className={styles.field}>
7575
{mode === 'search' ? (
76-
<SearchForm autoFocus={autoFocus} onOpenSuggestion={handleOpenSuggestion} onSubmit={handleSubmitSearch} />
76+
<SearchForm
77+
autoFocus={autoFocus}
78+
onOpenSuggestion={handleOpenSuggestion}
79+
onSubmit={handleSubmitSearch}
80+
onSubmitChat={handleSubmitChat}
81+
/>
7782
) : (
7883
<AiChatForm chat={query} autoFocus={autoFocus} onChange={setQuery} onSubmit={handleSubmitChat} />
7984
)}
8085
</ResizingContainer>
81-
{mode === 'search' && <SuggestionsList onOpenSuggestion={handleOpenSuggestion} />}
86+
{mode === 'search' && <SuggestionsList onOpenSuggestion={handleOpenSuggestion} onSubmitChat={handleSubmitChat} />}
8287
</div>
8388
</div>
8489
</SearchFormProvider>

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@ import { CloseSmallIcon } from '../../components/Icons.js';
2121
* @param {boolean} [props.autoFocus]
2222
* @param {(params: {suggestion: Suggestion, target: OpenTarget}) => void} props.onOpenSuggestion
2323
* @param {(params: {term: string, target: OpenTarget}) => void} props.onSubmit
24+
* @param {(params: {chat: string, target: OpenTarget}) => void} props.onSubmitChat
2425
*/
25-
export function SearchForm({ autoFocus, onOpenSuggestion, onSubmit }) {
26+
export function SearchForm({ autoFocus, onOpenSuggestion, onSubmit, onSubmitChat }) {
2627
const { t } = useTypedTranslationWith(/** @type {Strings} */ ({}));
2728
const platformName = usePlatformName();
2829

@@ -100,7 +101,11 @@ export function SearchForm({ autoFocus, onOpenSuggestion, onSubmit }) {
100101
case 'Enter':
101102
event.preventDefault();
102103
if (selectedSuggestion) {
103-
onOpenSuggestion({ suggestion: selectedSuggestion, target: eventToTarget(event, platformName) });
104+
if (selectedSuggestion.kind === 'aiChat') {
105+
onSubmitChat({ chat: selectedSuggestion.chat, target: eventToTarget(event, platformName) });
106+
} else {
107+
onOpenSuggestion({ suggestion: selectedSuggestion, target: eventToTarget(event, platformName) });
108+
}
104109
} else {
105110
onSubmit({ term, target: eventToTarget(event, platformName) });
106111
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export function useSuffixText(suffix) {
2121
return ' – ' + t('omnibar_visitSuffix', { url: suffix.url });
2222
case 'raw':
2323
return ' – ' + suffix.text;
24+
case 'askDuckAi':
25+
return ' – ' + t('omnibar_askDuckAiSuffix');
2426
}
2527
}
2628

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

Lines changed: 93 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Fragment, h } from 'preact';
22
import { eventToTarget } from '../../../../../shared/handlers';
33
import {
4+
AiChatIcon,
45
ArrowRightIcon,
56
BookmarkIcon,
67
BrowserIcon,
@@ -15,8 +16,10 @@ import { getSuggestionSuffix, getSuggestionTitle, startsWithIgnoreCase } from '.
1516
import { useSearchFormContext } from './SearchFormProvider';
1617
import { SuffixText } from './SuffixText';
1718
import styles from './SuggestionsList.module.css';
19+
import { useTypedTranslationWith } from '../../types';
1820

1921
/**
22+
* @typedef {import('../strings.json')} Strings
2023
* @typedef {import('./useSuggestions').SuggestionModel} SuggestionModel
2124
* @typedef {import('../../../types/new-tab.js').Suggestion} Suggestion
2225
* @typedef {import('../../../types/new-tab.js').OpenTarget} OpenTarget
@@ -25,63 +28,104 @@ import styles from './SuggestionsList.module.css';
2528
/**
2629
* @param {object} props
2730
* @param {(params: {suggestion: Suggestion, target: OpenTarget}) => void} props.onOpenSuggestion
31+
* @param {(params: {chat: string, target: OpenTarget}) => void} props.onSubmitChat
2832
*/
29-
export function SuggestionsList({ onOpenSuggestion }) {
30-
const platformName = usePlatformName();
31-
32-
const { term, suggestionsListId, suggestions, selectedSuggestion, setSelectedSuggestion, clearSelectedSuggestion } =
33-
useSearchFormContext();
33+
export function SuggestionsList({ onOpenSuggestion, onSubmitChat }) {
34+
const { suggestionsListId, suggestions } = useSearchFormContext();
3435

3536
if (suggestions.length === 0) return null;
3637

38+
const mainSuggestions = suggestions.filter((suggestion) => suggestion.kind !== 'aiChat');
39+
const footerSuggestions = suggestions.filter((suggestion) => suggestion.kind === 'aiChat');
40+
3741
return (
3842
<div role="listbox" id={suggestionsListId} class={styles.list}>
39-
{suggestions.map((suggestion) => {
40-
const title = getSuggestionTitle(suggestion, term);
41-
const suffix = getSuggestionSuffix(suggestion);
42-
return (
43-
<button
44-
key={suggestion.id}
45-
role="option"
46-
id={suggestion.id}
47-
class={styles.item}
48-
tabIndex={suggestion === selectedSuggestion ? 0 : -1}
49-
aria-selected={suggestion === selectedSuggestion}
50-
onMouseOver={() => setSelectedSuggestion(suggestion)}
51-
onMouseLeave={() => clearSelectedSuggestion()}
52-
onClick={(event) => {
53-
event.preventDefault();
54-
onOpenSuggestion({ suggestion, target: eventToTarget(event, platformName) });
55-
}}
56-
>
57-
<SuggestionIcon suggestion={suggestion} />
58-
<span class={styles.title}>
59-
{startsWithIgnoreCase(title, term) ? (
60-
<>
61-
<b>{title.slice(0, term.length)}</b>
62-
{title.slice(term.length)}
63-
</>
64-
) : (
65-
title
66-
)}
67-
</span>
68-
{suffix && (
69-
<span class={styles.suffix}>
70-
<SuffixText suffix={suffix} />
71-
</span>
72-
)}
73-
{suggestion.kind === 'openTab' && (
74-
<span class={styles.badge}>
75-
Switch to Tab <ArrowRightIcon />
76-
</span>
77-
)}
78-
</button>
79-
);
80-
})}
43+
{mainSuggestions.length > 0 && (
44+
<div class={styles.main}>
45+
{mainSuggestions.map((suggestion) => (
46+
<SuggestionsListItem
47+
key={suggestion.id}
48+
suggestion={suggestion}
49+
onOpenSuggestion={onOpenSuggestion}
50+
onSubmitChat={onSubmitChat}
51+
/>
52+
))}
53+
</div>
54+
)}
55+
{footerSuggestions.length > 0 && (
56+
<div class={styles.footer}>
57+
{footerSuggestions.map((suggestion) => (
58+
<SuggestionsListItem
59+
key={suggestion.id}
60+
suggestion={suggestion}
61+
onOpenSuggestion={onOpenSuggestion}
62+
onSubmitChat={onSubmitChat}
63+
/>
64+
))}
65+
</div>
66+
)}
8167
</div>
8268
);
8369
}
8470

71+
/**
72+
* @param {object} props
73+
* @param {SuggestionModel} props.suggestion
74+
* @param {(params: {suggestion: Suggestion, target: OpenTarget}) => void} props.onOpenSuggestion
75+
* @param {(params: {chat: string, target: OpenTarget}) => void} props.onSubmitChat
76+
*/
77+
function SuggestionsListItem({ suggestion, onOpenSuggestion, onSubmitChat }) {
78+
const { t } = useTypedTranslationWith(/** @type {Strings} */ ({}));
79+
const platformName = usePlatformName();
80+
81+
const { term, selectedSuggestion, setSelectedSuggestion, clearSelectedSuggestion } = useSearchFormContext();
82+
83+
const title = getSuggestionTitle(suggestion, term);
84+
const suffix = getSuggestionSuffix(suggestion);
85+
86+
return (
87+
<button
88+
role="option"
89+
id={suggestion.id}
90+
class={styles.item}
91+
tabIndex={suggestion === selectedSuggestion ? 0 : -1}
92+
aria-selected={suggestion === selectedSuggestion}
93+
onMouseOver={() => setSelectedSuggestion(suggestion)}
94+
onMouseLeave={() => clearSelectedSuggestion()}
95+
onClick={(event) => {
96+
event.preventDefault();
97+
if (suggestion.kind === 'aiChat') {
98+
onSubmitChat({ chat: suggestion.chat, target: eventToTarget(event, platformName) });
99+
} else {
100+
onOpenSuggestion({ suggestion, target: eventToTarget(event, platformName) });
101+
}
102+
}}
103+
>
104+
<SuggestionIcon suggestion={suggestion} />
105+
<span class={styles.title}>
106+
{startsWithIgnoreCase(title, term) ? (
107+
<>
108+
<b>{title.slice(0, term.length)}</b>
109+
{title.slice(term.length)}
110+
</>
111+
) : (
112+
title
113+
)}
114+
</span>
115+
{suffix && (
116+
<span class={styles.suffix}>
117+
<SuffixText suffix={suffix} />
118+
</span>
119+
)}
120+
{suggestion.kind === 'openTab' && (
121+
<span class={styles.badge}>
122+
{t('omnibar_switchToTab')} <ArrowRightIcon />
123+
</span>
124+
)}
125+
</button>
126+
);
127+
}
128+
85129
/**
86130
* @param {object} props
87131
* @param {SuggestionModel} props.suggestion
@@ -100,6 +144,8 @@ function SuggestionIcon({ suggestion }) {
100144
return <TabDesktopIcon />;
101145
case 'internalPage':
102146
return <BrowserIcon />;
147+
case 'aiChat':
148+
return <AiChatIcon />;
103149
default:
104150
throw new Error('Unknown suggestion kind');
105151
}

special-pages/pages/new-tab/app/omnibar/components/SuggestionsList.module.css

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
11
.list {
22
border-top: 1px solid var(--ntp-surface-border-color);
3+
display: flex;
4+
flex-direction: column;
5+
margin-top: -1px; /* - 1px to account for the border-top */
6+
}
7+
8+
.main,
9+
.footer {
310
display: flex;
411
flex-direction: column;
512
padding: calc(3px + var(--sp-1)); /* 3px for popup borders + 1sp of padding */
6-
padding-top: calc(3px + var(--sp-1) - 1px); /* - 1px to account for the border-top */
13+
}
14+
15+
.main + .footer {
16+
border-top: 1px solid var(--ntp-surface-border-color);
717
}
818

919
.item {

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

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@ import { OmnibarContext } from './OmnibarProvider.js';
77
*/
88

99
/**
10-
* @typedef {Suggestion & {
10+
* Internal representation of Suggestion with additional properties for the omnibar component.
11+
*
12+
* @typedef {(Suggestion & {
13+
* id: string,
14+
* }) | {
15+
* kind: 'aiChat',
16+
* chat: string,
1117
* id: string,
1218
* }} SuggestionModel
1319
*/
@@ -124,6 +130,7 @@ export function useSuggestions({ term, setTerm }) {
124130

125131
useEffect(() => {
126132
return onSuggestions((data, term) => {
133+
/** @type {SuggestionModel[]} */
127134
const suggestions = [
128135
...data.suggestions.topHits,
129136
...data.suggestions.duckduckgoSuggestions,
@@ -132,6 +139,16 @@ export function useSuggestions({ term, setTerm }) {
132139
...suggestion,
133140
id: `suggestion-${index}`,
134141
}));
142+
143+
// Add persistent aiChat suggestion at the end if there's a term
144+
if (term.trim().length > 0) {
145+
suggestions.push({
146+
kind: 'aiChat',
147+
chat: term,
148+
id: 'suggestion-ai-chat',
149+
});
150+
}
151+
135152
dispatch({
136153
type: 'setSuggestions',
137154
term,

0 commit comments

Comments
 (0)