Skip to content

Commit

Permalink
[AI Chat]: Add URL based routing
Browse files Browse the repository at this point in the history
  • Loading branch information
fallaciousreasoning committed Nov 10, 2024
1 parent ebd215e commit 2324860
Show file tree
Hide file tree
Showing 13 changed files with 301 additions and 130 deletions.
3 changes: 2 additions & 1 deletion browser/ui/views/side_panel/brave_side_panel_utils.cc
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* You can obtain one at https://mozilla.org/MPL/2.0/. */

#include "brave/browser/ui/webui/ai_chat/ai_chat_ui.h"
#include "base/strings/strcat.h"
#include "brave/components/ai_chat/core/browser/utils.h"
#include "brave/components/constants/webui_url_constants.h"
#include "chrome/browser/profiles/profile.h"
Expand All @@ -29,7 +30,7 @@ std::unique_ptr<views::View> CreateAIChatSidePanelWebView(
auto web_view = std::make_unique<SidePanelWebUIViewT<AIChatUI>>(
base::RepeatingClosure(), base::RepeatingClosure(),
std::make_unique<WebUIContentsWrapperT<AIChatUI>>(
GURL(kChatUIURL), profile.get(),
GURL(base::StrCat({kChatUIURL, "default"})), profile.get(),
IDS_SIDEBAR_CHAT_SUMMARIZER_ITEM_TITLE,
/*esc_closes_ui=*/false));
web_view->ShowUI();
Expand Down
75 changes: 9 additions & 66 deletions components/ai_chat/resources/page/chat_ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ import '@brave/leo/tokens/css/variables.css'
import '$web-common/defaultTrustedTypesPolicy'
import { loadTimeData } from '$web-common/loadTimeData'
import BraveCoreThemeProvider from '$web-common/BraveCoreThemeProvider'
import getAPI, * as API from './api'
import { AIChatContextProvider, useAIChat } from './state/ai_chat_context'
import Main from './components/main'
import {
ConversationContextProps,
ConversationContextProvider
} from './state/conversation_context'
import FullScreen from './components/full_page'
import { NavigationContext } from '$web-common/navigation/Context'
import { Routes } from './routes'

setIconBasePath('chrome-untrusted://resources/brave-icons')

Expand All @@ -30,78 +30,21 @@ function App() {
document.getElementById('mountPoint')?.classList.add('loaded')
}, [])

const [selectedConversationUuid, setSelectedConversationUuid] = React.useState<
string | undefined
>()

const [conversationAPI, setConversationAPI] =
React.useState<ConversationContextProps>()

// A token so that we can re-bind to a new default conversation when
// the associated content navigates
const [defaultConversationToken, setDefaultConversationToken] =
React.useState(new Date().getTime())

const handleSelectConversationUuid = (id: string | undefined) => {
if (!id || id !== selectedConversationUuid) {
console.log('select conversation', id)
setConversationAPI(API.bindConversation(id))
setSelectedConversationUuid(id)
}
}

// Start off with default conversation and if the target content
// navigates then show the new conversation, only if we're still
// on the default conversation.
React.useEffect(() => {
if (!selectedConversationUuid) {
handleSelectConversationUuid(undefined)
}
}, [defaultConversationToken])

// Clean up bindings when not used anymore
React.useEffect(() => {
return () => {
conversationAPI?.callbackRouter.$.close()
conversationAPI?.conversationHandler.$.close()
}
}, [conversationAPI])

const handleNewConversation = () => {
setConversationAPI(API.newConversation())
setSelectedConversationUuid(undefined)
}

React.useEffect(() => {
// Observe when default conversation changes
const onNewDefaultConversationListenerId =
getAPI().UIObserver.onNewDefaultConversation.addListener(() => {
setDefaultConversationToken(new Date().getTime())
})

return () => {
getAPI().UIObserver.removeListener(onNewDefaultConversationListenerId)
}
}, [])

return (
<AIChatContextProvider
isDefaultConversation={!selectedConversationUuid}
onNewConversation={handleNewConversation}
onSelectConversationUuid={handleSelectConversationUuid}
>
{conversationAPI && (
<ConversationContextProvider {...conversationAPI}>
<NavigationContext>
<Routes />
<AIChatContextProvider>
<ConversationContextProvider>
<BraveCoreThemeProvider>
<Content />
</BraveCoreThemeProvider>
</ConversationContextProvider>
)}
</AIChatContextProvider>
</AIChatContextProvider>
</NavigationContext>
)
}

