| // Copyright 2025 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "chrome/browser/contextual_tasks/contextual_tasks_ui_service.h" |
| |
| #include "base/containers/adapters.h" |
| #include "base/containers/contains.h" |
| #include "base/logging.h" |
| #include "base/strings/strcat.h" |
| #include "base/strings/string_util.h" |
| #include "base/task/sequenced_task_runner.h" |
| #include "base/uuid.h" |
| #include "chrome/browser/contextual_tasks/contextual_tasks_context_controller.h" |
| #include "chrome/browser/contextual_tasks/contextual_tasks_side_panel_coordinator.h" |
| #include "chrome/browser/contextual_tasks/contextual_tasks_ui.h" |
| #include "chrome/browser/profiles/profile.h" |
| #include "chrome/browser/ui/browser_navigator.h" |
| #include "chrome/browser/ui/browser_navigator_params.h" |
| #include "chrome/browser/ui/browser_window/public/browser_window_features.h" |
| #include "chrome/browser/ui/browser_window/public/browser_window_interface.h" |
| #include "chrome/browser/ui/tabs/tab_enums.h" |
| #include "chrome/browser/ui/tabs/tab_strip_model.h" |
| #include "chrome/browser/ui/views/frame/browser_view.h" |
| #include "chrome/common/webui_url_constants.h" |
| #include "components/contextual_tasks/public/contextual_task.h" |
| #include "components/contextual_tasks/public/features.h" |
| #include "components/sessions/content/session_tab_helper.h" |
| #include "components/tabs/public/tab_interface.h" |
| #include "content/public/browser/navigation_controller.h" |
| #include "content/public/browser/web_contents.h" |
| #include "net/base/url_util.h" |
| #include "ui/base/page_transition_types.h" |
| #include "ui/base/window_open_disposition.h" |
| |
| using sessions::SessionTabHelper; |
| |
| namespace contextual_tasks { |
| |
| namespace { |
| constexpr char kAiPageHost[] = "https://google.com"; |
| constexpr char kTaskQueryParam[] = "task"; |
| |
| bool IsContextualTasksHost(const GURL& url) { |
| return url.scheme() == content::kChromeUIScheme && |
| url.host() == chrome::kChromeUIContextualTasksHost; |
| } |
| |
| GURL AppendCommonUrlParams(GURL url) { |
| url = net::AppendQueryParameter(url, "gsc", "2"); |
| // TODO(crbug.com/454388385): Remove this param once authentication flow is |
| // implemented. |
| url = net::AppendQueryParameter(url, "gl", "us"); |
| return url; |
| } |
| |
| bool IsSignInDomain(const GURL& url) { |
| if (!url.is_valid() || !url.SchemeIsHTTPOrHTTPS()) { |
| return false; |
| } |
| std::vector<std::string> sign_in_domains = GetContextualTasksSignInDomains(); |
| for (const auto& sign_in_domain : sign_in_domains) { |
| if (url.host() == sign_in_domain) { |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| // Gets the contextual task Id from a contextual task host URL. |
| base::Uuid GetTaskIdFromHostURL(const GURL& url) { |
| std::string task_id; |
| net::GetValueForKeyInQuery(url, kTaskQueryParam, &task_id); |
| return base::Uuid::ParseLowercase(task_id); |
| } |
| } // namespace |
| |
| ContextualTasksUiService::ContextualTasksUiService( |
| Profile* profile, |
| ContextualTasksContextController* context_controller) |
| : profile_(profile), context_controller_(context_controller) { |
| ai_page_host_ = GURL(kAiPageHost); |
| } |
| |
| ContextualTasksUiService::~ContextualTasksUiService() = default; |
| |
| void ContextualTasksUiService::OnNavigationToAiPageIntercepted( |
| const GURL& url, |
| base::WeakPtr<tabs::TabInterface> tab, |
| bool is_to_new_tab) { |
| CHECK(context_controller_); |
| |
| // Create a task for the URL that was just intercepted. |
| ContextualTask task = context_controller_->CreateTaskFromUrl(url); |
| |
| // Map the task ID to the a new URL that uses the base AI page URL with the |
| // query from the one that was intercepted. This is done so the UI knows |
| // which URL to load initially in the embedded frame. |
| std::string query; |
| net::GetValueForKeyInQuery(url, "q", &query); |
| GURL stripped_query_url = GetDefaultAiPageUrl(); |
| if (!query.empty()) { |
| stripped_query_url = |
| net::AppendQueryParameter(stripped_query_url, "q", query); |
| } |
| task_id_to_creation_url_[task.GetTaskId()] = stripped_query_url; |
| |
| GURL ui_url(chrome::kChromeUIContextualTasksURL); |
| ui_url = net::AppendQueryParameter(ui_url, kTaskQueryParam, |
| task.GetTaskId().AsLowercaseString()); |
| |
| content::WebContents* contextual_task_web_contents = nullptr; |
| if (!is_to_new_tab) { |
| tab->GetContents()->GetController().LoadURLWithParams( |
| content::NavigationController::LoadURLParams(ui_url)); |
| contextual_task_web_contents = tab->GetContents(); |
| } else { |
| NavigateParams params(profile_, ui_url, ui::PAGE_TRANSITION_AUTO_TOPLEVEL); |
| params.disposition = WindowOpenDisposition::NEW_FOREGROUND_TAB; |
| |
| Navigate(¶ms); |
| contextual_task_web_contents = params.navigated_or_inserted_contents; |
| } |
| // Attach the session Id of the ai page to the task. |
| if (contextual_task_web_contents) { |
| AssociateWebContentsToTask(contextual_task_web_contents, task.GetTaskId()); |
| } |
| } |
| |
| void ContextualTasksUiService::OnThreadLinkClicked( |
| const GURL& url, |
| base::Uuid task_id, |
| base::WeakPtr<tabs::TabInterface> tab, |
| base::WeakPtr<BrowserWindowInterface> browser) { |
| if (!browser) { |
| return; |
| } |
| |
| TabStripModel* tab_strip_model = browser->GetTabStripModel(); |
| std::unique_ptr<content::WebContents> new_contents = |
| content::WebContents::Create( |
| content::WebContents::CreateParams(profile_)); |
| content::WebContents* new_contents_ptr = new_contents.get(); |
| new_contents->GetController().LoadURLWithParams( |
| content::NavigationController::LoadURLParams(url)); |
| |
| // If the source contents is the panel, open the AI page in a new foreground |
| // tab. |
| // TODO(crbug.com/458139141): Split this API so we can assume `tab` non-null. |
| if (!tab) { |
| // Creates the Tab so session ID is created for the WebContents. |
| auto tab_to_insert = std::make_unique<tabs::TabModel>( |
| std::move(new_contents), tab_strip_model); |
| if (task_id.is_valid()) { |
| AssociateWebContentsToTask(new_contents_ptr, task_id); |
| } |
| |
| // Insert the WebContents after the current active tab. |
| int active_tab_index = tab_strip_model->active_index(); |
| tab_strip_model->AddTab(std::move(tab_to_insert), active_tab_index + 1, |
| ui::PAGE_TRANSITION_LINK, AddTabTypes::ADD_ACTIVE); |
| return; |
| } |
| |
| if (tab->GetContents() && |
| IsContextualTasksHost(tab->GetContents()->GetLastCommittedURL())) { |
| content::WebUI* webui = tab->GetContents()->GetWebUI(); |
| if (webui && webui->GetController()) { |
| webui->GetController() |
| ->GetAs<ContextualTasksUI>() |
| ->OnSidePanelStateChanged(); |
| } |
| } |
| |
| // Get the index of the web contents. |
| const int current_index = tab_strip_model->GetIndexOfTab(tab.get()); |
| |
| // Open the linked page in a tab directly after this one. |
| tab_strip_model->InsertWebContentsAt( |
| current_index + 1, std::move(new_contents), AddTabTypes::ADD_ACTIVE); |
| |
| // Detach the WebContents from tab. |
| std::unique_ptr<content::WebContents> contextual_task_contents = |
| tab_strip_model->DetachWebContentsAtForInsertion( |
| current_index, |
| TabStripModelChange::RemoveReason::kInsertedIntoSidePanel); |
| |
| CHECK(new_contents_ptr == tab_strip_model->GetActiveWebContents()); |
| AssociateWebContentsToTask(new_contents_ptr, task_id); |
| |
| // Transfer the contextual task contents into the side panel cache. |
| ContextualTasksSidePanelCoordinator::From(browser.get()) |
| ->TransferWebContentsFromTab(task_id, |
| std::move(contextual_task_contents)); |
| |
| // Open the side panel. |
| // TODO: This currently should be passed the bounds of the |
| // contents_container_view from BrowserView, though the view is not accessible |
| // from here. This API could be changed to simply accept the web_contents. |
| ContextualTasksSidePanelCoordinator::From(browser.get())->Show(); |
| } |
| |
| bool ContextualTasksUiService::HandleNavigation( |
| const GURL& navigation_url, |
| bool initiated_in_page, |
| content::WebContents* source_contents, |
| bool is_to_new_tab) { |
| // Allow any navigation to the contextual tasks host. |
| if (IsContextualTasksHost(navigation_url)) { |
| return false; |
| } |
| |
| bool is_nav_to_ai = IsAiUrl(navigation_url); |
| bool is_nav_to_sign_in = IsSignInDomain(navigation_url); |
| |
| // Try to get the active tab if there is one. This will be null if the link is |
| // originating from the side panel. |
| tabs::TabInterface* tab = nullptr; |
| BrowserWindowInterface* browser = nullptr; |
| if (source_contents) { |
| tab = tabs::TabInterface::MaybeGetFromContents(source_contents); |
| BrowserWindow* window = |
| BrowserWindow::FindBrowserWindowWithWebContents(source_contents); |
| if (window) { |
| browser = window->AsBrowserView()->browser(); |
| } |
| } |
| |
| // Intercept any navigation where the wrapping WebContents is the WebUI host |
| // unless it is the embedded page. |
| if (IsContextualTasksHost(source_contents->GetLastCommittedURL())) { |
| if (is_nav_to_ai || !initiated_in_page) { |
| return false; |
| } |
| // Allow users to sign in within the <webview>. |
| // TODO(crbug.com/454388385): Remove this once the authentication flow is |
| // implemented. |
| if (is_nav_to_sign_in) { |
| return false; |
| } |
| |
| base::Uuid task_id; |
| if (source_contents) { |
| task_id = GetTaskIdFromHostURL(source_contents->GetLastCommittedURL()); |
| } |
| |
| // This needs to be posted in case the called method triggers a navigation |
| // in the same WebContents, invalidating the nav handle used up the chain. |
| base::SequencedTaskRunner::GetCurrentDefault()->PostTask( |
| FROM_HERE, |
| base::BindOnce(&ContextualTasksUiService::OnThreadLinkClicked, |
| weak_ptr_factory_.GetWeakPtr(), navigation_url, task_id, |
| tab ? tab->GetWeakPtr() : nullptr, |
| browser ? browser->GetWeakPtr() : nullptr)); |
| return true; |
| } |
| |
| // Navigations to the AI URL in the topmost frame should always be |
| // intercepted. |
| if (is_nav_to_ai) { |
| // This needs to be posted in case the called method triggers a navigation |
| // in the same WebContents, invalidating the nav handle used up the chain. |
| base::SequencedTaskRunner::GetCurrentDefault()->PostTask( |
| FROM_HERE, |
| base::BindOnce( |
| &ContextualTasksUiService::OnNavigationToAiPageIntercepted, |
| weak_ptr_factory_.GetWeakPtr(), navigation_url, |
| tab ? tab->GetWeakPtr() : nullptr, is_to_new_tab)); |
| return true; |
| } |
| |
| // Allow anything else. |
| return false; |
| } |
| |
| GURL ContextualTasksUiService::GetInitialUrlForTask(const base::Uuid& uuid) { |
| auto it = task_id_to_creation_url_.find(uuid); |
| if (it != task_id_to_creation_url_.end()) { |
| return it->second; |
| } |
| return GURL(); |
| } |
| |
| GURL ContextualTasksUiService::GetDefaultAiPageUrl() { |
| return AppendCommonUrlParams(GURL(GetContextualTasksAiPageUrl())); |
| } |
| |
| void ContextualTasksUiService::OnTaskChangedInPanel( |
| BrowserWindowInterface* browser_window_interface, |
| const base::Uuid& task_id) { |
| // If a new thread is started in the panel, affiliated tabs should change |
| // their thread to the new one. |
| base::Uuid new_task_id = task_id; |
| if (!task_id.is_valid()) { |
| // If the panel is in zero state, create an empty task. |
| ContextualTask task = context_controller_->CreateTask(); |
| new_task_id = task.GetTaskId(); |
| } |
| |
| TabStripModel* tab_strip_model = browser_window_interface->GetTabStripModel(); |
| content::WebContents* active_contents = |
| tab_strip_model->GetActiveWebContents(); |
| SessionID active_id = SessionTabHelper::IdForTab(active_contents); |
| |
| if (kTaskScopedSidpePanel.Get()) { |
| // If the current tab is associated with any task, change associations for |
| // all tabs associated with that task. |
| std::optional<ContextualTask> current_task = |
| context_controller_->GetContextualTaskForTab(active_id); |
| if (current_task) { |
| std::vector<SessionID> tab_ids = |
| context_controller_->GetTabsAssociatedWithTask( |
| current_task->GetTaskId()); |
| for (const auto& id : tab_ids) { |
| context_controller_->AssociateTabWithTask(new_task_id, id); |
| } |
| return; |
| } |
| } |
| |
| context_controller_->AssociateTabWithTask(new_task_id, active_id); |
| } |
| |
| void ContextualTasksUiService::MoveTaskUiToToNewTab( |
| const base::Uuid& task_id, |
| BrowserWindowInterface* browser) { |
| auto* coordinator = |
| contextual_tasks::ContextualTasksSidePanelCoordinator::From(browser); |
| CHECK(coordinator); |
| |
| std::unique_ptr<content::WebContents> web_contents = |
| coordinator->DetachWebContentsForTask(task_id); |
| if (!web_contents) { |
| return; |
| } |
| |
| NavigateParams params(browser, std::move(web_contents)); |
| params.disposition = WindowOpenDisposition::NEW_FOREGROUND_TAB; |
| params.transition = ui::PAGE_TRANSITION_LINK; |
| Navigate(¶ms); |
| |
| coordinator->Close(); |
| } |
| |
| bool ContextualTasksUiService::IsAiUrl(const GURL& url) { |
| if (!url.is_valid() || !url.SchemeIsHTTPOrHTTPS() || |
| !base::EndsWith(url.host(), ai_page_host_.host())) { |
| return false; |
| } |
| |
| if (!base::StartsWith(url.path(), "/search")) { |
| return false; |
| } |
| |
| // AI pages are identified by the "udm" URL param having a value of 50. |
| std::string udm_value; |
| if (!net::GetValueForKeyInQuery(url, "udm", &udm_value)) { |
| return false; |
| } |
| |
| return udm_value == "50"; |
| } |
| |
| void ContextualTasksUiService::AssociateWebContentsToTask( |
| content::WebContents* web_contents, |
| const base::Uuid& task_id) { |
| SessionID session_id = SessionTabHelper::IdForTab(web_contents); |
| if (session_id.is_valid()) { |
| context_controller_->AssociateTabWithTask(task_id, session_id); |
| } |
| } |
| } // namespace contextual_tasks |