mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
feat: jtbd popup in sidepanel (#214)
* feat: v0.1 jtbd popup for users * feat: v0.2 jtbd popup based on messages sent * fix: clean up previous chat status and added comment * chore: change threshold to 15 * fix: show popup only when every N messages * fix: set survey taken only after clicking start on welcome page
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { AlertCircle, CheckCircle2, RotateCcw } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { jtbdPopupStorage } from '@/lib/jtbd-popup/storage'
|
||||
import { Chat } from './chat'
|
||||
import { Header } from './header'
|
||||
import { useChat } from './use-chat'
|
||||
@@ -45,12 +46,18 @@ const ErrorCard: FC<{ error: Error; onRetry: () => void }> = ({
|
||||
export const SurveyPage: FC = () => {
|
||||
const chat = useChat()
|
||||
|
||||
const handleStart = async () => {
|
||||
const current = await jtbdPopupStorage.getValue()
|
||||
await jtbdPopupStorage.setValue({ ...current, surveyTaken: true })
|
||||
chat.start()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fade-in slide-in-from-bottom-5 animate-in space-y-6 duration-500">
|
||||
<Header />
|
||||
|
||||
{chat.phase === 'idle' && (
|
||||
<Welcome onStart={chat.start} isLoading={chat.isStreaming} />
|
||||
<Welcome onStart={handleStart} isLoading={chat.isStreaming} />
|
||||
)}
|
||||
|
||||
{chat.phase === 'active' && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createBrowserOSAction } from '@/lib/chat-actions/types'
|
||||
import { SIDEPANEL_AI_TRIGGERED_EVENT } from '@/lib/constants/analyticsEvents'
|
||||
import { useJtbdPopup } from '@/lib/jtbd-popup/use-jtbd-popup'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import { ChatEmptyState } from './ChatEmptyState'
|
||||
import { ChatError } from './ChatError'
|
||||
@@ -34,6 +35,14 @@ export const Chat = () => {
|
||||
onClickDislike,
|
||||
} = useChatSession()
|
||||
|
||||
const {
|
||||
popupVisible,
|
||||
recordMessageSent,
|
||||
triggerIfEligible,
|
||||
onTakeSurvey,
|
||||
onDismiss: onDismissJtbdPopup,
|
||||
} = useJtbdPopup()
|
||||
|
||||
const [input, setInput] = useState('')
|
||||
const [attachedTabs, setAttachedTabs] = useState<chrome.tabs.Tab[]>([])
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
@@ -64,6 +73,21 @@ export const Chat = () => {
|
||||
scrollToBottom()
|
||||
}, [messages])
|
||||
|
||||
// Trigger JTBD popup when AI finishes responding
|
||||
const previousChatStatus = useRef(status)
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally only trigger on status change
|
||||
useEffect(() => {
|
||||
const aiWasProcessing =
|
||||
previousChatStatus.current === 'streaming' ||
|
||||
previousChatStatus.current === 'submitted'
|
||||
const aiJustFinished = aiWasProcessing && status === 'ready'
|
||||
|
||||
if (aiJustFinished && messages.length > 0) {
|
||||
triggerIfEligible()
|
||||
}
|
||||
previousChatStatus.current = status
|
||||
}, [status])
|
||||
|
||||
const toggleTabSelection = (tab: chrome.tabs.Tab) => {
|
||||
setAttachedTabs((prev) => {
|
||||
const isSelected = prev.some((t) => t.id === tab.id)
|
||||
@@ -81,6 +105,9 @@ export const Chat = () => {
|
||||
const executeMessage = (customMessageText?: string) => {
|
||||
const messageText = customMessageText ? customMessageText : input.trim()
|
||||
if (!messageText) return
|
||||
|
||||
recordMessageSent()
|
||||
|
||||
if (attachedTabs.length) {
|
||||
const action = createBrowserOSAction({
|
||||
mode,
|
||||
@@ -145,6 +172,9 @@ export const Chat = () => {
|
||||
onClickLike={onClickLike}
|
||||
disliked={disliked}
|
||||
onClickDislike={onClickDislike}
|
||||
showJtbdPopup={popupVisible}
|
||||
onTakeSurvey={onTakeSurvey}
|
||||
onDismissJtbdPopup={onDismissJtbdPopup}
|
||||
/>
|
||||
)}
|
||||
{agentUrlError && <ChatError error={agentUrlError} />}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import type { ChatAction } from '@/lib/chat-actions/types'
|
||||
import { ChatMessageActions } from './ChatMessageActions'
|
||||
import { getMessageSegments } from './getMessageSegments'
|
||||
import { JtbdPopup } from './JtbdPopup'
|
||||
import { ToolBatch } from './ToolBatch'
|
||||
import { UserActionMessage } from './UserActionMessage'
|
||||
|
||||
@@ -31,6 +32,9 @@ interface ChatMessagesProps {
|
||||
onClickLike: (messageId: string) => void
|
||||
disliked: Record<string, boolean>
|
||||
onClickDislike: (messageId: string, comment?: string) => void
|
||||
showJtbdPopup: boolean
|
||||
onTakeSurvey: () => void
|
||||
onDismissJtbdPopup: () => void
|
||||
}
|
||||
|
||||
export const ChatMessages: FC<ChatMessagesProps> = ({
|
||||
@@ -42,6 +46,9 @@ export const ChatMessages: FC<ChatMessagesProps> = ({
|
||||
disliked,
|
||||
onClickLike,
|
||||
onClickDislike,
|
||||
showJtbdPopup,
|
||||
onTakeSurvey,
|
||||
onDismissJtbdPopup,
|
||||
}) => {
|
||||
const isStreaming = status === 'streaming' || status === 'submitted'
|
||||
|
||||
@@ -128,6 +135,12 @@ export const ChatMessages: FC<ChatMessagesProps> = ({
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
{showJtbdPopup && (
|
||||
<JtbdPopup
|
||||
onTakeSurvey={onTakeSurvey}
|
||||
onDismiss={onDismissJtbdPopup}
|
||||
/>
|
||||
)}
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
|
||||
46
apps/agent/entrypoints/sidepanel/index/JtbdPopup.tsx
Normal file
46
apps/agent/entrypoints/sidepanel/index/JtbdPopup.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { MessageSquareHeart, X } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { Message, MessageContent } from '@/components/ai-elements/message'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface JtbdPopupProps {
|
||||
onTakeSurvey: () => void
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
export const JtbdPopup: FC<JtbdPopupProps> = ({ onTakeSurvey, onDismiss }) => {
|
||||
return (
|
||||
<Message from="assistant">
|
||||
<MessageContent>
|
||||
<div className="relative rounded-lg border border-border/50 bg-card p-4 shadow-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
className="absolute top-2 right-2 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-start gap-3 pr-6">
|
||||
<MessageSquareHeart className="h-5 w-5 shrink-0 text-primary" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">Help us improve BrowserOS!</p>
|
||||
<p className="mt-1 text-muted-foreground text-xs">
|
||||
Take a quick 2-minute survey.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button size="sm" onClick={onTakeSurvey}>
|
||||
Take Survey
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={onDismiss}>
|
||||
Maybe Later
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)
|
||||
}
|
||||
@@ -74,3 +74,12 @@ export const MANAGED_MCP_ADDED_EVENT = 'settings.managed_mcp.added'
|
||||
|
||||
/** @public */
|
||||
export const CUSTOM_MCP_ADDED_EVENT = 'settings.custom_mcp.added'
|
||||
|
||||
/** @public */
|
||||
export const JTBD_POPUP_SHOWN_EVENT = 'ui.jtbd_popup.shown'
|
||||
|
||||
/** @public */
|
||||
export const JTBD_POPUP_CLICKED_EVENT = 'ui.jtbd_popup.clicked'
|
||||
|
||||
/** @public */
|
||||
export const JTBD_POPUP_DISMISSED_EVENT = 'ui.jtbd_popup.dismissed'
|
||||
|
||||
7
apps/agent/lib/jtbd-popup/constants.ts
Normal file
7
apps/agent/lib/jtbd-popup/constants.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const JTBD_POPUP_CONSTANTS = {
|
||||
// Show popup after this many messages
|
||||
MESSAGE_THRESHOLD: 15,
|
||||
// Show to 1 in N users (samplingId % N === 0)
|
||||
// Set to 1 to show to everyone
|
||||
SAMPLING_DIVISOR: 1,
|
||||
} as const
|
||||
18
apps/agent/lib/jtbd-popup/storage.ts
Normal file
18
apps/agent/lib/jtbd-popup/storage.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { storage } from '@wxt-dev/storage'
|
||||
|
||||
export interface JtbdPopupState {
|
||||
messageCount: number
|
||||
surveyTaken: boolean
|
||||
samplingId: number
|
||||
}
|
||||
|
||||
export const jtbdPopupStorage = storage.defineItem<JtbdPopupState>(
|
||||
'local:jtbdPopupState',
|
||||
{
|
||||
fallback: {
|
||||
messageCount: 0,
|
||||
surveyTaken: false,
|
||||
samplingId: -1,
|
||||
},
|
||||
},
|
||||
)
|
||||
67
apps/agent/lib/jtbd-popup/use-jtbd-popup.ts
Normal file
67
apps/agent/lib/jtbd-popup/use-jtbd-popup.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
JTBD_POPUP_CLICKED_EVENT,
|
||||
JTBD_POPUP_DISMISSED_EVENT,
|
||||
JTBD_POPUP_SHOWN_EVENT,
|
||||
} from '@/lib/constants/analyticsEvents'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import { JTBD_POPUP_CONSTANTS } from './constants'
|
||||
import { type JtbdPopupState, jtbdPopupStorage } from './storage'
|
||||
|
||||
const isEligible = (state: JtbdPopupState): boolean => {
|
||||
if (state.surveyTaken) return false
|
||||
if (state.messageCount < JTBD_POPUP_CONSTANTS.MESSAGE_THRESHOLD) return false
|
||||
if (state.messageCount % JTBD_POPUP_CONSTANTS.MESSAGE_THRESHOLD !== 0)
|
||||
return false
|
||||
if (state.samplingId % JTBD_POPUP_CONSTANTS.SAMPLING_DIVISOR !== 0)
|
||||
return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function useJtbdPopup() {
|
||||
const [popupVisible, setPopupVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
jtbdPopupStorage.getValue().then(async (val) => {
|
||||
if (val.samplingId === -1) {
|
||||
const newVal = { ...val, samplingId: Math.floor(Math.random() * 100) }
|
||||
await jtbdPopupStorage.setValue(newVal)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const recordMessageSent = useCallback(async () => {
|
||||
const current = await jtbdPopupStorage.getValue()
|
||||
const newState = { ...current, messageCount: current.messageCount + 1 }
|
||||
await jtbdPopupStorage.setValue(newState)
|
||||
}, [])
|
||||
|
||||
const triggerIfEligible = useCallback(async () => {
|
||||
const current = await jtbdPopupStorage.getValue()
|
||||
if (isEligible(current)) {
|
||||
track(JTBD_POPUP_SHOWN_EVENT, { messageCount: current.messageCount })
|
||||
setPopupVisible(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onTakeSurvey = useCallback(async () => {
|
||||
const current = await jtbdPopupStorage.getValue()
|
||||
track(JTBD_POPUP_CLICKED_EVENT, { messageCount: current.messageCount })
|
||||
setPopupVisible(false)
|
||||
window.open('/options.html?page=survey', '_blank')
|
||||
}, [])
|
||||
|
||||
const onDismiss = useCallback(async () => {
|
||||
const current = await jtbdPopupStorage.getValue()
|
||||
track(JTBD_POPUP_DISMISSED_EVENT, { messageCount: current.messageCount })
|
||||
setPopupVisible(false)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
popupVisible,
|
||||
recordMessageSent,
|
||||
triggerIfEligible,
|
||||
onTakeSurvey,
|
||||
onDismiss,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user