feat: add inline chat experience to new tab page (#418)

* feat: add inline chat experience to new tab page

Bring the full sidepanel chat experience to the new tab page. When
users select an AI suggestion from the search bar, the page transitions
inline to a full chat view instead of opening the sidepanel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove unnecessary comments from NewTab.tsx

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review comments

- Move NEWTAB_CHAT_STARTED_EVENT tracking to startInlineChat where it
  actually fires (was dead code in NewTabChat handleSubmit)
- Add NEWTAB_CHAT_RESET_EVENT tracking to handleNewConversation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: gate newtab chat behind NEWTAB_CHAT_SUPPORT feature flag

When the flag is off (BrowserOS < 0.40.0), falls back to opening the
sidepanel via openSidePanelWithSearch (previous behavior). In dev mode
all features are enabled, so inline chat works during development.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add newtab origin context to chat system prompt

When chatting from the new tab page, the AI is instructed to open
content in new tabs rather than navigating the current tab, keeping
the user's new tab page accessible.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Felarof
2026-03-05 12:02:39 -08:00
committed by GitHub
parent ec725b3781
commit 2b605bdaa3
8 changed files with 360 additions and 24 deletions

View File

@@ -26,6 +26,7 @@ import {
} from '@/components/ui/tooltip'
import { McpServerIcon } from '@/entrypoints/app/connect-mcp/McpServerIcon'
import { useGetUserMCPIntegrations } from '@/entrypoints/app/connect-mcp/useGetUserMCPIntegrations'
import { useChatSessionContext } from '@/entrypoints/sidepanel/layout/ChatSessionContext'
import { Feature } from '@/lib/browseros/capabilities'
import { useCapabilities } from '@/lib/browseros/useCapabilities'
import {
@@ -35,6 +36,8 @@ import {
import {
NEWTAB_AI_TRIGGERED_EVENT,
NEWTAB_APPS_OPENED_EVENT,
NEWTAB_CHAT_RESET_EVENT,
NEWTAB_CHAT_STARTED_EVENT,
NEWTAB_OPENED_EVENT,
NEWTAB_SEARCH_EXECUTED_EVENT,
NEWTAB_TAB_REMOVED_EVENT,
@@ -54,6 +57,7 @@ import {
useSuggestions,
} from './lib/suggestions/useSuggestions'
import { NewTabBranding } from './NewTabBranding'
import { NewTabChat } from './NewTabChat'
import { NewTabTip } from './NewTabTip'
import { ScheduleResults } from './ScheduleResults'
import { SearchSuggestions } from './SearchSuggestions'
@@ -79,6 +83,7 @@ export const NewTab = () => {
const tabsDropdownRef = useRef<HTMLDivElement>(null)
const [selectedTabs, setSelectedTabs] = useState<chrome.tabs.Tab[]>([])
const [shortcutsDialogOpen, setShortcutsDialogOpen] = useState(false)
const [chatActive, setChatActive] = useState(false)
const [mentionState, setMentionState] = useState<MentionState>({
isOpen: false,
filterText: '',
@@ -89,6 +94,9 @@ export const NewTab = () => {
const { servers: mcpServers } = useMcpServers()
const { data: userMCPIntegrations } = useGetUserMCPIntegrations()
const { messages, sendMessage, setMode, resetConversation } =
useChatSessionContext()
const connectedManagedServers = mcpServers.filter((s) => {
if (s.type !== 'managed' || !s.managedServerName) return false
return userMCPIntegrations?.integrations?.find(
@@ -262,6 +270,21 @@ export const NewTab = () => {
runSelectedAction(selectedItem)
}
const startInlineChat = (
message: string,
mode: 'chat' | 'agent',
action?: ReturnType<
typeof createBrowserOSAction | typeof createAITabAction
>,
) => {
track(NEWTAB_CHAT_STARTED_EVENT, { mode, tabs_count: selectedTabs.length })
setMode(mode)
setChatActive(true)
sendMessage({ text: message, action })
reset()
setSelectedTabs([])
}
const runSelectedAction = (item: SuggestionItem | undefined) => {
if (!item) return
@@ -284,11 +307,17 @@ export const NewTab = () => {
tabs: selectedTabs,
})
const searchQuery = `${item.name}${item.description ? ` - ${item.description}` : ''}}`
openSidePanelWithSearch('open', {
query: searchQuery,
mode: 'agent',
action,
})
if (supports(Feature.NEWTAB_CHAT_SUPPORT)) {
startInlineChat(searchQuery, 'agent', action)
} else {
openSidePanelWithSearch('open', {
query: searchQuery,
mode: 'agent',
action,
})
reset()
setSelectedTabs([])
}
break
}
case 'browseros': {
@@ -301,25 +330,32 @@ export const NewTab = () => {
message: item.message,
tabs: selectedTabs,
})
openSidePanelWithSearch('open', {
query: item.message,
mode: item.mode,
action,
})
if (supports(Feature.NEWTAB_CHAT_SUPPORT)) {
startInlineChat(item.message, item.mode, action)
} else {
openSidePanelWithSearch('open', {
query: item.message,
mode: item.mode,
action,
})
reset()
setSelectedTabs([])
}
break
}
}
reset()
setSelectedTabs([])
}
const handleBackToSearch = () => {
track(NEWTAB_CHAT_RESET_EVENT, { message_count: messages.length })
resetConversation()
setChatActive(false)
}
const isSuggestionsVisible =
!mentionState.isOpen &&
// User is typing text into the input
((isOpen && inputValue.length) ||
// There are sections to display
(sections.length > 0 && inputValue.length) ||
// User has selected some active tabs
(isOpen && selectedTabs.length))
useEffect(() => {
@@ -327,6 +363,10 @@ export const NewTab = () => {
track(NEWTAB_OPENED_EVENT)
}, [])
if (chatActive) {
return <NewTabChat onBackToSearch={handleBackToSearch} />
}
return (
<div className="pt-[max(25vh,16px)]">
{/* Main content */}

View File

@@ -0,0 +1,182 @@
import { Loader2 } from 'lucide-react'
import { type FC, useEffect, useRef, useState } from 'react'
import { ChatEmptyState } from '@/entrypoints/sidepanel/index/ChatEmptyState'
import { ChatError } from '@/entrypoints/sidepanel/index/ChatError'
import { ChatFooter } from '@/entrypoints/sidepanel/index/ChatFooter'
import { ChatMessages } from '@/entrypoints/sidepanel/index/ChatMessages'
import type { ChatMode } from '@/entrypoints/sidepanel/index/chatTypes'
import { useChatSessionContext } from '@/entrypoints/sidepanel/layout/ChatSessionContext'
import { createBrowserOSAction } from '@/lib/chat-actions/types'
import {
NEWTAB_CHAT_MODE_CHANGED_EVENT,
NEWTAB_CHAT_RESET_EVENT,
NEWTAB_CHAT_STOPPED_EVENT,
NEWTAB_CHAT_SUGGESTION_CLICKED_EVENT,
NEWTAB_TAB_REMOVED_EVENT,
NEWTAB_TAB_TOGGLED_EVENT,
} from '@/lib/constants/analyticsEvents'
import { track } from '@/lib/metrics/track'
import { NewTabChatHeader } from './NewTabChatHeader'
interface NewTabChatProps {
onBackToSearch: () => void
}
export const NewTabChat: FC<NewTabChatProps> = ({ onBackToSearch }) => {
const {
mode,
setMode,
messages,
sendMessage,
status,
stop,
agentUrlError,
chatError,
getActionForMessage,
liked,
onClickLike,
disliked,
onClickDislike,
isRestoringConversation,
providers,
selectedProvider,
handleSelectProvider,
resetConversation,
} = useChatSessionContext()
const [input, setInput] = useState('')
const [attachedTabs, setAttachedTabs] = useState<chrome.tabs.Tab[]>([])
const messagesEndRef = useRef<HTMLDivElement>(null)
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll only when messages change
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
const handleModeChange = (newMode: ChatMode) => {
track(NEWTAB_CHAT_MODE_CHANGED_EVENT, { from: mode, to: newMode })
setMode(newMode)
}
const handleStop = () => {
track(NEWTAB_CHAT_STOPPED_EVENT)
stop()
}
const toggleTabSelection = (tab: chrome.tabs.Tab) => {
setAttachedTabs((prev) => {
const isSelected = prev.some((t) => t.id === tab.id)
track(NEWTAB_TAB_TOGGLED_EVENT, {
action: isSelected ? 'removed' : 'added',
})
if (isSelected) {
return prev.filter((t) => t.id !== tab.id)
}
return [...prev, tab]
})
}
const removeTab = (tabId?: number) => {
track(NEWTAB_TAB_REMOVED_EVENT)
setAttachedTabs((prev) => prev.filter((t) => t.id !== tabId))
}
const executeMessage = (customMessageText?: string) => {
const messageText = customMessageText ? customMessageText : input.trim()
if (!messageText) return
if (attachedTabs.length) {
const action = createBrowserOSAction({
mode,
message: messageText,
tabs: attachedTabs,
})
sendMessage({ text: messageText, action })
} else {
sendMessage({ text: messageText })
}
setInput('')
setAttachedTabs([])
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
executeMessage()
}
const handleSuggestionClick = (suggestion: string) => {
track(NEWTAB_CHAT_SUGGESTION_CLICKED_EVENT, { mode })
executeMessage(suggestion)
}
const handleNewConversation = () => {
track(NEWTAB_CHAT_RESET_EVENT, { message_count: messages.length })
resetConversation()
}
if (!selectedProvider) return null
return (
<div className="flex h-[calc(100vh-2rem)] flex-col">
<NewTabChatHeader
selectedProvider={selectedProvider}
providers={providers}
onSelectProvider={handleSelectProvider}
onNewConversation={handleNewConversation}
onBackToSearch={onBackToSearch}
hasMessages={messages.length > 0}
/>
<main className="mx-auto flex w-full max-w-3xl flex-1 flex-col space-y-4 overflow-y-auto px-4 pt-4">
{isRestoringConversation ? (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : messages.length === 0 ? (
<ChatEmptyState
mode={mode}
mounted={mounted}
onSuggestionClick={handleSuggestionClick}
/>
) : (
<ChatMessages
messages={messages}
status={status}
messagesEndRef={messagesEndRef}
getActionForMessage={getActionForMessage}
liked={liked}
onClickLike={onClickLike}
disliked={disliked}
onClickDislike={onClickDislike}
showJtbdPopup={false}
showDontShowAgain={false}
onTakeSurvey={() => {}}
onDismissJtbdPopup={() => {}}
/>
)}
{agentUrlError && <ChatError error={agentUrlError} />}
{chatError && <ChatError error={chatError} />}
</main>
<div className="mx-auto w-full max-w-3xl px-4">
<ChatFooter
mode={mode}
onModeChange={handleModeChange}
input={input}
onInputChange={setInput}
onSubmit={handleSubmit}
status={status}
onStop={handleStop}
attachedTabs={attachedTabs}
onToggleTab={toggleTabSelection}
onRemoveTab={removeTab}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,78 @@
import { ArrowLeft, Plus } from 'lucide-react'
import type { FC } from 'react'
import { ChatProviderSelector } from '@/components/chat/ChatProviderSelector'
import type { Provider } from '@/components/chat/chatComponentTypes'
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
import type { ProviderType } from '@/lib/llm-providers/types'
interface NewTabChatHeaderProps {
selectedProvider: Provider
providers: Provider[]
onSelectProvider: (provider: Provider) => void
onNewConversation: () => void
onBackToSearch: () => void
hasMessages: boolean
}
export const NewTabChatHeader: FC<NewTabChatHeaderProps> = ({
selectedProvider,
providers,
onSelectProvider,
onNewConversation,
onBackToSearch,
hasMessages,
}) => {
return (
<header className="flex items-center justify-between border-border/40 border-b bg-background/80 px-4 py-2.5 backdrop-blur-md">
<div className="flex items-center gap-2">
{/* Back to search */}
<button
type="button"
onClick={onBackToSearch}
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
title="Back to search"
>
<ArrowLeft className="h-4 w-4" />
</button>
{/* Provider selector */}
<ChatProviderSelector
providers={providers}
selectedProvider={selectedProvider}
onSelectProvider={onSelectProvider}
>
<button
type="button"
className="group relative inline-flex cursor-pointer items-center gap-2 rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground data-[state=open]:bg-accent"
title="Change AI Provider"
>
{selectedProvider.type === 'browseros' ? (
<BrowserOSIcon size={18} />
) : (
<ProviderIcon
type={selectedProvider.type as ProviderType}
size={18}
/>
)}
<span className="font-semibold text-base">
{selectedProvider.name}
</span>
</button>
</ChatProviderSelector>
</div>
<div className="flex items-center gap-1">
{hasMessages && (
<button
type="button"
onClick={onNewConversation}
className="cursor-pointer rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
title="New conversation"
>
<Plus className="h-4 w-4" />
</button>
)}
</div>
</header>
)
}

View File

@@ -1,12 +1,13 @@
import type { FC } from 'react'
import { Outlet } from 'react-router'
import { ChatSessionProvider } from '@/entrypoints/sidepanel/layout/ChatSessionContext'
import { NewTabFocusGrid } from './NewTabFocusGrid'
export const NewTabLayout: FC = () => {
return (
<>
<ChatSessionProvider origin="newtab">
<NewTabFocusGrid />
<Outlet />
</>
</ChatSessionProvider>
)
}

View File

@@ -64,7 +64,15 @@ export const getResponseAndQueryFromMessageId = (
}
}
export const useChatSession = () => {
export type ChatOrigin = 'sidepanel' | 'newtab'
export interface ChatSessionOptions {
origin?: ChatOrigin
}
const NEWTAB_SYSTEM_PROMPT = `IMPORTANT: The user is chatting from the New Tab page. When performing browser actions, ALWAYS open content in a NEW TAB rather than navigating the current tab. The user's new tab page should remain accessible.`
export const useChatSession = (options?: ChatSessionOptions) => {
const {
selectedLlmProviderRef,
enabledMcpServersRef,
@@ -294,7 +302,12 @@ export const useChatSession = () => {
region: provider?.region,
sessionToken: provider?.sessionToken,
browserContext,
userSystemPrompt: personalizationRef.current,
userSystemPrompt:
options?.origin === 'newtab'
? [personalizationRef.current, NEWTAB_SYSTEM_PROMPT]
.filter(Boolean)
.join('\n\n')
: personalizationRef.current,
userWorkingDir: workingDirRef.current,
supportsImages: provider?.supportsImages,
previousConversation,

View File

@@ -1,14 +1,17 @@
import { createContext, type FC, type ReactNode, useContext } from 'react'
import { useChatSession } from '../index/useChatSession'
import {
type ChatSessionOptions,
useChatSession,
} from '../index/useChatSession'
type ChatSessionContextValue = ReturnType<typeof useChatSession>
const ChatSessionContext = createContext<ChatSessionContextValue | null>(null)
export const ChatSessionProvider: FC<{ children: ReactNode }> = ({
children,
}) => {
const session = useChatSession()
export const ChatSessionProvider: FC<
{ children: ReactNode } & ChatSessionOptions
> = ({ children, ...options }) => {
const session = useChatSession(options)
return (
<ChatSessionContext.Provider value={session}>
{children}

View File

@@ -37,6 +37,8 @@ export enum Feature {
PREVIOUS_CONVERSATION_ARRAY = 'PREVIOUS_CONVERSATION_ARRAY',
// Soul page: agent personality viewer and editor
SOUL_SUPPORT = 'SOUL_SUPPORT',
// Inline chat in the new tab page
NEWTAB_CHAT_SUPPORT = 'NEWTAB_CHAT_SUPPORT',
}
/**
@@ -60,6 +62,7 @@ const FEATURE_CONFIG: { [K in Feature]: FeatureConfig } = {
[Feature.WORKFLOW_SUPPORT]: { minServerVersion: '0.0.41' },
[Feature.PREVIOUS_CONVERSATION_ARRAY]: { minServerVersion: '0.0.64' },
[Feature.SOUL_SUPPORT]: { minServerVersion: '0.0.67' },
[Feature.NEWTAB_CHAT_SUPPORT]: { minBrowserOSVersion: '0.40.0.0' },
}
function parseVersion(version: string): number[] {

View File

@@ -98,6 +98,22 @@ export const NEWTAB_APPS_OPENED_EVENT = 'newtab.apps.opened'
/** @public */
export const NEWTAB_TIP_DISMISSED_EVENT = 'newtab.tip.dismissed'
/** @public */
export const NEWTAB_CHAT_STARTED_EVENT = 'newtab.chat.started'
/** @public */
export const NEWTAB_CHAT_STOPPED_EVENT = 'newtab.chat.stopped'
/** @public */
export const NEWTAB_CHAT_RESET_EVENT = 'newtab.chat.reset'
/** @public */
export const NEWTAB_CHAT_SUGGESTION_CLICKED_EVENT =
'newtab.chat.suggestion_clicked'
/** @public */
export const NEWTAB_CHAT_MODE_CHANGED_EVENT = 'newtab.chat.mode_changed'
/** @public */
export const WORKFLOW_DELETED_EVENT = 'settings.workflow.deleted'