function Content () {
function Content() {
const aiChatContext = useAIChat()

if (aiChatContext.isStandalone === undefined) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,9 @@ import * as React from 'react'
import Alert from '@brave/leo/react/alert'
import Button from '@brave/leo/react/button'
import { getLocale } from '$web-common/locale'
import { useAIChat } from '../../state/ai_chat_context'
import styles from './alerts.module.scss'

function ErrorConversationEnd () {
const aiChatContext = useAIChat()

function ErrorConversationEnd() {
return (
<div className={styles.alert}>
<Alert
Expand All @@ -23,9 +20,9 @@ function ErrorConversationEnd () {
<Button
slot='actions'
kind='plain-faint'
onClick={() => { aiChatContext.onNewConversation() }}
onClick={() => location.href = "/"}
>
{getLocale('menuNewChat')}
{getLocale('menuNewChat')}
</Button>
</Alert>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,14 @@ import * as React from 'react'
import Icon from '@brave/leo/react/icon'
import Button from '@brave/leo/react/button'
import { getLocale } from '$web-common/locale'
import { useAIChat } from '../../state/ai_chat_context'
import { useConversation } from '../../state/conversation_context'
import styles from './alerts.module.scss'

export default function LongConversationInfo() {
const context = useConversation()
const aiChatContext = useAIChat()

const handleClearChat = () => {
aiChatContext.onNewConversation()
location.href = "/"
context.dismissLongConversationInfo()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,15 +120,15 @@ export default function ConversationsList(props: ConversationsListProps) {
{aiChatContext.visibleConversations.map(item => {
return (
<li key={item.uuid}>
<div
<a
className={classnames({
[styles.navItem]: true,
[styles.navItemActive]: item.uuid === conversationContext.conversationUuid
})}
onClick={() => {
aiChatContext.onSelectConversationUuid(item.uuid)
props.setIsConversationsListOpen?.(false)
}}
href={`/${item.uuid}`}
>
{item.uuid === aiChatContext.editingConversationId ? (
<div className={styles.editibleTitle}>
Expand All @@ -149,7 +149,7 @@ export default function ConversationsList(props: ConversationsListProps) {
onDelete={() => getAPI().Service.deleteConversation(item.uuid)}
/>
)}
</div>
</a>
</li>
)
})}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@
}

.navItem {
all: unset;

display: block;

font: var(--leo-font-default-regular);
width: 100%;
padding: var(--leo-spacing-l) var(--leo-spacing-xl);
border-radius: 8px;
position: relative;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export default function FullScreen() {
<Button
fab
kind='plain-faint'
onClick={aiChatContext.onNewConversation}
onClick={() => window.location.href = "/"}
>
<Icon name='edit-box' />
</Button>
Expand Down
23 changes: 9 additions & 14 deletions components/ai_chat/resources/page/components/header/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import styles from './style.module.scss'
import { useAIChat } from '../../state/ai_chat_context'
import { useConversation } from '../../state/conversation_context'
import { getLocale } from '$web-common/locale'
import { useSelectedConversation } from '../../routes'

const Logo = ({ isPremium }: { isPremium: boolean }) => <div className={styles.logo}>
<Icon name='product-brave-leo' />
Expand All @@ -35,22 +36,20 @@ export const ConversationHeader = React.forwardRef(function (props: FeatureButto
const shouldDisplayEraseAction = !aiChatContext.isStandalone &&
conversationContext.conversationHistory.length >= 1

const newConversation = () => {
aiChatContext.onNewConversation()
}

const activeConversation = aiChatContext.visibleConversations.find(c => c.uuid === conversationContext.conversationUuid)
const showTitle = (!aiChatContext.isDefaultConversation || aiChatContext.isStandalone)
const conversationId = useSelectedConversation()
const isDefault = conversationId === 'default'
const showTitle = !isDefault || aiChatContext.isStandalone
const canShowFullScreenButton = aiChatContext.isHistoryEnabled && !aiChatContext.isMobile && !aiChatContext.isStandalone && conversationContext.conversationUuid

return (
<div className={styles.header} ref={ref}>
{showTitle ? (
<div className={styles.conversationTitle}>
{!aiChatContext.isStandalone && <Button
<div className={styles.pageTitle}>
{!isDefault && !aiChatContext.isStandalone && <Button
kind='plain-faint'
fab
onClick={() => { aiChatContext.onSelectConversationUuid(undefined) }}
onClick={() => location.href = "/default"}
>
<Icon name='arrow-left' />
</Button>}
Expand All @@ -67,7 +66,7 @@ export const ConversationHeader = React.forwardRef(function (props: FeatureButto
kind='plain-faint'
aria-label={newChatButtonLabel}
title={newChatButtonLabel}
onClick={newConversation}
onClick={() => location.href = "/"}
>
<Icon name={aiChatContext.isHistoryEnabled ? 'edit-box' : 'erase'} />
</Button>
Expand Down Expand Up @@ -106,10 +105,6 @@ export function NavigationHeader() {
const aiChatContext = useAIChat()
const conversationContext = useConversation()

const newConversation = () => {
aiChatContext.onNewConversation()
}

const canStartNewConversation = conversationContext.conversationHistory.length >= 1
&& aiChatContext.hasAcceptedAgreement

Expand All @@ -126,7 +121,7 @@ export function NavigationHeader() {
kind='plain-faint'
aria-label={newChatButtonLabel}
title={newChatButtonLabel}
onClick={newConversation}
onClick={() => location.href = "/"}
>
<Icon name='edit-box' />
</Button>
Expand Down
33 changes: 33 additions & 0 deletions components/ai_chat/resources/page/routes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// 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 { useNavigation, useParams } from '$web-common/navigation/Context'
import { useEffect } from 'react'

export function useSelectedConversation() {
return useParams().chatId
}

const routes = [
'/',
'/{chatId}'
]

export function Routes() {
const { addRoute, removeRoute } = useNavigation()
useEffect(() => {
for (const route of routes) {
addRoute(route)
}

return () => {
for (const route of routes) {
removeRoute(route)
}
}
}, [])

return null
}
38 changes: 23 additions & 15 deletions components/ai_chat/resources/page/state/ai_chat_context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,9 @@
import * as React from 'react'
import getAPI, * as mojom from '../api'
import { loadTimeData } from '$web-common/loadTimeData'
import { useNavigation } from '$web-common/navigation/Context'


interface Props {
// Whether there is a specific conversation selected
isDefaultConversation: boolean
// Create a new conversation and use it
onNewConversation: () => unknown
// Select a new conversation
onSelectConversationUuid: (id: string | undefined) => unknown
}

export interface AIChatContext extends Props {
export interface AIChatContext {
visibleConversations: mojom.Conversation[]
hasAcceptedAgreement: boolean
isPremiumStatusFetching: boolean
Expand All @@ -40,7 +31,6 @@ export interface AIChatContext extends Props {
}

const defaultContext: AIChatContext = {
isDefaultConversation: true,
visibleConversations: [],
hasAcceptedAgreement: Boolean(loadTimeData.getBoolean('hasAcceptedAgreement')),
isPremiumStatusFetching: true,
Expand All @@ -55,8 +45,6 @@ const defaultContext: AIChatContext = {
handleAgreeClick: () => { },
dismissPremiumPrompt: () => { },
userRefreshPremiumSession: () => { },
onNewConversation: () => { },
onSelectConversationUuid: () => { },

editingConversationId: null,
setEditingConversationId: () => { }
Expand All @@ -65,7 +53,7 @@ const defaultContext: AIChatContext = {
export const AIChatReactContext =
React.createContext<AIChatContext>(defaultContext)

export function AIChatContextProvider(props: React.PropsWithChildren<Props>) {
export function AIChatContextProvider(props: React.PropsWithChildren) {
const [context, setContext] = React.useState<AIChatContext>(defaultContext)
const [editingConversationId, setEditingConversationId] = React.useState<string | null>(null)

Expand Down Expand Up @@ -137,6 +125,26 @@ export function AIChatContextProvider(props: React.PropsWithChildren<Props>) {
})
}, [])

const { addRoute, removeRoute, params } = useNavigation()

// Handle the case where a non-existent chat has been selected by going home.
React.useEffect(() => {
const checkExistsHandler = (params: { chatId: string }) => {
// Special case the default conversation - it gets treated specially as
// the chat is rebound as the tab navigates.
if (params.chatId === 'default') return

if (params.chatId && !context.visibleConversations.find(c => c.uuid === params.chatId)) {
location.href = '/'
}
}

addRoute('/{chatId}', checkExistsHandler)
return () => {
removeRoute('/{chatId}', checkExistsHandler)
}
}, [context.visibleConversations, params.chatId])

const { Service, UIHandler } = getAPI()

const store: AIChatContext = {
Expand Down
Loading

0 comments on commit 2324860

Please sign in to comment.