Skip to content

Commit 3a8c8fd

Browse files
committed
Implement Brave's own tab organization UI
- Hook into where Chromium's tab organization UI is located by overriding auto_tab_groups_page to our own implementation. - Chromium's TabSearchPageHandler and TabSearchUI is overriden to include what we need for our own tab organization feature using Leo. - Add a pref toggle in Leo settings page for user to enable/disable tab organization UI. - Add a feature flag for tab organization feature.
1 parent bb6510a commit 3a8c8fd

32 files changed

+1784
-10
lines changed

app/brave_generated_resources.grd

+35
Original file line numberDiff line numberDiff line change
@@ -1373,6 +1373,41 @@ Or change later at <ph name="SETTINGS_EXTENIONS_LINK">$2<ex>brave://settings/ext
13731373
<message name="IDS_BRAVE_FILE_SELECT_SAVE_TITLE_NONSTANDARD_URL_IFRAME" desc="">
13741374
An embedded page on this page wants to save
13751375
</message>
1376+
1377+
<!-- Tab Search -->
1378+
<message name="IDS_BRAVE_ORGANIZE_TAB_TITLE" desc="Title for the tab organizer page">
1379+
Organize tabs
1380+
</message>
1381+
<message name="IDS_BRAVE_ORGANIZE_TAB_SUBTITLE" desc="Subtitle for the tab organizer page">
1382+
Gather all related tabs into a new window
1383+
</message>
1384+
<message name="IDS_BRAVE_ORGANIZE_TAB_SUGGESTED_TOPICS_SUBTITLE" desc="Subtitle for the suggested topics section">
1385+
Suggested topics
1386+
</message>
1387+
<message name="IDS_BRAVE_ORGANIZE_TAB_TOPIC_INPUT_PLACEHOLDER" desc="Placeholder text for the input field to enter a topic">
1388+
Enter a topic to focus on...
1389+
</message>
1390+
<message name="IDS_BRAVE_ORGANIZE_TAB_SUBMIT_BUTTON_LABEL" desc="Label for the submit topic input button">
1391+
Go
1392+
</message>
1393+
<message name="IDS_BRAVE_ORGANIZE_TAB_UNDO_BUTTON_LABEL" desc="Label for the undo button">
1394+
Undo
1395+
</message>
1396+
<message name="IDS_BRAVE_ORGANIZE_TAB_WINDOW_CREATED_MESSAGE" desc="Message shown when a new window is created">
1397+
New window created for <ph name="TOPIC">$1<ex>Social media</ex></ph>
1398+
</message>
1399+
<message name="IDS_BRAVE_ORGANIZE_TAB_SEND_TAB_DATA_MESSAGE" desc="Description of how tab data is shared with Leo">
1400+
This feature sends data about your open tabs to Leo, Brave's AI-powered browser assistant.
1401+
</message>
1402+
<message name="IDS_BRAVE_ORGANIZE_TAB_LEARN_MORE_LABEL" desc="Text of the learn more link">
1403+
Learn more
1404+
</message>
1405+
<message name="IDS_BRAVE_ORGANIZE_TAB_GO_PREMIUM_BUTTON_LABEL" desc="Text of go premium button">
1406+
Go Premium
1407+
</message>
1408+
<message name="IDS_BRAVE_ORGANIZE_TAB_DISMISS_BUTTON_LABEL" desc="Text of the dismiss button">
1409+
Dismiss
1410+
</message>
13761411
<!--Add new items to the appropriate sections above -->
13771412
</messages>
13781413
</release>

app/brave_settings_strings.grdp

