Skip to content
22 changes: 15 additions & 7 deletions apps/console/src/app/components/header/header.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { Link, useParams } from '@tanstack/react-router'
import posthog from 'posthog-js'
import { useFeatureFlagVariantKey } from 'posthog-js/react'
import { Suspense, useCallback } from 'react'
import { AssistantTrigger } from '@qovery/shared/assistant/feature'
import { AssistantTrigger, useConversationsUnreadCount, useSetConversationsOpen } from '@qovery/shared/assistant/feature'
import { DevopsCopilotButton } from '@qovery/shared/devops-copilot/feature'
import { SpotlightTrigger } from '@qovery/shared/spotlight/feature'
import { Button, LogoIcon } from '@qovery/shared/ui'
Expand All @@ -25,9 +24,11 @@ export function Separator() {
export function Header() {
const { organizationId = '' } = useParams({ strict: false })
const isDevopsCopilotEnabled = useFeatureFlagVariantKey('devops-copilot')
const setConversationsOpen = useSetConversationsOpen()
const unreadCount = useConversationsUnreadCount()
const handleFeedbackClick = useCallback(() => {
posthog.capture('feedback_button_clicked_new_navigation')
}, [])
setConversationsOpen(true)
}, [setConversationsOpen])

return (
<header className="relative z-header w-full bg-background-secondary py-4 pl-3 pr-4">
Expand All @@ -47,9 +48,16 @@ export function Header() {
<div className="hidden md:block">
<SpotlightTrigger />
</div>
<Button onClick={handleFeedbackClick} variant="outline">
Feedback
</Button>
<div className="relative">
<Button onClick={handleFeedbackClick} variant="outline">
Feedback
</Button>
{unreadCount > 0 && (
<span className="pointer-events-none absolute -top-1.5 -right-1.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-surface-brand-solid px-1 text-[10px] font-medium text-neutralInvert">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</div>
<AssistantTrigger />
{isDevopsCopilotEnabled && <DevopsCopilotButton />}
<UserMenu />
Expand Down
2 changes: 1 addition & 1 deletion apps/console/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -178,10 +178,10 @@ function App() {
initSentry()
}, [])

// Keep PostHog's identified user in sync once Auth0 resolves the session
useEffect(() => {
if (!auth.user?.sub) {
Sentry.setUser(null)
posthog.reset()
return
}

Expand Down
40 changes: 25 additions & 15 deletions apps/console/src/routes/_authenticated/organization/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import { useEnvironment } from '@qovery/domains/environments/feature'
import { useProject } from '@qovery/domains/projects/feature'
import { type AnyService, isArgoCd, isEditableService, isManagedDatabase } from '@qovery/domains/services/data-access'
import { useRecentServices, useServiceSummary } from '@qovery/domains/services/feature'
import { AssistantPanelOutlet, AssistantProvider } from '@qovery/shared/assistant/feature'
import {
AssistantPanelOutlet,
AssistantProvider,
ConversationsPanelOutlet,
ConversationsProvider,
} from '@qovery/shared/assistant/feature'
import { DevopsCopilotContext } from '@qovery/shared/devops-copilot/context'
import { DevopsCopilotTrigger } from '@qovery/shared/devops-copilot/feature'
import { ErrorBoundary, Icon, Link, LoaderSpinner, Navbar } from '@qovery/shared/ui'
Expand Down Expand Up @@ -647,20 +652,22 @@ function OrganizationRoute() {
sendMessageRef,
}}
>
<AssistantProvider>
{isServiceNotFound ? (
<NotFoundPage
action={serviceNotFoundAction}
data={{
title: 'Service not found',
message: "This service doesn't exist anymore, or the URL is incorrect.",
}}
/>
) : (
<Outlet />
)}
<DevopsCopilotTrigger />
</AssistantProvider>
<ConversationsProvider>
<AssistantProvider>
{isServiceNotFound ? (
<NotFoundPage
action={serviceNotFoundAction}
data={{
title: 'Service not found',
message: "This service doesn't exist anymore, or the URL is incorrect.",
}}
/>
) : (
<Outlet />
)}
<DevopsCopilotTrigger />
</AssistantProvider>
</ConversationsProvider>
</DevopsCopilotContext.Provider>
)
}
Expand All @@ -673,6 +680,7 @@ function OrganizationRoute() {
sendMessageRef,
}}
>
<ConversationsProvider>
<AssistantProvider>
<div className="flex h-dvh w-full flex-col bg-background">
{/* TODO: Conflicts with body main:not(.h-screen, .layout-onboarding) */}
Expand Down Expand Up @@ -704,6 +712,7 @@ function OrganizationRoute() {
}}
>
<AssistantPanelOutlet />
<ConversationsPanelOutlet />
</div>
</div>

