diff --git a/browser/ui/webui/ai_chat/ai_chat_ui.cc b/browser/ui/webui/ai_chat/ai_chat_ui.cc index a6e48ebe79ac..9c5b7185f229 100644 --- a/browser/ui/webui/ai_chat/ai_chat_ui.cc +++ b/browser/ui/webui/ai_chat/ai_chat_ui.cc @@ -21,6 +21,7 @@ #include "brave/components/constants/webui_url_constants.h" #include "brave/components/l10n/common/localization_util.h" #include "chrome/browser/profiles/profile.h" +#include "chrome/browser/ui/tabs/tab_model.h" #include "chrome/browser/ui/webui/webui_util.h" #include "components/grit/brave_components_resources.h" #include "components/prefs/pref_service.h" @@ -79,13 +80,6 @@ AIChatUI::AIChatUI(content::WebUI* web_ui) str.name, brave_l10n::GetLocalizedResourceUTF16String(str.id)); } - base::Time last_accepted_disclaimer = - profile_->GetOriginalProfile()->GetPrefs()->GetTime( - ai_chat::prefs::kLastAcceptedDisclaimer); - - untrusted_source->AddBoolean("hasAcceptedAgreement", - !last_accepted_disclaimer.is_null()); - #if BUILDFLAG(IS_ANDROID) || BUILDFLAG(IS_IOS) constexpr bool kIsMobile = true; #else @@ -96,11 +90,6 @@ AIChatUI::AIChatUI(content::WebUI* web_ui) untrusted_source->AddBoolean("isHistoryEnabled", ai_chat::features::IsAIChatHistoryEnabled()); - untrusted_source->AddBoolean( - "hasUserDismissedPremiumPrompt", - profile_->GetOriginalProfile()->GetPrefs()->GetBoolean( - ai_chat::prefs::kUserDismissedPremiumPrompt)); - untrusted_source->OverrideContentSecurityPolicy( network::mojom::CSPDirectiveName::ScriptSrc, "script-src 'self' chrome-untrusted://resources;"); @@ -128,21 +117,26 @@ void AIChatUI::BindInterface( if (embedder_) { embedder_->ShowUI(); } - + // Get the WebContents which SidePanel mode should be associated with content::WebContents* web_contents = nullptr; #if !BUILDFLAG(IS_ANDROID) Browser* browser = ai_chat::GetBrowserForWebContents(web_ui()->GetWebContents()); - if (!browser) { - return; + if (browser) { + TabStripModel* tab_strip_model = browser->tab_strip_model(); + if (tab_strip_model) { + // If this WebUI is a main tab, we never want to be associated with + // the active tab + if (tab_strip_model->GetIndexOfWebContents(web_ui()->GetWebContents()) == + TabStripModel::kNoTab) { + web_contents = tab_strip_model->GetActiveWebContents(); + } + } } - - TabStripModel* tab_strip_model = browser->tab_strip_model(); - DCHECK(tab_strip_model); - web_contents = tab_strip_model->GetActiveWebContents(); #else web_contents = GetActiveWebContents(profile_); #endif + // Don't associate with the WebUI's WebContents if (web_contents == web_ui()->GetWebContents()) { web_contents = nullptr; } diff --git a/browser/ui/webui/ai_chat/ai_chat_ui_page_handler.cc b/browser/ui/webui/ai_chat/ai_chat_ui_page_handler.cc index 0d059503168e..8bafa8354685 100644 --- a/browser/ui/webui/ai_chat/ai_chat_ui_page_handler.cc +++ b/browser/ui/webui/ai_chat/ai_chat_ui_page_handler.cc @@ -205,10 +205,10 @@ void AIChatUIPageHandler::CloseUI() { #endif } -void AIChatUIPageHandler::SetChatUI( - mojo::PendingRemote chat_ui) { +void AIChatUIPageHandler::SetChatUI(mojo::PendingRemote chat_ui, + SetChatUICallback callback) { chat_ui_.Bind(std::move(chat_ui)); - chat_ui_->SetInitialData(active_chat_tab_helper_ == nullptr); + std::move(callback).Run(active_chat_tab_helper_ == nullptr); } void AIChatUIPageHandler::BindRelatedConversation( diff --git a/browser/ui/webui/ai_chat/ai_chat_ui_page_handler.h b/browser/ui/webui/ai_chat/ai_chat_ui_page_handler.h index 7fc3c89cbe93..4dc28747bba5 100644 --- a/browser/ui/webui/ai_chat/ai_chat_ui_page_handler.h +++ b/browser/ui/webui/ai_chat/ai_chat_ui_page_handler.h @@ -54,7 +54,8 @@ class AIChatUIPageHandler : public mojom::AIChatUIHandler, void ManagePremium() override; void HandleVoiceRecognition(const std::string& conversation_uuid) override; void CloseUI() override; - void SetChatUI(mojo::PendingRemote chat_ui) override; + void SetChatUI(mojo::PendingRemote chat_ui, + SetChatUICallback callback) override; void BindRelatedConversation( mojo::PendingReceiver receiver, mojo::PendingRemote conversation_ui_handler) diff --git a/components/ai_chat/core/browser/ai_chat_service.cc b/components/ai_chat/core/browser/ai_chat_service.cc index 68478bf541be..b0d0884d9006 100644 --- a/components/ai_chat/core/browser/ai_chat_service.cc +++ b/components/ai_chat/core/browser/ai_chat_service.cc @@ -117,6 +117,10 @@ AIChatService::AIChatService( prefs::kStorageEnabled, base::BindRepeating(&AIChatService::MaybeInitStorage, weak_ptr_factory_.GetWeakPtr())); + pref_change_registrar_.Add( + prefs::kUserDismissedPremiumPrompt, + base::BindRepeating(&AIChatService::OnStateChanged, + weak_ptr_factory_.GetWeakPtr())); MaybeInitStorage(); } @@ -362,6 +366,7 @@ void AIChatService::MaybeInitStorage() { weak_ptr_factory_.GetWeakPtr())); } } + OnStateChanged(); } void AIChatService::OnOsCryptAsyncReady(os_crypt_async::Encryptor encryptor, @@ -548,6 +553,18 @@ void AIChatService::MarkAgreementAccepted() { SetUserOptedIn(profile_prefs_, true); } +void AIChatService::EnableStoragePref() { + profile_prefs_->SetBoolean(prefs::kStorageEnabled, true); +} + +void AIChatService::DismissStorageNotice() { + profile_prefs_->SetBoolean(prefs::kUserDismissedStorageNotice, true); +} + +void AIChatService::DismissPremiumPrompt() { + profile_prefs_->SetBoolean(prefs::kUserDismissedPremiumPrompt, true); +} + void AIChatService::GetActionMenuList(GetActionMenuListCallback callback) { std::move(callback).Run(ai_chat::GetActionMenuList()); } @@ -558,41 +575,6 @@ void AIChatService::GetPremiumStatus(GetPremiumStatusCallback callback) { weak_ptr_factory_.GetWeakPtr(), std::move(callback))); } -void AIChatService::GetCanShowPremiumPrompt( - GetCanShowPremiumPromptCallback callback) { - bool has_user_dismissed_prompt = - profile_prefs_->GetBoolean(prefs::kUserDismissedPremiumPrompt); - - if (has_user_dismissed_prompt) { - std::move(callback).Run(false); - return; - } - - base::Time last_accepted_disclaimer = - profile_prefs_->GetTime(prefs::kLastAcceptedDisclaimer); - - // Can't show if we haven't accepted disclaimer yet - if (last_accepted_disclaimer.is_null()) { - std::move(callback).Run(false); - return; - } - - base::Time time_1_day_ago = base::Time::Now() - base::Days(1); - bool is_more_than_24h_since_last_seen = - last_accepted_disclaimer < time_1_day_ago; - - if (is_more_than_24h_since_last_seen) { - std::move(callback).Run(true); - return; - } - - std::move(callback).Run(false); -} - -void AIChatService::DismissPremiumPrompt() { - profile_prefs_->SetBoolean(prefs::kUserDismissedPremiumPrompt, true); -} - void AIChatService::DeleteConversation(const std::string& id) { auto handler_it = conversation_handlers_.find(id); if (handler_it != conversation_handlers_.end()) { @@ -681,6 +663,38 @@ void AIChatService::MaybeUnloadConversation( } } +mojom::ServiceStatePtr AIChatService::BuildState() { + bool has_user_dismissed_storage_notice = + profile_prefs_->GetBoolean(prefs::kUserDismissedStorageNotice); + base::Time last_accepted_disclaimer = + profile_prefs_->GetTime(ai_chat::prefs::kLastAcceptedDisclaimer); + + bool is_user_opted_in = !last_accepted_disclaimer.is_null(); + + // Premium prompt is only shown conditionally (e.g. the user hasn't dismissed + // it and it's been some time since the user started using the feature). + bool can_show_premium_prompt = + !profile_prefs_->GetBoolean(prefs::kUserDismissedPremiumPrompt) && + !last_accepted_disclaimer.is_null() && + last_accepted_disclaimer < base::Time::Now() - base::Days(1); + + bool is_storage_enabled = profile_prefs_->GetBoolean(prefs::kStorageEnabled); + + mojom::ServiceStatePtr state = mojom::ServiceState::New(); + state->has_accepted_agreement = is_user_opted_in; + state->is_storage_pref_enabled = is_storage_enabled; + state->is_storage_notice_dismissed = has_user_dismissed_storage_notice; + state->can_show_premium_prompt = can_show_premium_prompt; + return state; +} + +void AIChatService::OnStateChanged() { + mojom::ServiceStatePtr state = BuildState(); + for (auto& remote : observer_remotes_) { + remote->OnStateChanged(state.Clone()); + } +} + bool AIChatService::IsAIChatHistoryEnabled() { return (features::IsAIChatHistoryEnabled() && profile_prefs_->GetBoolean(prefs::kStorageEnabled)); @@ -843,8 +857,10 @@ void AIChatService::BindConversation( } void AIChatService::BindObserver( - mojo::PendingRemote observer) { + mojo::PendingRemote observer, + BindObserverCallback callback) { observer_remotes_.Add(std::move(observer)); + std::move(callback).Run(BuildState()); } bool AIChatService::HasUserOptedIn() { @@ -866,6 +882,7 @@ size_t AIChatService::GetInMemoryConversationCountForTesting() { } void AIChatService::OnUserOptedIn() { + OnStateChanged(); bool is_opted_in = HasUserOptedIn(); if (!is_opted_in) { return; @@ -873,9 +890,6 @@ void AIChatService::OnUserOptedIn() { for (auto& kv : conversation_handlers_) { kv.second->OnUserOptedIn(); } - for (auto& remote : observer_remotes_) { - remote->OnAgreementAccepted(); - } if (ai_chat_metrics_ != nullptr) { ai_chat_metrics_->RecordEnabled(true, true, {}); } diff --git a/components/ai_chat/core/browser/ai_chat_service.h b/components/ai_chat/core/browser/ai_chat_service.h index 4856fccaff0e..b868c17e4e5d 100644 --- a/components/ai_chat/core/browser/ai_chat_service.h +++ b/components/ai_chat/core/browser/ai_chat_service.h @@ -136,13 +136,13 @@ class AIChatService : public KeyedService, // mojom::Service void MarkAgreementAccepted() override; + void EnableStoragePref() override; + void DismissStorageNotice() override; + void DismissPremiumPrompt() override; void GetVisibleConversations( GetVisibleConversationsCallback callback) override; void GetActionMenuList(GetActionMenuListCallback callback) override; void GetPremiumStatus(GetPremiumStatusCallback callback) override; - void GetCanShowPremiumPrompt( - GetCanShowPremiumPromptCallback callback) override; - void DismissPremiumPrompt() override; void DeleteConversation(const std::string& id) override; void RenameConversation(const std::string& id, const std::string& new_name) override; @@ -152,7 +152,8 @@ class AIChatService : public KeyedService, mojo::PendingReceiver receiver, mojo::PendingRemote conversation_ui_handler) override; - void BindObserver(mojo::PendingRemote ui) override; + void BindObserver(mojo::PendingRemote ui, + BindObserverCallback callback) override; bool HasUserOptedIn(); bool IsPremiumStatus(); @@ -208,6 +209,8 @@ class AIChatService : public KeyedService, mojom::PremiumStatus status, mojom::PremiumInfoPtr info); void OnDataDeletedForDisabledStorage(bool success); + mojom::ServiceStatePtr BuildState(); + void OnStateChanged(); bool IsAIChatHistoryEnabled(); diff --git a/components/ai_chat/core/browser/ai_chat_service_unittest.cc b/components/ai_chat/core/browser/ai_chat_service_unittest.cc index ba21807bb164..b1e3cc300ea4 100644 --- a/components/ai_chat/core/browser/ai_chat_service_unittest.cc +++ b/components/ai_chat/core/browser/ai_chat_service_unittest.cc @@ -17,6 +17,7 @@ #include "base/files/scoped_temp_dir.h" #include "base/functional/bind.h" #include "base/functional/callback.h" +#include "base/functional/callback_helpers.h" #include "base/functional/overloaded.h" #include "base/memory/scoped_refptr.h" #include "base/run_loop.h" @@ -72,8 +73,8 @@ class MockAIChatCredentialManager : public AIChatCredentialManager { class MockServiceClient : public mojom::ServiceObserver { public: explicit MockServiceClient(AIChatService* service) { - service->BindObserver( - service_observer_receiver_.BindNewPipeAndPassRemote()); + service->BindObserver(service_observer_receiver_.BindNewPipeAndPassRemote(), + base::DoNothing()); service->Bind(service_remote_.BindNewPipeAndPassReceiver()); } @@ -91,7 +92,7 @@ class MockServiceClient : public mojom::ServiceObserver { (std::vector), (override)); - MOCK_METHOD(void, OnAgreementAccepted, (), (override)); + MOCK_METHOD(void, OnStateChanged, (mojom::ServiceStatePtr), (override)); private: mojo::Receiver service_observer_receiver_{this}; diff --git a/components/ai_chat/core/browser/constants.cc b/components/ai_chat/core/browser/constants.cc index 161efe86ebbf..4ef399c47d34 100644 --- a/components/ai_chat/core/browser/constants.cc +++ b/components/ai_chat/core/browser/constants.cc @@ -128,6 +128,17 @@ base::span GetLocalizedStrings() { {"searchInProgress", IDS_CHAT_UI_SEARCH_IN_PROGRESS}, {"searchQueries", IDS_CHAT_UI_SEARCH_QUERIES}, {"learnMore", IDS_CHAT_UI_LEARN_MORE}, + {"closeNotice", IDS_CHAT_UI_CLOSE_NOTICE}, + {"noticeConversationHistoryBody", + IDS_CHAT_UI_NOTICE_CONVERSATION_HISTORY_BODY}, + {"noticeConversationHistoryEmpty", + IDS_CHAT_UI_NOTICE_CONVERSATION_HISTORY_EMPTY}, + {"noticeConversationHistoryTitleDisabledPref", + IDS_CHAT_UI_NOTICE_CONVERSATION_HISTORY_TITLE_DISABLED_PREF}, + {"noticeConversationHistoryDisabledPref", + IDS_CHAT_UI_NOTICE_CONVERSATION_HISTORY_DISABLED_PREF}, + {"noticeConversationHistoryDisabledPrefButton", + IDS_CHAT_UI_NOTICE_CONVERSATION_HISTORY_DISABLED_PREF_BUTTON}, {"leoSettingsTooltipLabel", IDS_CHAT_UI_LEO_SETTINGS_TOOLTIP_LABEL}, {"summarizePageButtonLabel", IDS_CHAT_UI_SUMMARIZE_PAGE}, {"welcomeGuideTitle", IDS_CHAT_UI_WELCOME_GUIDE_TITLE}, @@ -171,6 +182,8 @@ base::span GetLocalizedStrings() { {"useMicButtonLabel", IDS_AI_CHAT_USE_MICROPHONE_BUTTON_LABEL}, {"menuTitleCustomModels", IDS_AI_CHAT_MENU_TITLE_CUSTOM_MODELS}, {"startConversationLabel", IDS_AI_CHAT_START_CONVERSATION_LABEL}, + {"goBackToActiveConversationButton", + IDS_AI_CHAT_GO_BACK_TO_ACTIVE_CONVERSATION_BUTTON}, {"conversationListUntitled", IDS_AI_CHAT_CONVERSATION_LIST_UNTITLED}}); return kLocalizedStrings; diff --git a/components/ai_chat/core/common/mojom/ai_chat.mojom b/components/ai_chat/core/common/mojom/ai_chat.mojom index 3f81a9c326c3..6e0b7f72336f 100644 --- a/components/ai_chat/core/common/mojom/ai_chat.mojom +++ b/components/ai_chat/core/common/mojom/ai_chat.mojom @@ -299,9 +299,21 @@ struct ActionGroup { array entries; }; +// This does not cover more specific data that the Service owns, such as the +// conversation list, but does cover status of preferences and notices. +struct ServiceState { + bool has_accepted_agreement; + bool is_storage_pref_enabled; + bool is_storage_notice_dismissed; + bool can_show_premium_prompt; +}; + interface Service { - // User opts-in to the feature at a profile level + // Profile-level acknowledgements MarkAgreementAccepted(); + EnableStoragePref(); + DismissStorageNotice(); + DismissPremiumPrompt(); // Get metadata for non-archived conversations GetVisibleConversations() => (array conversations); @@ -312,16 +324,11 @@ interface Service { // Current status of subscription GetPremiumStatus() => (PremiumStatus status, PremiumInfo? info); - // Premium prompt is only shown conditionally (e.g. the user hasn't dismissed - // it and it's been some time since the user started using the feature). - GetCanShowPremiumPrompt() => (bool can_show); - DismissPremiumPrompt(); - DeleteConversation(string id); RenameConversation(string id, string new_name); - // Send events to the UI - BindObserver(pending_remote ui); + // Bind ability to send events to the UI and receive current state + BindObserver(pending_remote ui) => (ServiceState state); // Bind specified Conversation for 2-way communication BindConversation( @@ -332,7 +339,7 @@ interface Service { interface ServiceObserver { OnConversationListChanged(array conversations); - OnAgreementAccepted(); + OnStateChanged(ServiceState state); }; // Browser-side handler for general AI Chat UI functions, implemented @@ -358,7 +365,9 @@ interface AIChatUIHandler { // This might be a no-op if the UI isn't closeable CloseUI(); - SetChatUI(pending_remote chat_ui); + // Provide a reference of the UI to the UI handler and get some + // initial constant state + SetChatUI(pending_remote chat_ui) => (bool is_standalone); // Bind 2-way communication to the conversation related to the open page in // the current browser window. No binding will occur if this isn't a @@ -380,8 +389,6 @@ interface AIChatUIHandler { // UI-side handler for whole AI Chat UI interface ChatUI { - // Initial Data - SetInitialData(bool is_standalone); // Notifies that the default conversation for the // panel has changed. e.g. Tab navigation with AIChat open in sidebar. OnNewDefaultConversation(); diff --git a/components/ai_chat/core/common/pref_names.cc b/components/ai_chat/core/common/pref_names.cc index 87f3e3838ee7..0bb49c3eb447 100644 --- a/components/ai_chat/core/common/pref_names.cc +++ b/components/ai_chat/core/common/pref_names.cc @@ -19,6 +19,7 @@ void RegisterProfilePrefs(PrefRegistrySimple* registry) { registry->RegisterBooleanPref(kStorageEnabled, true); registry->RegisterBooleanPref(kBraveChatAutocompleteProviderEnabled, true); registry->RegisterBooleanPref(kUserDismissedPremiumPrompt, false); + registry->RegisterBooleanPref(kUserDismissedStorageNotice, false); #if BUILDFLAG(IS_ANDROID) registry->RegisterBooleanPref(kBraveChatSubscriptionActiveAndroid, false); registry->RegisterStringPref(kBraveChatPurchaseTokenAndroid, ""); diff --git a/components/ai_chat/core/common/pref_names.h b/components/ai_chat/core/common/pref_names.h index 1499a56d775d..1222461b4bd4 100644 --- a/components/ai_chat/core/common/pref_names.h +++ b/components/ai_chat/core/common/pref_names.h @@ -28,6 +28,8 @@ inline constexpr char kBraveChatPremiumCredentialCache[] = "brave.ai_chat.premium_credential_cache"; inline constexpr char kUserDismissedPremiumPrompt[] = "brave.ai_chat.user_dismissed_premium_prompt"; +inline constexpr char kUserDismissedStorageNotice[] = + "brave.ai_chat.user_dismissed_storage_notice"; inline constexpr char kBraveChatP3AOmniboxOpenWeeklyStorage[] = "brave.ai_chat.p3a_omnibox_open"; inline constexpr char kBraveChatP3AOmniboxAutocompleteWeeklyStorage[] = diff --git a/components/ai_chat/resources/page/ai_chat_ui.html b/components/ai_chat/resources/page/ai_chat_ui.html index 99c9bf799961..4da2718b97b5 100644 --- a/components/ai_chat/resources/page/ai_chat_ui.html +++ b/components/ai_chat/resources/page/ai_chat_ui.html @@ -16,7 +16,7 @@ -
+ diff --git a/components/ai_chat/resources/page/api/index.ts b/components/ai_chat/resources/page/api/index.ts index 345029bb1008..572dc6656b73 100644 --- a/components/ai_chat/resources/page/api/index.ts +++ b/components/ai_chat/resources/page/api/index.ts @@ -4,38 +4,150 @@ * You can obtain one at https://mozilla.org/MPL/2.0/. */ import * as mojom from 'gen/brave/components/ai_chat/core/common/mojom/ai_chat.mojom.m.js' - -// Provide access to all the generated types export * from 'gen/brave/components/ai_chat/core/common/mojom/ai_chat.mojom.m.js' +import { debounce } from '$web-common/debounce' +import { loadTimeData } from '$web-common/loadTimeData' + +// State that is owned by this class because it is global to the UI +// (loadTimeData / Service / UIHandler). +export type State = mojom.ServiceState & { + initialized: boolean + isStandalone?: boolean + visibleConversations: mojom.Conversation[] + isPremiumStatusFetching: boolean + isPremiumUser: boolean + isPremiumUserDisconnected: boolean + isMobile: boolean + isHistoryFeatureEnabled: boolean + allActions: mojom.ActionGroup[] +} + +export const defaultUIState: State = { + initialized: false, + visibleConversations: [], + hasAcceptedAgreement: false, + isStoragePrefEnabled: false, + isPremiumStatusFetching: true, + isPremiumUser: false, + isPremiumUserDisconnected: false, + isStorageNoticeDismissed: false, + canShowPremiumPrompt: false, + isMobile: loadTimeData.getBoolean('isMobile'), + isHistoryFeatureEnabled: loadTimeData.getBoolean('isHistoryEnabled'), + allActions: [], +} + +export type UIStateChangeEvent = CustomEvent class API { - public Service: mojom.ServiceRemote - public Observer: mojom.ServiceObserverCallbackRouter - public UIHandler: mojom.AIChatUIHandlerRemote - public UIObserver: mojom.ChatUICallbackRouter - public isStandalone: boolean + public service: mojom.ServiceRemote + public observer: mojom.ServiceObserverCallbackRouter + public uiHandler: mojom.AIChatUIHandlerRemote + public uiObserver: mojom.ChatUICallbackRouter + public state: State = { ...defaultUIState } + + private eventTarget = new EventTarget() constructor() { // Connect to service - this.Service = mojom.Service.getRemote() - this.Observer = new mojom.ServiceObserverCallbackRouter() - this.Service.bindObserver(this.Observer.$.bindNewPipeAndPassRemote()) + this.service = mojom.Service.getRemote() + this.observer = new mojom.ServiceObserverCallbackRouter() // Connect to platform UI handler - this.UIHandler = mojom.AIChatUIHandler.getRemote() - this.UIObserver = new mojom.ChatUICallbackRouter() - this.UIObserver.setInitialData.addListener((isStandalone: boolean) => { - this.isStandalone = isStandalone + this.uiHandler = mojom.AIChatUIHandler.getRemote() + this.uiObserver = new mojom.ChatUICallbackRouter() + this.initialize() + this.updateCurrentPremiumStatus() + } + + async initialize() { + // Get any global UI state. We can do that here instead of React context + // to start as early as possible. + // Premium state separately because it takes longer to fetch and we don't + // need to wait for it. + const [ + { state }, + { isStandalone }, + { conversations: visibleConversations }, + { actionList: allActions }, + premiumStatus + ] = await Promise.all([ + this.service.bindObserver(this.observer.$.bindNewPipeAndPassRemote()), + this.uiHandler.setChatUI(this.uiObserver.$.bindNewPipeAndPassRemote()), + this.service.getVisibleConversations(), + this.service.getActionMenuList(), + this.getCurrentPremiumStatus() + ]) + this.setPartialState({ + ...state, + ...premiumStatus, + initialized: true, + isStandalone, + visibleConversations, + allActions + }) + + this.observer.onStateChanged.addListener((state: mojom.ServiceState) => { + this.setPartialState(state) + }) + + this.observer.onConversationListChanged.addListener( + (conversations: mojom.Conversation[]) => { + this.setPartialState({ + visibleConversations: conversations + }) + } + ) + + // Since there is no browser-side event for premium status changing, + // we should check often. And since purchase or login is performed in + // a separate WebContents, we can check when focus is returned here. + window.addEventListener('focus', () => { + this.updateCurrentPremiumStatus() + }) + + document.addEventListener('visibilitychange', (e) => { + if (document.visibilityState === 'visible') { + this.updateCurrentPremiumStatus() + } }) - this.UIHandler.setChatUI(this.UIObserver.$.bindNewPipeAndPassRemote()) } -} -let apiInstance: API + addStateChangeListener(callback: (event: UIStateChangeEvent) => void) { + this.eventTarget.addEventListener('uistatechange', callback) + } + + removeStateChangeListener(callback: (event: UIStateChangeEvent) => void) { + this.eventTarget.removeEventListener('uistatechange', callback) + } + + private dispatchDebouncedStateChange = debounce(() => { + console.debug('dispatching uistatechange event', {...this.state}) + this.eventTarget.dispatchEvent( + new Event('uistatechange') + ) + }, 0) + + public setPartialState(partialState: Partial) { + this.state = { ...this.state, ...partialState } + this.dispatchDebouncedStateChange() + } + + private async getCurrentPremiumStatus() { + const { status } = await this.service.getPremiumStatus() + return { + isPremiumStatusFetching: false, + isPremiumUser: (status !== undefined && status !== mojom.PremiumStatus.Inactive), + isPremiumUserDisconnected: status === mojom.PremiumStatus.ActiveDisconnected + } + } -export function setAPIForTesting(instance: API) { - apiInstance = instance + private async updateCurrentPremiumStatus() { + this.setPartialState(await this.getCurrentPremiumStatus()) + } } +let apiInstance: API + export default function getAPI() { if (!apiInstance) { apiInstance = new API() @@ -49,13 +161,13 @@ export function bindConversation(id: string | undefined) { let callbackRouter = new mojom.ConversationUICallbackRouter() if (id !== undefined) { - getAPI().Service.bindConversation( + getAPI().service.bindConversation( id, conversationHandler.$.bindNewPipeAndPassReceiver(), callbackRouter.$.bindNewPipeAndPassRemote() ) } else { - getAPI().UIHandler.bindRelatedConversation( + getAPI().uiHandler.bindRelatedConversation( conversationHandler.$.bindNewPipeAndPassReceiver(), callbackRouter.$.bindNewPipeAndPassRemote() ) @@ -68,11 +180,12 @@ export function bindConversation(id: string | undefined) { export function newConversation() { let conversationHandler: mojom.ConversationHandlerRemote = - new mojom.ConversationHandlerRemote() + new mojom.ConversationHandlerRemote() let callbackRouter = new mojom.ConversationUICallbackRouter() - getAPI().UIHandler.newConversation( - conversationHandler.$.bindNewPipeAndPassReceiver(), - callbackRouter.$.bindNewPipeAndPassRemote()) + getAPI().uiHandler.newConversation( + conversationHandler.$.bindNewPipeAndPassReceiver(), + callbackRouter.$.bindNewPipeAndPassRemote() + ) return { conversationHandler, callbackRouter diff --git a/components/ai_chat/resources/page/chat_ui.tsx b/components/ai_chat/resources/page/chat_ui.tsx index 2ec5630d3cc6..0bc398a3d0cf 100644 --- a/components/ai_chat/resources/page/chat_ui.tsx +++ b/components/ai_chat/resources/page/chat_ui.tsx @@ -5,25 +5,26 @@ import * as React from 'react' import { createRoot } from 'react-dom/client' -import { initLocale } from 'brave-ui' import { setIconBasePath } from '@brave/leo/react/icon' - -import '$web-components/app.global.scss' import '@brave/leo/tokens/css/variables.css' - +import '$web-components/app.global.scss' import '$web-common/defaultTrustedTypesPolicy' -import { loadTimeData } from '$web-common/loadTimeData' import BraveCoreThemeProvider from '$web-common/BraveCoreThemeProvider' +import getAPI from './api' import { AIChatContextProvider, useAIChat } from './state/ai_chat_context' -import Main from './components/main' import { ConversationContextProvider } from './state/conversation_context' +import Main from './components/main' import FullScreen from './components/full_page' +import Loading from './components/loading' import { ActiveChatProviderFromUrl } from './state/active_chat_context' setIconBasePath('chrome-untrusted://resources/brave-icons') +// Make sure we're fetching data as early as possible +getAPI() + function App() { React.useEffect(() => { document.getElementById('mountPoint')?.classList.add('loaded') @@ -45,8 +46,8 @@ function App() { function Content() { const aiChatContext = useAIChat() - if (aiChatContext.isStandalone === undefined) { - return
loading...
+ if (!aiChatContext.initialized || aiChatContext.isStandalone === undefined) { + return } if (!aiChatContext.isStandalone) { @@ -57,7 +58,6 @@ function Content() { } function initialize() { - initLocale(loadTimeData.data_) const root = createRoot(document.getElementById('mountPoint')!) root.render() } diff --git a/components/ai_chat/resources/page/components/code_block/index.tsx b/components/ai_chat/resources/page/components/code_block/index.tsx index c62daeacfb82..50b89f054ed9 100644 --- a/components/ai_chat/resources/page/components/code_block/index.tsx +++ b/components/ai_chat/resources/page/components/code_block/index.tsx @@ -3,18 +3,17 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at https://mozilla.org/MPL/2.0/. */ -import * as React from 'react' - -import styles from './style.module.scss' -import { getLocale } from '@brave/brave-ui' import Button from '@brave/leo/react/button' import Icon from '@brave/leo/react/icon' +import * as React from 'react' import { Light as SyntaxHighlighter } from 'react-syntax-highlighter' import hljsStyle from 'react-syntax-highlighter/dist/esm/styles/hljs/ir-black' import cpp from 'react-syntax-highlighter/dist/esm/languages/hljs/cpp' import javascript from 'react-syntax-highlighter/dist/esm/languages/hljs/javascript' import python from 'react-syntax-highlighter/dist/esm/languages/hljs/python' import json from 'react-syntax-highlighter/dist/esm/languages/hljs/json' +import { getLocale } from '$web-common/locale' +import styles from './style.module.scss' SyntaxHighlighter.registerLanguage('cpp', cpp) SyntaxHighlighter.registerLanguage('javascript', javascript) diff --git a/components/ai_chat/resources/page/components/conversations_list/index.tsx b/components/ai_chat/resources/page/components/conversations_list/index.tsx index d6dea6e0a247..29bd4d900737 100644 --- a/components/ai_chat/resources/page/components/conversations_list/index.tsx +++ b/components/ai_chat/resources/page/components/conversations_list/index.tsx @@ -12,6 +12,8 @@ import { useAIChat } from '../../state/ai_chat_context' import { getLocale } from '$web-common/locale' import getAPI from '../../api' import { useConversation } from '../../state/conversation_context' +import Alert from '@brave/leo/react/alert' +import Button from '@brave/leo/react/button' interface SimpleInputProps { text?: string @@ -116,6 +118,26 @@ export default function ConversationsList(props: ConversationsListProps) { <>
@@ -146,7 +168,7 @@ export default function ConversationsList(props: ConversationsListProps) { title={item.title || getLocale('conversationListUntitled')} description='' onEditTitle={() => aiChatContext.setEditingConversationId(item.uuid)} - onDelete={() => getAPI().Service.deleteConversation(item.uuid)} + onDelete={() => getAPI().service.deleteConversation(item.uuid)} /> )} @@ -154,6 +176,7 @@ export default function ConversationsList(props: ConversationsListProps) { ) })} + } diff --git a/components/ai_chat/resources/page/components/conversations_list/style.module.scss b/components/ai_chat/resources/page/components/conversations_list/style.module.scss index 28c31d18f54b..971ee94d0a2e 100644 --- a/components/ai_chat/resources/page/components/conversations_list/style.module.scss +++ b/components/ai_chat/resources/page/components/conversations_list/style.module.scss @@ -11,6 +11,11 @@ max-width: 680px; margin: 0 auto; + // In case we have multiple notices or notices + conversations + display: flex; + flex-direction: column; + gap: var(--leo-spacing-m); + ol { list-style: none; padding: 0; diff --git a/components/ai_chat/resources/page/components/feature_button_menu/index.tsx b/components/ai_chat/resources/page/components/feature_button_menu/index.tsx index c8fc9cda745b..3030b0bbdbd2 100644 --- a/components/ai_chat/resources/page/components/feature_button_menu/index.tsx +++ b/components/ai_chat/resources/page/components/feature_button_menu/index.tsx @@ -125,7 +125,7 @@ export default function FeatureMenu(props: Props) { - getAPI().Service.deleteConversation(conversationContext.conversationUuid!)}> + getAPI().service.deleteConversation(conversationContext.conversationUuid!)}>
)} - {!aiChatContext.isStandalone && aiChatContext.isHistoryEnabled && ( + {!aiChatContext.isStandalone && aiChatContext.isHistoryFeatureEnabled && ( <> props.setIsConversationsListOpen?.(true)} diff --git a/components/ai_chat/resources/page/components/full_page/index.tsx b/components/ai_chat/resources/page/components/full_page/index.tsx index 58d6be95d566..3345615a7a77 100644 --- a/components/ai_chat/resources/page/components/full_page/index.tsx +++ b/components/ai_chat/resources/page/components/full_page/index.tsx @@ -8,6 +8,7 @@ import Button from '@brave/leo/react/button' import Icon from '@brave/leo/react/icon' import useMediaQuery from '$web-common/useMediaQuery' import { useAIChat } from '../../state/ai_chat_context' +import { useConversation } from '../../state/conversation_context' import ConversationsList from '../conversations_list' import { NavigationHeader } from '../header' import Main from '../main' @@ -17,6 +18,7 @@ import { useActiveChat } from '../../state/active_chat_context' export default function FullScreen() { const aiChatContext = useAIChat() const { createNewConversation } = useActiveChat() + const conversationContext = useConversation() const asideAnimationRef = React.useRef() const controllerRef = React.useRef(new AbortController()) @@ -24,9 +26,12 @@ export default function FullScreen() { const [isNavigationCollapsed, setIsNavigationCollapsed] = React.useState(isSmall) const [isNavigationRendered, setIsNavigationRendered] = React.useState(!isSmall) + const canStartNewConversation = aiChatContext.hasAcceptedAgreement && + !!conversationContext.conversationHistory.length + const initAsideAnimation = React.useCallback((node: HTMLElement | null) => { if (!node) return - const open = { width: '340px', opacity: 1 } + const open = { width: 'var(--navigation-width)', opacity: 1 } const close = { width: '0px', opacity: 0 } const animationOptions: KeyframeAnimationOptions = { duration: 200, @@ -100,7 +105,7 @@ export default function FullScreen() { > - {!isNavigationRendered && ( + {!isNavigationRendered && canStartNewConversation && ( <>
diff --git a/components/ai_chat/resources/page/components/full_page/style.module.scss b/components/ai_chat/resources/page/components/full_page/style.module.scss index 552474411e1c..5bf9cbb3ab19 100644 --- a/components/ai_chat/resources/page/components/full_page/style.module.scss +++ b/components/ai_chat/resources/page/components/full_page/style.module.scss @@ -27,7 +27,8 @@ } .aside { - width: 340px; + --navigation-width: 340px; + width: var(--navigation-width); height: 100%; overflow: hidden; background: var(--leo-color-page-background); @@ -41,6 +42,12 @@ padding-top: var(--top-padding); } +.nav { + // Ensure navigation content always stays at full-width even when animation + // is decreasing the width of the parent + width: var(--navigation-width); +} + .left { min-width: 52px; } diff --git a/components/ai_chat/resources/page/components/header/index.tsx b/components/ai_chat/resources/page/components/header/index.tsx index 77e46c2ef5f3..031ec40e8e35 100644 --- a/components/ai_chat/resources/page/components/header/index.tsx +++ b/components/ai_chat/resources/page/components/header/index.tsx @@ -38,16 +38,17 @@ export const ConversationHeader = React.forwardRef(function (props: FeatureButto const activeConversation = aiChatContext.visibleConversations.find(c => c.uuid === conversationContext.conversationUuid) const showTitle = !isTabAssociated || aiChatContext.isStandalone - const canShowFullScreenButton = aiChatContext.isHistoryEnabled && !aiChatContext.isMobile && !aiChatContext.isStandalone && conversationContext.conversationUuid + const canShowFullScreenButton = aiChatContext.isHistoryFeatureEnabled && !aiChatContext.isMobile && !aiChatContext.isStandalone && conversationContext.conversationUuid return (
{showTitle ? ( -
+
{!isTabAssociated && !aiChatContext.isStandalone && } @@ -66,7 +67,7 @@ export const ConversationHeader = React.forwardRef(function (props: FeatureButto title={newChatButtonLabel} onClick={createNewConversation} > - + )} {canShowFullScreenButton && @@ -75,7 +76,7 @@ export const ConversationHeader = React.forwardRef(function (props: FeatureButto kind='plain-faint' aria-label={openFullPageButtonLabel} title={openFullPageButtonLabel} - onClick={() => getAPI().UIHandler.openConversationFullPage(conversationContext.conversationUuid!)} + onClick={() => getAPI().uiHandler.openConversationFullPage(conversationContext.conversationUuid!)} > } @@ -87,7 +88,7 @@ export const ConversationHeader = React.forwardRef(function (props: FeatureButto aria-label={closeButtonLabel} title={closeButtonLabel} className={styles.closeButton} - onClick={() => getAPI().UIHandler.closeUI()} + onClick={() => getAPI().uiHandler.closeUI()} > diff --git a/components/ai_chat/resources/page/components/header/style.module.scss b/components/ai_chat/resources/page/components/header/style.module.scss index aca3e58d418c..a127d901d6f5 100644 --- a/components/ai_chat/resources/page/components/header/style.module.scss +++ b/components/ai_chat/resources/page/components/header/style.module.scss @@ -64,9 +64,10 @@ } .conversationTitle { + margin-right: var(--leo-spacing-m); display: flex; align-items: center; - gap: 10px; + gap: var(--leo-spacing-m); overflow: hidden; } diff --git a/components/ai_chat/resources/page/components/loading/index.tsx b/components/ai_chat/resources/page/components/loading/index.tsx new file mode 100644 index 000000000000..db9e82b1fd50 --- /dev/null +++ b/components/ai_chat/resources/page/components/loading/index.tsx @@ -0,0 +1,16 @@ +// Copyright (c) 2024 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +import * as React from 'react' +import ProgressRing from '@brave/leo/react/progressRing' +import styles from './loading.module.scss' + +export default function Loading() { + return ( +
+ +
+ ) +} diff --git a/components/ai_chat/resources/page/components/loading/loading.module.scss b/components/ai_chat/resources/page/components/loading/loading.module.scss new file mode 100644 index 000000000000..bc0ba682a92c --- /dev/null +++ b/components/ai_chat/resources/page/components/loading/loading.module.scss @@ -0,0 +1,24 @@ +// Copyright (c) 2024 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +.loading { + --leo-progressring-size: 60px; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + // Only show if page has been loading for more than x milliseconds + animation: .24s ease-out 1s forwards fadeIn; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} diff --git a/components/ai_chat/resources/page/components/main/index.tsx b/components/ai_chat/resources/page/components/main/index.tsx index a2ddc82f0cc3..4cb8d726105e 100644 --- a/components/ai_chat/resources/page/components/main/index.tsx +++ b/components/ai_chat/resources/page/components/main/index.tsx @@ -18,6 +18,7 @@ import ErrorConversationEnd from '../alerts/error_conversation_end' import ErrorInvalidEndpointURL from '../alerts/error_invalid_endpoint_url' import ErrorRateLimit from '../alerts/error_rate_limit' import LongConversationInfo from '../alerts/long_conversation_info' +import NoticeConversationStorage from '../notices/notice_conversation_storage' import WarningPremiumDisconnected from '../alerts/warning_premium_disconnected' import ConversationEntries from '../conversation_entries' import ConversationsList from '../conversations_list' @@ -50,6 +51,7 @@ function Main() { !aiChatContext.isPremiumStatusFetching && // Avoid flash of content !shouldShowPremiumSuggestionForModel && // Don't show 2 premium prompts !conversationContext.apiHasError && // Don't show premium prompt and errors (rate limit error has its own premium prompt suggestion) + !aiChatContext.isStorageNoticeDismissed && // Don't show premium prompt and storage notice aiChatContext.canShowPremiumPrompt && conversationContext.associatedContentInfo === null && // SiteInfo request has finished and this is a standalone conversation !aiChatContext.isPremiumUser @@ -192,61 +194,69 @@ function Main() { onScroll={handleScroll} > -
- {aiChatContext.hasAcceptedAgreement && <> - - - } +
+ {aiChatContext.hasAcceptedAgreement && ( + <> + + + + )} {currentErrorElement && (
{currentErrorElement}
)} - { - shouldShowPremiumSuggestionForModel && ( -
- conversationContext.switchToBasicModel()} - > - {getLocale('switchToBasicModelButtonLabel')} - - } - /> -
- ) - } - { - shouldShowPremiumSuggestionStandalone && ( -
- aiChatContext.dismissPremiumPrompt()} - > - {getLocale('dismissButtonLabel')} - - } - /> -
- ) - } - {aiChatContext.isPremiumUserDisconnected && (!conversationContext.currentModel || isLeoModel(conversationContext.currentModel)) && + {aiChatContext.hasAcceptedAgreement && !aiChatContext.isStorageNoticeDismissed && ( +
+ +
+ )} + {shouldShowPremiumSuggestionForModel && ( +
+ conversationContext.switchToBasicModel()} + > + {getLocale('switchToBasicModelButtonLabel')} + + } + /> +
+ )} + {shouldShowPremiumSuggestionStandalone && ( +
+ aiChatContext.dismissPremiumPrompt()} + > + {getLocale('dismissButtonLabel')} + + } + /> +
+ )} + {aiChatContext.isPremiumUserDisconnected && (!conversationContext.currentModel || isLeoModel(conversationContext.currentModel)) && (
- } - {conversationContext.shouldShowLongConversationInfo && + )} + {conversationContext.shouldShowLongConversationInfo && (
-
} - {!aiChatContext.hasAcceptedAgreement && !conversationContext.conversationHistory.length && } +
+ )} + {!aiChatContext.hasAcceptedAgreement && !conversationContext.conversationHistory.length && ( + + )}
diff --git a/components/ai_chat/resources/page/components/main/style.module.scss b/components/ai_chat/resources/page/components/main/style.module.scss index e9001d0ffeff..5c0a20c6a4a9 100644 --- a/components/ai_chat/resources/page/components/main/style.module.scss +++ b/components/ai_chat/resources/page/components/main/style.module.scss @@ -4,6 +4,8 @@ // you can obtain one at https://mozilla.org/MPL/2.0/. .main { + --conversation-content-margin: var(--leo-spacing-2xl); + --conversation-content-max-width: calc(680px + calc(var(--conversation-content-margin) * 2)); display: flex; flex-direction: column; width: 100%; @@ -16,24 +18,23 @@ display: flex; flex-direction: column; width: 100%; - max-width: 680px; - margin: 0 auto; - padding: 0 8px 8px 8px; -} - -.conversationContent { - width: 100%; - max-width: 680px; + max-width: var(--conversation-content-max-width); margin: 0 auto; + padding: 0 + var(--conversation-content-margin) + var(--leo-spacing-m) + var(--conversation-content-margin); } .scroller { + --scrollbar-width: 6px; overflow: auto; + scrollbar-gutter: stable; height: 100%; - padding-bottom: 16px; + padding-bottom: var(--leo-spacing-xl); &::-webkit-scrollbar { - width: 6px; + width: var(--scrollbar-width); } &::-webkit-scrollbar-thumb { @@ -42,6 +43,19 @@ } } +.conversationContent { + margin: 0 auto; + width: 100%; + max-width: var(--conversation-content-max-width); + // Adjust padding to account for scrollbar width so content lines up with + // input area which is outside of the scroller. + padding: + 0 + calc(var(--conversation-content-margin) - var(--scrollbar-width) / 2) + 0 + calc(var(--conversation-content-margin) + var(--scrollbar-width) / 2); +} + .flushBottom { display: flex; flex-direction: column; @@ -50,7 +64,7 @@ } .header { - padding: 8px 16px; + padding: var(--leo-spacing-m) var(--leo-spacing-xl); border-bottom: 1px solid var(--leo-color-divider-subtle); display: flex; align-items: center; @@ -96,7 +110,8 @@ } .promptContainer { - margin: 24px; + margin: var(--conversation-content-margin) 0; + container-type: inline-size; } .badgePremium { @@ -139,6 +154,6 @@ min-height: 60px; display: flex; align-items: center; - gap: 8px; + gap: var(--leo-spacing-m); border-bottom: 1px solid var(--leo-color-divider-subtle); } diff --git a/components/ai_chat/resources/page/components/notices/conversation_storage.svg b/components/ai_chat/resources/page/components/notices/conversation_storage.svg new file mode 100644 index 000000000000..b4dba713b800 --- /dev/null +++ b/components/ai_chat/resources/page/components/notices/conversation_storage.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/components/ai_chat/resources/page/components/notices/notice_conversation_storage.tsx b/components/ai_chat/resources/page/components/notices/notice_conversation_storage.tsx new file mode 100644 index 000000000000..d5b5738d2e91 --- /dev/null +++ b/components/ai_chat/resources/page/components/notices/notice_conversation_storage.tsx @@ -0,0 +1,73 @@ +// Copyright (c) 2024 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +import * as React from 'react' +import Button from '@brave/leo/react/button' +import Icon from '@brave/leo/react/icon' +import { getLocale } from '$web-common/locale' +import VisibilityTimer from '$web-common/visibilityTimer' +import { useAIChat } from '../../state/ai_chat_context' +import styles from './notices.module.scss' +import illustrationUrl from './conversation_storage.svg' + +export default function NoticeConversationStorage() { + const aiChatContext = useAIChat() + + const visibilityTimer = React.useRef() + + const noticeElementRef = React.useCallback((el: HTMLDivElement | null) => { + // note: el will be null when we destroy it. + // note: In new versions of React (maybe newer than we're using) you can return a cleanup function instead + // https://react.dev/blog/2024/04/25/react-19#cleanup-functions-for-refs + if (visibilityTimer.current) { + visibilityTimer.current.stopTracking() + } + + if (!el) { + return + } + + visibilityTimer.current = new VisibilityTimer( + aiChatContext.markStorageNoticeViewed, 4000, el + ) + + visibilityTimer.current.startTracking() + }, []) + + return ( +
+
+ illustration +
+
+

{getLocale('menuConversationHistory')}

+

{getLocale('noticeConversationHistoryBody')}

+

+ aiChatContext.uiHandler?.openModelSupportUrl()} + > + {getLocale('learnMore')} + + +

+
+ +
+ ) +} diff --git a/components/ai_chat/resources/page/components/notices/notices.module.scss b/components/ai_chat/resources/page/components/notices/notices.module.scss new file mode 100644 index 000000000000..c3b1ff7769c2 --- /dev/null +++ b/components/ai_chat/resources/page/components/notices/notices.module.scss @@ -0,0 +1,90 @@ +// Copyright (c) 2024 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +.notice { + position: relative; + border-radius: var(--leo-radius-m); + background-color: var(--leo-color-container-highlight); + width: 100%; + display: flex; + flex-direction: row; + align-items: stretch; + overflow: hidden;; + color: var(--leo-color-text-secondary); +} + +.illustration { + flex: 0 0 auto; + width: 40%; + max-width: 260px; + background-color: #FF815C; + display: flex; + align-items: center; + justify-content: center; + padding: 0 3%; + + img { + width: 100%; + height: auto; + } +} + +.content { + flex-grow: 1; + padding: var(--leo-spacing-xl); + + h4 { + margin: 0 0 var(--leo-spacing-s) 0; + // width of close button, make sure no overlap + padding-right: var(--leo-spacing-2xl); + display: block; + overflow: hidden; + overflow-wrap: break-word; + font: var(--leo-font-heading-h4); + color: var(--leo-color-text-primary); + } + + p { + margin: 0 0 var(--leo-spacing-s) 0; + font: var(--leo-font-default-regular); + } + + a { + font: var(--leo-font-default-link); + color: inherit; + } +} + +.closeButton { + --leo-button-color: color-mix(in srgb, + transparent 50%, + var(--leo-color-container-background)); + --leo-button-padding: var(--leo-spacing-xs); + --leo-button-radius: 100px; + position: absolute; + top: var(--leo-spacing-xl); + right: var(--leo-spacing-xl); +} +.closeIcon { + --leo-icon-color: var(--leo-color-icon-default); +} + +@container (width < 410px) { + .notice { + flex-direction: column; + } + .illustration { + max-width: none; + width: auto; + padding: 3%; + height: 136px; + img { + width: auto; + max-width: 260px; + height: auto; + max-height: 100%; + } + } +} diff --git a/components/ai_chat/resources/page/state/active_chat_context.tsx b/components/ai_chat/resources/page/state/active_chat_context.tsx index 54559e7a6e67..5f0ec6a9bbe8 100644 --- a/components/ai_chat/resources/page/state/active_chat_context.tsx +++ b/components/ai_chat/resources/page/state/active_chat_context.tsx @@ -4,7 +4,7 @@ // You can obtain one at https://mozilla.org/MPL/2.0/. import * as React from 'react' -import getAPI, * as API from '../api' +import getAPI, * as AIChat from '../api' import { useRoute } from '$web-common/useRoute' import { useAIChat } from './ai_chat_context' @@ -13,8 +13,8 @@ export const tabAssociatedChatId = 'tab' export interface SelectedChatDetails { selectedConversationId: string | undefined updateSelectedConversationId: (conversationId: string | undefined) => void - conversationHandler: API.ConversationHandlerRemote - callbackRouter: API.ConversationUICallbackRouter + conversationHandler: AIChat.ConversationHandlerRemote + callbackRouter: AIChat.ConversationUICallbackRouter createNewConversation: () => void, isTabAssociated: boolean } @@ -58,7 +58,7 @@ function ActiveChatProvider({ children, selectedConversationId, updateSelectedCo selectedConversationId, updateSelectedConversationId, createNewConversation: () => { - setConversationAPI(API.newConversation()) + setConversationAPI(AIChat.newConversation()) }, isTabAssociated: selectedConversationId === tabAssociatedChatId }), [selectedConversationId, updateSelectedConversationId, conversationAPI]) @@ -66,12 +66,12 @@ function ActiveChatProvider({ children, selectedConversationId, updateSelectedCo React.useEffect(() => { // Handle creating a new conversation if (!selectedConversationId) { - setConversationAPI(API.newConversation()) + setConversationAPI(AIChat.newConversation()) return } // Select a specific conversation - setConversationAPI(API.bindConversation(selectedConversationId === tabAssociatedChatId + setConversationAPI(AIChat.bindConversation(selectedConversationId === tabAssociatedChatId ? undefined : selectedConversationId)) @@ -79,12 +79,12 @@ function ActiveChatProvider({ children, selectedConversationId, updateSelectedCo // listen for changes. if (selectedConversationId === tabAssociatedChatId) { const onNewDefaultConversationListenerId = - getAPI().UIObserver.onNewDefaultConversation.addListener(() => { - setConversationAPI(API.bindConversation(undefined)) + getAPI().uiObserver.onNewDefaultConversation.addListener(() => { + setConversationAPI(AIChat.bindConversation(undefined)) }) return () => { - getAPI().UIObserver.removeListener(onNewDefaultConversationListenerId) + getAPI().uiObserver.removeListener(onNewDefaultConversationListenerId) } } diff --git a/components/ai_chat/resources/page/state/ai_chat_context.tsx b/components/ai_chat/resources/page/state/ai_chat_context.tsx index c2fd3eabcb2b..68b63764d237 100644 --- a/components/ai_chat/resources/page/state/ai_chat_context.tsx +++ b/components/ai_chat/resources/page/state/ai_chat_context.tsx @@ -4,46 +4,35 @@ // You can obtain one at https://mozilla.org/MPL/2.0/. import * as React from 'react' -import getAPI, * as mojom from '../api' -import { loadTimeData } from '$web-common/loadTimeData' - -export interface AIChatContext { - visibleConversations: mojom.Conversation[] - hasAcceptedAgreement: boolean - isPremiumStatusFetching: boolean - isPremiumUser: boolean - isPremiumUserDisconnected: boolean - canShowPremiumPrompt?: boolean - isMobile: boolean - isStandalone?: boolean - isHistoryEnabled: boolean - allActions: mojom.ActionGroup[] +import getAPI, * as AIChat from '../api' + +type AIChatContextInternal = { initialized: boolean goPremium: () => void managePremium: () => void handleAgreeClick: () => void + enableStoragePref: () => void + markStorageNoticeViewed: () => void + dismissStorageNotice: () => void dismissPremiumPrompt: () => void userRefreshPremiumSession: () => void - uiHandler?: mojom.AIChatUIHandlerRemote + uiHandler?: AIChat.AIChatUIHandlerRemote editingConversationId: string | null setEditingConversationId: (uuid: string | null) => void } +export type AIChatContext = AIChat.State & AIChatContextInternal + const defaultContext: AIChatContext = { - visibleConversations: [], - hasAcceptedAgreement: Boolean(loadTimeData.getBoolean('hasAcceptedAgreement')), - isPremiumStatusFetching: true, - isPremiumUser: false, - isPremiumUserDisconnected: false, - canShowPremiumPrompt: undefined, - isMobile: Boolean(loadTimeData.getBoolean('isMobile')), - isHistoryEnabled: Boolean(loadTimeData.getBoolean('isHistoryEnabled')), - allActions: [], + ...AIChat.defaultUIState, initialized: false, goPremium: () => { }, managePremium: () => { }, handleAgreeClick: () => { }, + enableStoragePref: () => { }, + markStorageNoticeViewed: () => { }, + dismissStorageNotice: () => { }, dismissPremiumPrompt: () => { }, userRefreshPremiumSession: () => { }, @@ -55,90 +44,56 @@ export const AIChatReactContext = React.createContext(defaultContext) export function AIChatContextProvider(props: React.PropsWithChildren) { - const [context, setContext] = React.useState(defaultContext) + // Intialize with global state that may have been set between module-load + // time and the first React render. + const [context, setContext] = React.useState({ + ...defaultContext, + ...getAPI().state + }) const [editingConversationId, setEditingConversationId] = React.useState(null) - const setPartialContext = (partialContext: Partial) => { + const updateFromAPIState = (state: AIChat.State) => { setContext((value) => ({ ...value, - ...partialContext + ...state })) } React.useEffect(() => { - const { Service, Observer } = getAPI() - async function initialize() { - const [ - { conversations: visibleConversations }, - { actionList: allActions }, - { canShow: canShowPremiumPrompt } - ] = await Promise.all([ - Service.getVisibleConversations(), - Service.getActionMenuList(), - Service.getCanShowPremiumPrompt() - ]) - setPartialContext({ - visibleConversations, - allActions, - canShowPremiumPrompt, - initialized: true - }) - } + // Update with any global state change that may have occurred between + // first React render and first useEffect run. + updateFromAPIState(getAPI().state) - async function updateCurrentPremiumStatus() { - const { status } = await getAPI().Service.getPremiumStatus() - setPartialContext({ - isPremiumStatusFetching: false, - isPremiumUser: (status !== undefined && status !== mojom.PremiumStatus.Inactive), - isPremiumUserDisconnected: status === mojom.PremiumStatus.ActiveDisconnected - }) + // Listen for global state changes that occur after now + const onGlobalStateChange = () => { + updateFromAPIState(getAPI().state) } + getAPI().addStateChangeListener(onGlobalStateChange) - initialize() - updateCurrentPremiumStatus() - - if (context.isHistoryEnabled) { - Observer.onConversationListChanged.addListener( - (conversations: mojom.Conversation[]) => { - setPartialContext({ - visibleConversations: conversations - }) - } - ) + return () => { + getAPI().removeStateChangeListener(onGlobalStateChange) } - - Observer.onAgreementAccepted.addListener(() => - setPartialContext({ - hasAcceptedAgreement: true - }) - ) - - // Since there is no server-side event for premium status changing, - // we should check often. And since purchase or login is performed in - // a separate WebContents, we can check when focus is returned here. - window.addEventListener('focus', () => { - updateCurrentPremiumStatus() - }) - - document.addEventListener('visibilitychange', (e) => { - if (document.visibilityState === 'visible') { - updateCurrentPremiumStatus() - } - }) }, []) - const { Service, UIHandler } = getAPI() + const { service, uiHandler } = getAPI() const store: AIChatContext = { ...context, ...props, - isStandalone: getAPI().isStandalone, - goPremium: () => UIHandler.goPremium(), - managePremium: () => UIHandler.managePremium(), - dismissPremiumPrompt: () => Service.dismissPremiumPrompt(), - userRefreshPremiumSession: () => UIHandler.refreshPremiumSession(), - handleAgreeClick: () => Service.markAgreementAccepted(), - uiHandler: UIHandler, + goPremium: () => uiHandler.goPremium(), + managePremium: () => uiHandler.managePremium(), + markStorageNoticeViewed: () => service.dismissStorageNotice(), + dismissStorageNotice: () => { + getAPI().setPartialState({ + isStorageNoticeDismissed: true + }) + service.dismissStorageNotice() + }, + enableStoragePref: () => service.enableStoragePref(), + dismissPremiumPrompt: () => service.dismissPremiumPrompt(), + userRefreshPremiumSession: () => uiHandler.refreshPremiumSession(), + handleAgreeClick: () => service.markAgreementAccepted(), + uiHandler, editingConversationId, setEditingConversationId } diff --git a/components/ai_chat/resources/page/stories/components_panel.tsx b/components/ai_chat/resources/page/stories/components_panel.tsx index 95b561fbfdca..ef0312141503 100644 --- a/components/ai_chat/resources/page/stories/components_panel.tsx +++ b/components/ai_chat/resources/page/stories/components_panel.tsx @@ -12,10 +12,12 @@ import { getKeysForMojomEnum } from '$web-common/mojomUtils' import ThemeProvider from '$web-common/BraveCoreThemeProvider' import { InferControlsFromArgs } from '../../../../../.storybook/utils' import * as mojom from '../api/' +import { ActiveChatContext, SelectedChatDetails } from '../state/active_chat_context' import { AIChatContext, AIChatReactContext } from '../state/ai_chat_context' import { ConversationContext, ConversationReactContext } from '../state/conversation_context' import FeedbackForm from '../components/feedback_form' import FullPage from '../components/full_page' +import Loading from '../components/loading' import Main from '../components/main' import './locale' import ACTIONS_LIST from './actions' @@ -389,14 +391,18 @@ const SITE_INFO: mojom.SiteInfo = { } type CustomArgs = { + initialized: boolean currentErrorState: keyof typeof mojom.APIError model: string inputText: string hasConversation: boolean + hasConversationListItems: boolean hasSuggestedQuestions: boolean hasSiteInfo: boolean + isStorageNoticeDismissed: boolean canShowPremiumPrompt: boolean hasAcceptedAgreement: boolean + isStoragePrefEnabled: boolean isPremiumModel: boolean isPremiumUser: boolean isPremiumUserDisconnected: boolean @@ -410,11 +416,15 @@ type CustomArgs = { } const args: CustomArgs = { + initialized: true, inputText: `Write a Star Trek poem about Data's life on board the Enterprise`, hasConversation: true, + hasConversationListItems: true, hasSuggestedQuestions: true, hasSiteInfo: true, + isStorageNoticeDismissed: false, canShowPremiumPrompt: false, + isStoragePrefEnabled: true, hasAcceptedAgreement: true, isPremiumModel: false, isPremiumUser: true, @@ -476,26 +486,40 @@ const preview: Meta = { } const aiChatContext: AIChatContext = { - initialized: true, + initialized: options.args.initialized, editingConversationId: null, - visibleConversations: CONVERSATIONS, + visibleConversations: options.args.hasConversationListItems ? CONVERSATIONS : [], + isStoragePrefEnabled: options.args.isStoragePrefEnabled, hasAcceptedAgreement: options.args.hasAcceptedAgreement, isPremiumStatusFetching: false, isPremiumUser: options.args.isPremiumUser, isPremiumUserDisconnected: options.args.isPremiumUserDisconnected, + isStorageNoticeDismissed: options.args.isStorageNoticeDismissed, canShowPremiumPrompt: options.args.canShowPremiumPrompt, isMobile: options.args.isMobile, - isHistoryEnabled: options.args.isHistoryEnabled, + isHistoryFeatureEnabled: options.args.isHistoryEnabled, isStandalone: options.args.isStandalone, allActions: ACTIONS_LIST, goPremium: () => {}, managePremium: () => {}, handleAgreeClick: () => {}, + enableStoragePref: () => {}, + markStorageNoticeViewed: () => {}, + dismissStorageNotice: () => {}, dismissPremiumPrompt: () => {}, userRefreshPremiumSession: () => {}, setEditingConversationId: () => {} } + const activeChatContext: SelectedChatDetails = { + selectedConversationId: CONVERSATIONS[0].uuid, + updateSelectedConversationId: () => {}, + callbackRouter: undefined!, + conversationHandler: undefined!, + createNewConversation: () => {}, + isTabAssociated: options.args.isDefaultConversation + } + const inputText = options.args.inputText const conversationContext: ConversationContext = { @@ -537,11 +561,13 @@ const preview: Meta = { return ( - - - - - + + + + + + + ) } @@ -574,7 +600,8 @@ export const _FeedbackForm = { export const _FullPage = { args: { - isStandalone: true + isStandalone: true, + isDefaultConversation: false }, render: () => { return ( @@ -584,3 +611,16 @@ export const _FullPage = { ) } } + +export const _Loading = { + args: { + initialized: false + }, + render: () => { + return ( +
+ +
+ ) + } +} diff --git a/components/ai_chat/resources/page/stories/locale.ts b/components/ai_chat/resources/page/stories/locale.ts index 37b3c43f4d22..cbd938f20e15 100644 --- a/components/ai_chat/resources/page/stories/locale.ts +++ b/components/ai_chat/resources/page/stories/locale.ts @@ -20,6 +20,11 @@ provideStrings({ braveLeoAssistantEndpointValidAsPrivateIp: 'If you would like to use a private IP address, you must first enable "Private IP Addresses for Custom Model Enpoints" via brave://flags/#brave-ai-chat-allow-private-ips', retryButtonLabel: 'Retry', learnMore: 'Learn more', + noticeConversationHistoryBody: 'Leo will now remember your previous conversations so you can go back to them. They are stored privately on your device, and you can delete them any time.', + noticeConversationHistoryEmpty: 'Your conversation history will appear here once you start a conversation.', + noticeConversationHistoryTitleDisabledPref: 'History is disabled', + noticeConversationHistoryDisabledPref: 'In order to view and search your previous conversations with Leo, you need to enable conversation history.', + noticeConversationHistoryDisabledPrefButton: 'Enable', dismissButtonLabel: 'Dismiss', 'introMessage-0': `I'm here to help. What can I assist you with today? $1Learn more$2`, 'introMessage-1': 'I have a vast base of knowledge and a large memory able to help with more complex challenges. $1Learn more$2', @@ -27,6 +32,7 @@ provideStrings({ modelFreemiumLabelNonPremium: 'Limited', modelPremiumLabelNonPremium: 'Premium', 'modelCategory-chat': 'Chat', + menuConversationHistory: 'Conversation history', menuNewChat: 'New chat', menuSettings: 'Advanced Settings', menuTitleModels: 'Available language models', @@ -104,4 +110,7 @@ provideStrings({ sendChatButtonLabel: 'Send message to Leo', useMicButtonLabel: 'Use microphone', menuTitleCustomModels: 'Custom models', + startConversationLabel: 'Start new conversation', + goBackToActiveConversationButton: 'Go back to the active conversation', + conversationListUntitled: 'New conversation' }) diff --git a/components/resources/ai_chat_ui_strings.grdp b/components/resources/ai_chat_ui_strings.grdp index 3e6babc555a4..aae09b545c3e 100644 --- a/components/resources/ai_chat_ui_strings.grdp +++ b/components/resources/ai_chat_ui_strings.grdp @@ -270,6 +270,24 @@ Learn more + + Close this notice + + + Leo will now remember your conversations so you can go back to them. They are stored encrypted on your device, and you can delete them any time. + + + Your conversation history will appear here once you start a conversation. + + + History is disabled + + + In order to view your previous conversations with Leo, you need to enable conversation history. + + + Enable + General purpose chat @@ -405,6 +423,9 @@ Start new conversation + + Go back to the active conversation + New conversation diff --git a/ios/brave-ios/Sources/AIChat/ModelView/AIChatViewModel.swift b/ios/brave-ios/Sources/AIChat/ModelView/AIChatViewModel.swift index daebcf28e655..9662538f107b 100644 --- a/ios/brave-ios/Sources/AIChat/ModelView/AIChatViewModel.swift +++ b/ios/brave-ios/Sources/AIChat/ModelView/AIChatViewModel.swift @@ -29,7 +29,7 @@ public class AIChatViewModel: NSObject, ObservableObject { @Published var siteInfo: AiChat.SiteInfo? @Published var _shouldSendPageContents: Bool = true - @Published var canShowPremiumPrompt: Bool = false + @Published var _canShowPremiumPrompt: Bool = false @Published var premiumStatus: AiChat.PremiumStatus = .inactive @Published var suggestedQuestions: [String] = [] @Published var suggestionsStatus: AiChat.SuggestionGenerationStatus = .none @@ -68,11 +68,11 @@ public class AIChatViewModel: NSObject, ObservableObject { public var shouldShowPremiumPrompt: Bool { get { - return canShowPremiumPrompt + return _canShowPremiumPrompt } set { // swiftlint:disable:this unused_setter_value - self.canShowPremiumPrompt = newValue + _canShowPremiumPrompt = newValue if !newValue { api.dismissPremiumPrompt() } @@ -333,4 +333,8 @@ extension AIChatViewModel: AIChatDelegate { self.siteInfo = siteInfo self._shouldSendPageContents = shouldSendPageContents } + + public func onServiceStateChanged(_ state: AiChat.ServiceState) { + self._canShowPremiumPrompt = state.canShowPremiumPrompt + } } diff --git a/ios/browser/api/ai_chat/ai_chat.h b/ios/browser/api/ai_chat/ai_chat.h index bf10cd7fa721..60b8d58037ba 100644 --- a/ios/browser/api/ai_chat/ai_chat.h +++ b/ios/browser/api/ai_chat/ai_chat.h @@ -59,8 +59,6 @@ OBJC_EXPORT - (void)modifyConversation:(NSUInteger)turnId newText:(NSString*)newText; -- (void)getCanShowPremiumPrompt:(void (^_Nullable)(bool))completion; - - (void)dismissPremiumPrompt; @end diff --git a/ios/browser/api/ai_chat/ai_chat.mm b/ios/browser/api/ai_chat/ai_chat.mm index d93a13aca60d..387930908b83 100644 --- a/ios/browser/api/ai_chat/ai_chat.mm +++ b/ios/browser/api/ai_chat/ai_chat.mm @@ -61,6 +61,9 @@ - (instancetype)initWithProfileIOS:(ProfileIOS*)profile current_content_ = std::make_unique( profile_->GetSharedURLLoaderFactory(), delegate); + conversation_client_ = std::make_unique( + service_.get(), delegate_); + [self createNewConversation]; } return self; @@ -78,8 +81,7 @@ - (void)dealloc { - (void)createNewConversation { current_conversation_ = service_->CreateConversationHandlerForContent( current_content_->GetContentId(), current_content_->GetWeakPtr()); - conversation_client_ = std::make_unique( - current_conversation_.get(), delegate_); + conversation_client_->ChangeConversation(current_conversation_.get()); } - (bool)isAgreementAccepted { @@ -222,10 +224,6 @@ - (void)modifyConversation:(NSUInteger)turnId newText:(NSString*)newText { base::SysNSStringToUTF8(newText)); } -- (void)getCanShowPremiumPrompt:(void (^_Nullable)(bool))completion { - service_->GetCanShowPremiumPrompt(base::BindOnce(completion)); -} - - (void)dismissPremiumPrompt { service_->DismissPremiumPrompt(); } diff --git a/ios/browser/api/ai_chat/ai_chat_delegate.h b/ios/browser/api/ai_chat/ai_chat_delegate.h index ca9bdc67d935..fbc2569c150b 100644 --- a/ios/browser/api/ai_chat/ai_chat_delegate.h +++ b/ios/browser/api/ai_chat/ai_chat_delegate.h @@ -28,6 +28,7 @@ OBJC_EXPORT status:(AiChatSuggestionGenerationStatus)status; - (void)onPageHasContent:(AiChatSiteInfo*)siteInfo shouldSendContent:(bool)shouldSendContent; +- (void)onServiceStateChanged:(AiChatServiceState*)state; @end NS_ASSUME_NONNULL_END diff --git a/ios/browser/api/ai_chat/conversation_client.h b/ios/browser/api/ai_chat/conversation_client.h index ef923c6aa4c6..7ec3051dc317 100644 --- a/ios/browser/api/ai_chat/conversation_client.h +++ b/ios/browser/api/ai_chat/conversation_client.h @@ -9,6 +9,7 @@ #include #include +#include "base/memory/weak_ptr.h" #include "brave/components/ai_chat/core/common/mojom/ai_chat.mojom.h" #include "mojo/public/cpp/bindings/receiver.h" @@ -16,17 +17,21 @@ namespace ai_chat { +class AIChatService; class ConversationHandler; // TODO(petemill): Have AIChatViewModel.swift (aka AIChatDelegate) implement -// mojom::ConversationUI and bind directly to ConversationHandler via ai_chat.mm -// so that this proxy isn't neccessary. -class ConversationClient : public mojom::ConversationUI { +// mojom::ConversationUI and mojom::ServiceObserver and bind directly to +// ConversationHandler and AIChatService via ai_chat.mm so that this proxy isn't +// neccessary. +class ConversationClient : public mojom::ConversationUI, + public mojom::ServiceObserver { public: - ConversationClient(ConversationHandler* conversation, - id bridge); + ConversationClient(AIChatService* ai_chat_service, id bridge); ~ConversationClient() override; + void ChangeConversation(ConversationHandler* conversation); + protected: // mojom::ConversationUI void OnConversationHistoryUpdate() override; @@ -43,11 +48,19 @@ class ConversationClient : public mojom::ConversationUI { void OnFaviconImageDataChanged() override; void OnConversationDeleted() override; + // mojom::ServiceObserver + void OnStateChanged(mojom::ServiceStatePtr state) override; + void OnConversationListChanged( + std::vector conversations) override {} + private: // The actual UI __weak id bridge_; mojo::Receiver receiver_{this}; + mojo::Receiver service_receiver_{this}; + + base::WeakPtrFactory weak_ptr_factory_{this}; }; } // namespace ai_chat diff --git a/ios/browser/api/ai_chat/conversation_client.mm b/ios/browser/api/ai_chat/conversation_client.mm index 172077e241a5..d51bfdb2bb1a 100644 --- a/ios/browser/api/ai_chat/conversation_client.mm +++ b/ios/browser/api/ai_chat/conversation_client.mm @@ -8,6 +8,7 @@ #include "ai_chat.mojom.objc+private.h" #include "base/strings/sys_string_conversions.h" #include "brave/base/mac/conversions.h" +#include "brave/components/ai_chat/core/browser/ai_chat_service.h" #include "brave/components/ai_chat/core/browser/conversation_handler.h" #include "brave/components/ai_chat/core/common/mojom/ai_chat.mojom-shared.h" #include "brave/components/ai_chat/core/common/mojom/ai_chat.mojom.h" @@ -16,14 +17,22 @@ namespace ai_chat { -ConversationClient::ConversationClient(ConversationHandler* conversation, +ConversationClient::ConversationClient(AIChatService* ai_chat_service, id bridge) : bridge_(bridge) { - conversation->Bind(receiver_.BindNewPipeAndPassRemote()); + ai_chat_service->BindObserver( + service_receiver_.BindNewPipeAndPassRemote(), + base::BindOnce(&ConversationClient::OnStateChanged, + weak_ptr_factory_.GetWeakPtr())); } ConversationClient::~ConversationClient() = default; +void ConversationClient::ChangeConversation(ConversationHandler* conversation) { + receiver_.reset(); + conversation->Bind(receiver_.BindNewPipeAndPassRemote()); +} + // MARK: - mojom::ConversationUI void ConversationClient::OnConversationHistoryUpdate() { @@ -75,4 +84,9 @@ // allows deletion. } +void ConversationClient::OnStateChanged(const mojom::ServiceStatePtr state) { + [bridge_ onServiceStateChanged:[[AiChatServiceState alloc] + initWithServiceStatePtr:state->Clone()]]; +} + } // namespace ai_chat