+6
Original file line numberDiff line numberDiff line change
@@ -1517,6 +1517,12 @@
15171517
<message name="IDS_SETTINGS_LEO_ASSISTANT_SHOW_IN_CONTEXT_MENU_DESC" desc="The description for settings option to show Leo in context menu">
15181518
Leo will appear in the context menu when you right-click on a website. This will send context from the current web page to Leo.
15191519
</message>
1520+
<message name="IDS_SETTINGS_LEO_ASSISTANT_TAB_ORGANIZATION_LABEL" desc="The text for settings option to enable tab organization">
1521+
Enable tab organization with Leo
1522+
</message>
1523+
<message name="IDS_SETTINGS_LEO_ASSISTANT_TAB_ORGANIZATION_DESC" desc="The description for settings option to enable tab organization">
1524+
Enable tab organization in Tab Search page. When opened, this will automatically send the titles and domains of all non-private tabs to Leo to classify them using Anthropic's Claude Haiku or Claude Sonnet model. This data is subject to the <ph name="BEGIN_LINK">&lt;a target="_blank" href="$1"&gt;</ph>Leo privacy policy<ph name="END_LINK">&lt;/a&gt;</ph>.
1525+
</message>
15201526
<message name="IDS_SETTINGS_LEO_ASSISTANT_SHOW_SUGGESTED_PROMPTS_LABEL" desc="The text for settings option">
15211527
Show suggested prompts in the conversation
15221528
</message>

browser/extensions/api/settings_private/brave_prefs_util.cc