Expand All @@ -727,6 +736,7 @@ function OrganizationRoute() {
</div>
<DevopsCopilotTrigger />
</AssistantProvider>
</ConversationsProvider>
</DevopsCopilotContext.Provider>
)
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { type IconName } from '@fortawesome/fontawesome-common-types'
import clsx from 'clsx'
import posthog from 'posthog-js'
import { useCallback } from 'react'
import { useSetConversationsOpen } from '@qovery/shared/assistant/feature'
import { Heading, Icon, Section } from '@qovery/shared/ui'
import { useSupportChat } from '@qovery/shared/util-hooks'
import { twMerge } from '@qovery/shared/util-js'
Expand Down Expand Up @@ -34,10 +34,11 @@ const LINKS: {

export function SectionLinks() {
const { showChat } = useSupportChat()
const setConversationsOpen = useSetConversationsOpen()

const handleGiveFeedbackClick = useCallback(() => {
posthog.capture('feedback_button_clicked_new_navigation')
}, [])
setConversationsOpen(true)
}, [setConversationsOpen])

return (
<Section className="flex flex-col gap-3">
Expand Down
2 changes: 2 additions & 0 deletions libs/shared/assistant/feature/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@ export * from './lib/assistant-trigger/assistant-trigger'
export * from './lib/assistant-panel-outlet/assistant-panel-outlet'
export * from './lib/need-help/need-help'
export * from './lib/assistant-context/assistant-context'
export * from './lib/conversations-context/conversations-context'
export * from './lib/conversations-panel-outlet/conversations-panel-outlet'
export * from './lib/hooks/use-contextual-doc-links/use-contextual-doc-links'
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { motion, useReducedMotion } from 'framer-motion'
import posthog from 'posthog-js'
import { useEffect, useRef, useState } from 'react'
import { match } from 'ts-pattern'
import { ExternalLink, Icon, InputSearch, LoaderSpinner } from '@qovery/shared/ui'
Expand All @@ -11,6 +10,7 @@ import { useContextualDocLinks } from '../hooks/use-contextual-doc-links/use-con
import { useSearchDocumentation } from '../hooks/use-mintlify-search/use-mintlify-search'
import { useQoveryStatus } from '../hooks/use-qovery-status/use-qovery-status'
import { MintlifyHit } from '../mintlify-hit/mintlify-hit'
import { useSetConversationsOpen } from '../conversations-context/conversations-context'

export interface AssistantPanelProps {
onClose: () => void
Expand All @@ -19,6 +19,7 @@ export interface AssistantPanelProps {
export function AssistantPanel({ onClose }: AssistantPanelProps) {
const { data } = useQoveryStatus()
const { showChat } = useSupportChat()
const setConversationsOpen = useSetConversationsOpen()
const docLinks = useContextualDocLinks()
const [searchValue, setSearchValue] = useState('')
const shouldReduceMotion = useReducedMotion()
Expand Down Expand Up @@ -134,7 +135,7 @@ export function AssistantPanel({ onClose }: AssistantPanelProps) {
className="flex h-11 items-center justify-center gap-1.5 px-5 font-medium text-neutral transition hover:bg-surface-neutral-subtle"
type="button"
onClick={() => {
posthog.capture('feedback_button_clicked_new_navigation')
setConversationsOpen(true)
onClose()
}}
>
Expand Down
93 changes: 93 additions & 0 deletions libs/shared/assistant/feature/src/lib/conversations-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import posthog from 'posthog-js'

export interface Ticket {
id: string
status: 'new' | 'open' | 'pending' | 'on_hold' | 'resolved'
last_message?: string
last_message_at?: string
message_count: number
created_at: string
unread_count?: number
}

export interface Message {
id: string
content: string
author_type: 'customer' | 'AI' | 'human'
author_name?: string
created_at: string
is_private: boolean
}

export function getApiConfig(sub: string) {
const ph = posthog as unknown as { config?: { api_host?: string; token?: string } }
const apiHost = (ph.config?.api_host ?? 'https://us.posthog.com').replace(/\/$/, '')
const phcToken = ph.config?.token ?? ''
const remoteConfig = (
window as unknown as {
_POSTHOG_REMOTE_CONFIG?: Record<string, { config?: { conversations?: { token?: string } } }>
}
)._POSTHOG_REMOTE_CONFIG
const conversationsToken = remoteConfig?.[phcToken]?.config?.conversations?.token ?? ''
const widgetSessionId = posthog.get_session_id()
return { apiHost, conversationsToken, distinctId: sub, widgetSessionId }
}

export async function apiGetTickets(sub: string): Promise<Ticket[]> {
const { apiHost, conversationsToken, distinctId, widgetSessionId } = getApiConfig(sub)
const params = new URLSearchParams({ widget_session_id: widgetSessionId, distinct_id: distinctId, limit: '20', offset: '0' })
const res = await fetch(`${apiHost}/api/conversations/v1/widget/tickets?${params}`, {
headers: { 'X-Conversations-Token': conversationsToken },
})
if (!res.ok) throw new Error(`getTickets failed: ${res.status}`)
const data = await res.json()
return Array.isArray(data?.results) ? (data.results as Ticket[]) : []
}

export async function apiGetMessages(ticketId: string, sub: string): Promise<Message[]> {
const { apiHost, conversationsToken, widgetSessionId } = getApiConfig(sub)
const params = new URLSearchParams({ widget_session_id: widgetSessionId, limit: '50' })
const res = await fetch(`${apiHost}/api/conversations/v1/widget/messages/${ticketId}?${params}`, {
headers: { 'X-Conversations-Token': conversationsToken },
})
if (!res.ok) throw new Error(`getMessages failed: ${res.status}`)
const data = await res.json()
const messages: Message[] = Array.isArray(data?.messages) ? data.messages : []
return messages.filter((m) => !m.is_private)
}

export async function apiSendMessage(
message: string,
ticketId: string | null,
sub: string
): Promise<{ ticket_id: string; message_id: string; created_at: string }> {
const { apiHost, conversationsToken, distinctId, widgetSessionId } = getApiConfig(sub)
const res = await fetch(`${apiHost}/api/conversations/v1/widget/message`, {
method: 'POST',
headers: { 'X-Conversations-Token': conversationsToken, 'Content-Type': 'application/json' },
body: JSON.stringify({
widget_session_id: widgetSessionId,
distinct_id: distinctId,
message,
ticket_id: ticketId,
...(ticketId === null && {
session_id: posthog.get_session_id(),
session_context: {
session_replay_url: posthog.get_session_replay_url({ withTimestamp: true }),
current_url: window.location.href,
},
}),
}),
})
if (!res.ok) throw new Error(`sendMessage failed: ${res.status}`)
return res.json()
}

export async function apiMarkAsRead(ticketId: string, sub: string): Promise<void> {
const { apiHost, conversationsToken, widgetSessionId } = getApiConfig(sub)
await fetch(`${apiHost}/api/conversations/v1/widget/messages/${ticketId}/read`, {
method: 'POST',
headers: { 'X-Conversations-Token': conversationsToken, 'Content-Type': 'application/json' },
body: JSON.stringify({ widget_session_id: widgetSessionId }),
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { useAuth0 } from '@auth0/auth0-react'
import { type PropsWithChildren, createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { apiGetTickets } from '../conversations-api'

type ConversationsActionsContextValue = {
setConversationsOpen: (open: boolean) => void
toggleConversationsOpen: () => void
}

const ConversationsOpenContext = createContext(false)
const ConversationsActionsContext = createContext<ConversationsActionsContextValue>({
setConversationsOpen: (_open: boolean) => undefined,
toggleConversationsOpen: () => undefined,
})
const ConversationsUnreadContext = createContext(0)

export function ConversationsProvider({ children }: PropsWithChildren) {
const { user } = useAuth0()
const [conversationsOpen, setConversationsOpenRaw] = useState(false)
const [unreadCount, setUnreadCount] = useState(0)

useEffect(() => {
if (conversationsOpen || !user?.sub) return
const sub = user.sub

const poll = () => {
apiGetTickets(sub)
.then((tickets) => {
const count = tickets.filter((t) => typeof t.unread_count === 'number' && t.unread_count > 0).length
setUnreadCount(count)
})
.catch(() => undefined)
}

const timeout = setTimeout(poll, 2_000)
const interval = setInterval(poll, 30_000)
return () => {
clearTimeout(timeout)
clearInterval(interval)
}
}, [conversationsOpen, user?.sub])

const setConversationsOpen = useCallback((open: boolean) => {
if (open) setUnreadCount(0)
setConversationsOpenRaw(open)
}, [])

const toggleConversationsOpen = useCallback(() => {
setConversationsOpenRaw((prev) => {
if (!prev) setUnreadCount(0)
return !prev
})
}, [])

const actionsValue = useMemo(
() => ({ setConversationsOpen, toggleConversationsOpen }),
[setConversationsOpen, toggleConversationsOpen]
)

return (
<ConversationsActionsContext.Provider value={actionsValue}>
<ConversationsOpenContext.Provider value={conversationsOpen}>
<ConversationsUnreadContext.Provider value={unreadCount}>
{children}
</ConversationsUnreadContext.Provider>
</ConversationsOpenContext.Provider>
</ConversationsActionsContext.Provider>
)
}

export function useConversationsOpen() {
return useContext(ConversationsOpenContext)
}

export function useSetConversationsOpen() {
return useContext(ConversationsActionsContext).setConversationsOpen
}

export function useConversationsUnreadCount() {
return useContext(ConversationsUnreadContext)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { AnimatePresence } from 'framer-motion'
import { useConversationsOpen, useSetConversationsOpen } from '../conversations-context/conversations-context'
import { ConversationsPanel } from '../conversations-panel/conversations-panel'

export function ConversationsPanelOutlet() {
const conversationsOpen = useConversationsOpen()
const setConversationsOpen = useSetConversationsOpen()

return (
<AnimatePresence>
{conversationsOpen && <ConversationsPanel onClose={() => setConversationsOpen(false)} />}
</AnimatePresence>
)
}

export default ConversationsPanelOutlet
Loading
Loading