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:
Felarof
2026-01-12 18:48:19 -08:00
committed by GitHub
parent 752f4319b6
commit 552558e2fd
8 changed files with 198 additions and 1 deletions

View File

@@ -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' && (

View File

@@ -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} />}

View File

@@ -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>

View 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>
)
}

View File

@@ -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'

View 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

View 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,
},
},
)

View 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,
}
}