+2
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,8 @@ const PrefsUtil::TypedPrefMap& BravePrefsUtil::GetAllowlistedKeys() {
256256
settings_api::PrefType::kBoolean;
257257
(*s_brave_allowlist)[ai_chat::prefs::kBraveAIChatShowToolbarButton] =
258258
settings_api::PrefType::kBoolean;
259+
(*s_brave_allowlist)[ai_chat::prefs::kBraveAIChatTabOrganizationEnabled] =
260+
settings_api::PrefType::kBoolean;
259261

260262
#if !BUILDFLAG(USE_GCM_FROM_PLATFORM)
261263
// Push Messaging Pref

browser/resources/settings/brave_leo_assistant_page/brave_leo_assistant_page.html

+6
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@
5252
label="$i18n{braveLeoAssistantShowInContextMenuLabel}"
5353
sub-label="$i18n{braveLeoAssistantShowInContextMenuDesc}">
5454
</settings-toggle-button>
55+
<settings-toggle-button class="cr-row hr"
56+
pref="{{prefs.brave.ai_chat.tab_organization_enabled}}"
57+
label="$i18n{braveLeoAssistantTabOrganizationLabel}"
58+
sub-label-with-link="$i18n{braveLeoAssistantTabOrganizationDesc}"
59+
on-sub-label-link-clicked="openLeoPrivacyPolicy_">
60+
</settings-toggle-button>
5561
<div class="settings-box">
5662
<div class="flex cr-padded-text">
5763
<div>$i18n{braveLeoAssistantModelSelectionLabel}</div>

browser/resources/settings/brave_leo_assistant_page/brave_leo_assistant_page.ts

+4
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,10 @@ class BraveLeoAssistantPageElement extends BraveLeoAssistantPageBase {
192192
window.open(this.manageUrl_, "_self", "noopener noreferrer")
193193
}
194194

195+
openLeoPrivacyPolicy_() {
196+
window.open(loadTimeData.getString('braveLeoAssistanPrivacyPolicyURL'), "_blank", "noopener noreferrer")
197+
}
198+
195199
private onStorageEnabledChange_(event: Event) {
196200
const target = event.target
197201
assert(target instanceof SettingsToggleButtonElement);

browser/ui/webui/settings/brave_settings_localized_strings_provider.cc

+13
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ constexpr char16_t kLeoCustomModelsLearnMoreURL[] =
8787
u"https://support.brave.com/hc/en-us/articles/"
8888
u"34070140231821-How-do-I-use-the-Bring-Your-Own-Model-BYOM-with-Brave-Leo";
8989

90+
constexpr char16_t kLeoPrivacyPolicyURL[] =
91+
u"https://brave.com/privacy/browser/#brave-leo";
92+
9093
void BraveAddCommonStrings(content::WebUIDataSource* html_source,
9194
Profile* profile) {
9295
webui::LocalizedString localized_strings[] = {
@@ -524,6 +527,8 @@ void BraveAddCommonStrings(content::WebUIDataSource* html_source,
524527
IDS_SETTINGS_LEO_ASSISTANT_SHOW_IN_CONTEXT_MENU_LABEL},
525528
{"braveLeoAssistantShowInContextMenuDesc",
526529
IDS_SETTINGS_LEO_ASSISTANT_SHOW_IN_CONTEXT_MENU_DESC},
530+
{"braveLeoAssistantTabOrganizationLabel",
531+
IDS_SETTINGS_LEO_ASSISTANT_TAB_ORGANIZATION_LABEL},
527532
{"braveLeoAssistantHistoryPreferenceLabel",
528533
IDS_SETTINGS_LEO_ASSISTANT_HISTORY_PREFERENCE_LABEL},
529534
{"braveLeoAssistantHistoryPreferenceConfirm",
@@ -949,6 +954,14 @@ void BraveAddCommonStrings(content::WebUIDataSource* html_source,
949954
"braveLeoAssistantInputDefaultContextSize",
950955
base::NumberToString16(ai_chat::kDefaultCustomModelContextSize));
951956

957+
html_source->AddString("braveLeoAssistantTabOrganizationDesc",
958+
l10n_util::GetStringFUTF16(
959+
IDS_SETTINGS_LEO_ASSISTANT_TAB_ORGANIZATION_DESC,
960+
kLeoPrivacyPolicyURL));
961+
962+
html_source->AddString("braveLeoAssistanPrivacyPolicyURL",
963+
kLeoPrivacyPolicyURL);
964+
952965
#if BUILDFLAG(ENABLE_EXTENSIONS)
953966
html_source->AddString("webDiscoveryLearnMoreURL", kWebDiscoveryLearnMoreUrl);
954967
#endif

browser/ui/webui/tab_search/BUILD.gn

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Copyright (c) 2025 The Brave Authors. All rights reserved.
2+
# This Source Code Form is subject to the terms of the Mozilla Public
3+
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
4+
# You can obtain one at https://mozilla.org/MPL/2.0/.
5+
6+
import("//brave/build/config.gni")
7+
8+
# Curerntly this target is desktop only because chromium's
9+
# tab_search_page_handler is desktop only.
10+
if (!is_android) {
11+
source_set("browser_tests") {
12+
testonly = true
13+
14+
sources = [ "tab_search_page_handler_browsertest.cc" ]
15+
16+
defines = [ "HAS_OUT_OF_PROC_TEST_RUNNER" ]
17+
18+
deps = [
19+
"//base",
20+
"//brave/components/ai_chat/core/browser",
21+
"//brave/components/ai_chat/core/browser:test_support",
22+
"//brave/components/resources:strings_grit",
23+
"//chrome/browser/ui",
24+
"//chrome/browser/ui:browser_navigator_params_headers",
25+
"//chrome/test",
26+
"//chrome/test:test_support",
27+
"//testing/gtest",
28+
"//ui/base",
29+
]
30+
}
31+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
// Copyright (c) 2025 The Brave Authors. All rights reserved.
2+
// This Source Code Form is subject to the terms of the Mozilla Public
3+
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
4+
// You can obtain one at https://mozilla.org/MPL/2.0/.
5+
6+
#include "chrome/browser/ui/webui/tab_search/tab_search_page_handler.h"
7+
8+
#include <memory>
9+
#include <string>
10+
11+
#include "base/memory/raw_ptr.h"
12+
#include "base/strings/string_number_conversions.h"
13+
#include "base/strings/utf_string_conversions.h"
14+
#include "base/test/bind.h"
15+
#include "base/test/gmock_callback_support.h"
16+
#include "base/test/mock_callback.h"
17+
#include "brave/components/ai_chat/core/browser/engine/mock_engine_consumer.h"
18+
#include "brave/components/ai_chat/core/browser/types.h"
19+
#include "chrome/browser/profiles/profile.h"
20+
#include "chrome/browser/ui/browser.h"
21+
#include "chrome/browser/ui/browser_list.h"
22+
#include "chrome/browser/ui/browser_tabstrip.h"
23+
#include "chrome/browser/ui/test/test_browser_closed_waiter.h"
24+
#include "chrome/browser/ui/webui/tab_search/tab_search_ui.h"
25+
#include "chrome/common/webui_url_constants.h"
26+
#include "chrome/test/base/in_process_browser_test.h"
27+
#include "chrome/test/base/ui_test_utils.h"
28+
#include "components/grit/brave_components_strings.h"
29+
#include "content/public/browser/navigation_controller.h"
30+
#include "content/public/test/browser_test.h"
31+
#include "content/public/test/browser_test_utils.h"
32+
#include "content/public/test/test_utils.h"
33+
#include "testing/gmock/include/gmock/gmock.h"
34+
#include "testing/gtest/include/gtest/gtest.h"
35+
#include "ui/base/l10n/l10n_util.h"
36+
#include "url/gurl.h"
37+
#include "url/origin.h"
38+
39+
namespace {
40+
41+
constexpr char kFooDotComUrl1[] = "https://foo.com/1";
42+
constexpr char kFooDotComUrl2[] = "https://foo.com/2";
43+
constexpr char kBarDotComUrl1[] = "https://bar.com/1";
44+
constexpr char kBarDotComUrl2[] = "https://bar.com/2";
45+
46+
constexpr char kFooDotComTitle1[] = "foo.com 1";
47+
constexpr char kFooDotComTitle2[] = "foo.com 2";
48+
constexpr char kBarDotComTitle1[] = "bar.com 1";
49+
constexpr char kBarDotComTitle2[] = "bar.com 2";
50+
51+
} // namespace
52+
53+
using testing::_;
54+
55+
class TabSearchPageHandlerBrowserTest : public InProcessBrowserTest {
56+
public:
57+
void SetUpOnMainThread() override {
58+
InProcessBrowserTest::SetUpOnMainThread();
59+
webui_contents_ = content::WebContents::Create(
60+
content::WebContents::CreateParams(browser()->profile()));
61+
62+
webui_contents_->GetController().LoadURLWithParams(
63+
content::NavigationController::LoadURLParams(
64+
GURL(chrome::kChromeUITabSearchURL)));
65+
66+
// Finish loading after initializing.
67+
ASSERT_TRUE(content::WaitForLoadStop(webui_contents_.get()));
68+
69+
ui_test_utils::OpenNewEmptyWindowAndWaitUntilActivated(profile1());
70+
browser2_ = BrowserList::GetInstance()->GetLastActive();
71+
}
72+
73+
void TearDownOnMainThread() override {
74+
webui_contents_.reset();
75+
InProcessBrowserTest::TearDownOnMainThread();
76+
}
77+
78+
TabSearchPageHandler* handler() {
79+
return webui_contents_->GetWebUI()
80+
->GetController()
81+
->template GetAs<TabSearchUI>()
82+
->page_handler_for_testing();
83+
}
84+
85+
Profile* profile1() { return browser()->profile(); }
86+
87+
void AppendTabWithTitle(Browser* browser,
88+
const GURL& url,
89+
const std::string& title) {
90+
chrome::AddTabAt(browser, url, -1, true);
91+
content::WebContents* web_contents =
92+
browser->tab_strip_model()->GetActiveWebContents();
93+
web_contents->UpdateTitleForEntry(
94+
web_contents->GetController().GetLastCommittedEntry(),
95+
base::UTF8ToUTF16(title));
96+
}
97+
98+
// browser1 is a normal window with default profile.
99+
Browser* browser1() { return browser(); }
100+
101+
// browser2 is a normal window with default profile.
102+
Browser* browser2() { return browser2_; }
103+
104+
protected:
105+
std::unique_ptr<content::WebContents> webui_contents_;
106+
raw_ptr<Browser> browser2_;
107+
};
108+
109+
IN_PROC_BROWSER_TEST_F(TabSearchPageHandlerBrowserTest, GetFocusTabs) {
110+
// Test Engine's GetFocusTabs is called with expected tabs info and topic.
111+
handler()->SetAIChatEngineForTesting(
112+
std::make_unique<testing::NiceMock<ai_chat::MockEngineConsumer>>());
113+
114+
// Add tabs in windows with the default profile.
115+
AppendTabWithTitle(browser1(), GURL(kFooDotComUrl1), kFooDotComTitle1);
116+
AppendTabWithTitle(browser1(), GURL(kFooDotComUrl2), kFooDotComTitle2);
117+
AppendTabWithTitle(browser2(), GURL(kBarDotComUrl1), kBarDotComTitle1);
118+
AppendTabWithTitle(browser2(), GURL(kBarDotComUrl2), kBarDotComTitle2);
119+
120+
ASSERT_EQ(browser1()->tab_strip_model()->count(), 3);
121+
ASSERT_EQ(browser2()->tab_strip_model()->count(), 3);
122+
123+
const int tab_id1 =
124+
browser1()->tab_strip_model()->GetTabAtIndex(1)->GetHandle().raw_value();
125+
const int tab_id2 =
126+
browser1()->tab_strip_model()->GetTabAtIndex(2)->GetHandle().raw_value();
127+
const int tab_id3 =
128+
browser2()->tab_strip_model()->GetTabAtIndex(1)->GetHandle().raw_value();
129+
const int tab_id4 =
130+
browser2()->tab_strip_model()->GetTabAtIndex(2)->GetHandle().raw_value();
131+
132+
std::vector<ai_chat::Tab> expected_tabs = {
133+
{base::NumberToString(tab_id1), kFooDotComTitle1,
134+
url::Origin::Create(GURL(kFooDotComUrl1))},
135+
{base::NumberToString(tab_id2), kFooDotComTitle2,
136+
url::Origin::Create(GURL(kFooDotComUrl2))},
137+
{base::NumberToString(tab_id3), kBarDotComTitle1,
138+
url::Origin::Create(GURL(kBarDotComUrl1))},
139+
{base::NumberToString(tab_id4), kBarDotComTitle2,
140+
url::Origin::Create(GURL(kBarDotComUrl2))},
141+
};
142+
143+
std::vector<std::string> mock_ret_tabs = {base::NumberToString(tab_id1),
144+
"100", "invalid",
145+
base::NumberToString(tab_id4)};
146+
auto* mock_engine = static_cast<ai_chat::MockEngineConsumer*>(
147+
handler()->GetAIChatEngineForTesting());
148+
EXPECT_CALL(*mock_engine, GetFocusTabs(expected_tabs, "topic", _))
149+
.WillOnce(base::test::RunOnceCallback<2>(mock_ret_tabs));
150+
151+
handler()->GetFocusTabs("topic", base::BindLambdaForTesting(
152+
[&](bool new_window_created,
153+
tab_search::mojom::ErrorPtr error) {
154+
EXPECT_TRUE(new_window_created);
155+
EXPECT_FALSE(error);
156+
}));
157+
158+
BrowserList* browser_list = BrowserList::GetInstance();
159+
Browser* active_browser = browser_list->GetLastActive();
160+
ASSERT_EQ(browser_list->size(), 3u) << "A new window should be created.";
161+
ASSERT_EQ(active_browser, browser_list->get(2))
162+
<< "The new window should be active.";
163+
EXPECT_EQ(active_browser->tab_strip_model()->count(), 2)
164+
<< "The new window should have 2 tabs.";
165+
// Check the tabs are moved to the new window as expected.
166+
EXPECT_EQ(active_browser->tab_strip_model()
167+
->GetTabAtIndex(0)
168+
->GetHandle()
169+
.raw_value(),
170+
tab_id1);
171+
EXPECT_EQ(active_browser->tab_strip_model()
172+
->GetTabAtIndex(1)
173+
->GetHandle()
174+
.raw_value(),
175+
tab_id4);
176+
177+
// Test undo.
178+
handler()->UndoFocusTabs(base::BindLambdaForTesting([&]() {
179+
// Wait for the new window to be closed.
180+
ASSERT_EQ(browser_list->size(), 3u);
181+
ASSERT_TRUE(
182+
TestBrowserClosedWaiter(browser_list->get(2)).WaitUntilClosed());
183+
184+
Browser* browser1 = browser_list->get(0);
185+
EXPECT_EQ(browser1->tab_strip_model()->count(), 3)
186+
<< "The tabs should be moved back to the previous active window.";
187+
EXPECT_EQ(
188+
browser1->tab_strip_model()->GetTabAtIndex(1)->GetHandle().raw_value(),
189+
tab_id1);
190+
Browser* browser2 = browser_list->get(1);
191+
EXPECT_EQ(browser2->tab_strip_model()->count(), 3)
192+
<< "The tabs should be moved back to the previous active window.";
193+
EXPECT_EQ(
194+
browser2->tab_strip_model()->GetTabAtIndex(2)->GetHandle().raw_value(),
195+
tab_id4);
196+
}));
197+
}

0 commit comments

Comments
 (0)