diff --git a/.gitignore b/.gitignore index 0835549af..1fc2d8ec1 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,10 @@ deploy_config.json browser/build.env +# Environment configuration (contains local paths and secrets) +.env +.env.fish + # Build outputs browser/dist @@ -36,3 +40,10 @@ hail-*.log # Reads metadata databases reads/*.db + +.grove +.grove-worktrees + +# Binary artifacts for Docker builds +bin/ +resources/ diff --git a/browser/package.json b/browser/package.json index baadd8fa7..b25e0e16a 100644 --- a/browser/package.json +++ b/browser/package.json @@ -11,6 +11,11 @@ "dist" ], "dependencies": { + "@auth0/auth0-react": "^2.2.4", + "@copilotkit/react-core": "^1.10.6", + "@copilotkit/react-textarea": "^1.10.6", + "@copilotkit/react-ui": "^1.10.6", + "@copilotkit/runtime-client-gql": "1.10.6", "@fortawesome/fontawesome-free": "^5.8.1", "@gnomad/dataset-metadata": "*", "@gnomad/identifiers": "3.0.1", diff --git a/browser/src/App.tsx b/browser/src/App.tsx index 3edcd1a6f..6baaa7f29 100644 --- a/browser/src/App.tsx +++ b/browser/src/App.tsx @@ -1,6 +1,10 @@ import React, { Suspense, lazy, useEffect, useState } from 'react' import { BrowserRouter as Router, Route, useLocation } from 'react-router-dom' import styled from 'styled-components' +import { Auth0Provider, useAuth0 } from '@auth0/auth0-react' +import { CopilotKit } from '@copilotkit/react-core' +import '@copilotkit/react-ui/styles.css' +import './styles/chatComponents.css' import Delayed from './Delayed' import ErrorBoundary from './ErrorBoundary' @@ -8,6 +12,7 @@ import ErrorBoundary from './ErrorBoundary' import Notifications, { showNotification } from './Notifications' import StatusMessage from './StatusMessage' import userPreferences from './userPreferences' +import { GnomadCopilot } from './assistant' const NavBar = lazy(() => import('./NavBar')) const Routes = lazy(() => import('./Routes')) @@ -41,7 +46,7 @@ const GoogleAnalytics = () => { const location = useLocation() useEffect(() => { if ((window as any).gtag) { - ;(window as any).gtag('config', (window as any).gaTrackingId, { + ; (window as any).gtag('config', (window as any).gaTrackingId, { page_path: location.pathname, }) } @@ -69,10 +74,217 @@ const Banner = styled.div` } ` + const BANNER_CONTENT = null -const App = () => { +const COPILOT_MODEL_STORAGE_KEY = 'gnomad.copilot.model' +const COPILOT_SAVED_PROMPTS_STORAGE_KEY = 'gnomad.copilot.savedPrompts' +const COPILOT_ACTIVE_PROMPT_ID_STORAGE_KEY = 'gnomad.copilot.activePromptId' +const COPILOT_THREAD_ID_STORAGE_KEY = 'gnomad.copilot.threadId' + +interface SavedPrompt { + id: string + name: string + prompt: string +} + +// Helper to generate a new thread ID +const generateThreadId = () => crypto.randomUUID() + +// Wrapper to handle Auth0 loading state when authentication is enabled +const Auth0LoadingWrapper = ({ children }: { children: React.ReactNode }) => { + const { isLoading, error } = useAuth0() + + if (isLoading) { + return ( + + Authenticating... + + ) + } + + if (error) { + return ( + + Authentication error: {error.message} + + ) + } + + return <>{children} +} + +const GnomadApp = () => { + const isAuthEnabled = process.env.REACT_APP_AUTH0_ENABLE === 'true' + const { getAccessTokenSilently, isAuthenticated } = useAuth0() const [isLoading, setIsLoading] = useState(true) + const [copilotToken, setCopilotToken] = useState(null) + + // Thread ID state - load from localStorage or generate new + const [threadId, setThreadId] = useState(() => { + try { + const storedThreadId = localStorage.getItem(COPILOT_THREAD_ID_STORAGE_KEY) + return storedThreadId || generateThreadId() + } catch { + return generateThreadId() + } + }) + + // CopilotKit settings state - load from localStorage if available + const [selectedModel, setSelectedModel] = useState(() => { + try { + return localStorage.getItem(COPILOT_MODEL_STORAGE_KEY) || 'gemini-2.5-flash' + } catch { + return 'gemini-2.5-flash' + } + }) + + const [savedPrompts, setSavedPrompts] = useState(() => { + try { + const stored = localStorage.getItem(COPILOT_SAVED_PROMPTS_STORAGE_KEY) + return stored ? JSON.parse(stored) : [] + } catch { + return [] + } + }) + + const [activePromptId, setActivePromptId] = useState(() => { + try { + return localStorage.getItem(COPILOT_ACTIVE_PROMPT_ID_STORAGE_KEY) + } catch { + return null + } + }) + + const [customPrompt, setCustomPrompt] = useState(() => { + try { + const activeId = localStorage.getItem(COPILOT_ACTIVE_PROMPT_ID_STORAGE_KEY) + if (activeId) { + const stored = localStorage.getItem(COPILOT_SAVED_PROMPTS_STORAGE_KEY) + if (stored) { + const prompts: SavedPrompt[] = JSON.parse(stored) + const activePrompt = prompts.find(p => p.id === activeId) + return activePrompt?.prompt || '' + } + } + return '' + } catch { + return '' + } + }) + + // Persist model selection to localStorage + useEffect(() => { + try { + localStorage.setItem(COPILOT_MODEL_STORAGE_KEY, selectedModel) + } catch (error) { + console.error('Failed to save model preference:', error) + } + }, [selectedModel]) + + // Persist saved prompts to localStorage + useEffect(() => { + try { + localStorage.setItem(COPILOT_SAVED_PROMPTS_STORAGE_KEY, JSON.stringify(savedPrompts)) + } catch (error) { + console.error('Failed to save prompts:', error) + } + }, [savedPrompts]) + + // Persist active prompt ID to localStorage + useEffect(() => { + try { + if (activePromptId) { + localStorage.setItem(COPILOT_ACTIVE_PROMPT_ID_STORAGE_KEY, activePromptId) + } else { + localStorage.removeItem(COPILOT_ACTIVE_PROMPT_ID_STORAGE_KEY) + } + } catch (error) { + console.error('Failed to save active prompt ID:', error) + } + }, [activePromptId]) + + // Persist thread ID to localStorage + useEffect(() => { + try { + localStorage.setItem(COPILOT_THREAD_ID_STORAGE_KEY, threadId) + } catch (error) { + console.error('Failed to save thread ID:', error) + } + }, [threadId]) + + // Fetch Auth0 token for CopilotKit + useEffect(() => { + if (isAuthEnabled && isAuthenticated) { + const getToken = async () => { + try { + // Try to get token silently first + const token = await getAccessTokenSilently({ + authorizationParams: { + audience: process.env.REACT_APP_AUTH0_AUDIENCE, + scope: 'openid profile email', + } + }) + setCopilotToken(token) + } catch (e: any) { + // If consent is required, use popup to get consent interactively + if (e.error === 'consent_required' || e.error === 'login_required') { + try { + // Use popup to get consent - this will open a popup window + const token = await getAccessTokenSilently({ + authorizationParams: { + audience: process.env.REACT_APP_AUTH0_AUDIENCE, + scope: 'openid profile email', + prompt: 'consent', + }, + cacheMode: 'off', // Don't use cached token + }) + setCopilotToken(token) + } catch (consentError: any) { + console.error('Failed to get Auth0 token. Please check configuration.') + } + } + } + } + getToken() + } + }, [isAuthEnabled, isAuthenticated, getAccessTokenSilently]) + + // Handler for starting a new chat + const handleNewChat = async () => { + const newThreadId = generateThreadId() + + try { + const headers: HeadersInit = { 'Content-Type': 'application/json' } + if (isAuthEnabled && isAuthenticated) { + const token = await getAccessTokenSilently() + headers.Authorization = `Bearer ${token}` + } + + // Create the thread in the database so it appears in the sidebar + await fetch('/api/copilotkit/threads', { + method: 'POST', + headers, + body: JSON.stringify({ + threadId: newThreadId, + model: selectedModel, + }), + }) + + // Set the new thread as active + setThreadId(newThreadId) + } catch (error) { + console.error('Failed to create new thread:', error) + // Still set the thread ID even if the API call fails + setThreadId(newThreadId) + } + } + + // Handler for selecting an existing thread + const handleSelectThread = (selectedThreadId: string) => { + setThreadId(selectedThreadId) + } + useEffect(() => { userPreferences.loadPreferences().then( () => { @@ -89,45 +301,113 @@ const App = () => { ) }, []) + const copilotKitUrl = '/api/copilotkit' + + if (isAuthEnabled && isAuthenticated && !copilotToken) { + return ( + + Loading Assistant... + + ) + } + return ( - - {/* On any navigation, send event to Google Analytics. */} - - - {/** - * On any navigation, scroll to the anchor specified by location fragment (if any) or to the top of the page. - * If the page's module is already loaded, scrolling is handled by this router's render function. If the page's - * module is loaded by Suspense, scrolling is handled by the useEffect hook in the PageLoading component. - */} - { - scrollToAnchorOrStartOfPage(location) - return null - }} - /> - - - {isLoading ? ( - - Loading - - ) : ( - - - - {BANNER_CONTENT && {BANNER_CONTENT}} - - - - }> - + + + {/* On any navigation, send event to Google Analytics. */} + + + {/** + * On any navigation, scroll to the anchor specified by location fragment (if any) or to the top of the page. + * If the page's module is already loaded, scrolling is handled by this router's render function. If the page's + * module is loaded by Suspense, scrolling is handled by the useEffect hook in the PageLoading component. + */} + { + scrollToAnchorOrStartOfPage(location) + return null + }} + /> + + + {isLoading ? ( + + Loading + + ) : ( + + + + + {BANNER_CONTENT && {BANNER_CONTENT}} + + + }> + + + - - )} - - + )} + + + ) } +const App = () => { + const isAuthEnabled = process.env.REACT_APP_AUTH0_ENABLE === 'true' + + if (isAuthEnabled) { + // Handle the redirect callback - redirect to home after login + const onRedirectCallback = (appState: any) => { + console.log('[Auth0] Redirect callback', { appState, currentUrl: window.location.href }) + // After login, navigate to home or the page they were on + window.history.replaceState( + {}, + document.title, + appState?.returnTo || window.location.pathname + ) + } + + return ( + + + + + + ) + } + return +} + export default App diff --git a/browser/src/DocumentTitle.ts b/browser/src/DocumentTitle.ts index 651d8ed60..9d783fc8b 100644 --- a/browser/src/DocumentTitle.ts +++ b/browser/src/DocumentTitle.ts @@ -1,20 +1,68 @@ import PropTypes from 'prop-types' import { useEffect } from 'react' +import { useCopilotReadable } from '@copilotkit/react-core' -const DocumentTitle = ({ title }: any) => { +const DocumentTitle = ({ title, pageContext }: any) => { useEffect(() => { const fullTitle = title ? `${title} | gnomAD` : 'gnomAD' document.title = fullTitle }, [title]) + + let contextDescription = 'The current page context' + let contextValue = 'No context is available for the current page.' + + if (pageContext) { + if (pageContext.gene_id && pageContext.symbol) { + contextDescription = 'The currently viewed gene' + const geneContext = { + gene_id: pageContext.gene_id, + symbol: pageContext.symbol, + name: pageContext.name, + reference_genome: pageContext.reference_genome, + } + contextValue = JSON.stringify(geneContext, null, 2) + } else if (pageContext.variant_id) { + contextDescription = 'The currently viewed variant' + const datasetId = new URL(window.location.href).searchParams.get('dataset') + const variantContext = { + variant_id: pageContext.variant_id, + dataset: datasetId, + reference_genome: pageContext.reference_genome, + caid: pageContext.caid, + rsids: pageContext.rsids, + } + contextValue = JSON.stringify(variantContext, null, 2) + } else if (pageContext.chrom && pageContext.start && pageContext.stop) { + contextDescription = 'The currently viewed genomic region' + const regionContext = { + chrom: pageContext.chrom, + start: pageContext.start, + stop: pageContext.stop, + reference_genome: pageContext.reference_genome, + } + contextValue = JSON.stringify(regionContext, null, 2) + } else { + // Fallback for other contexts that might be passed + contextValue = JSON.stringify(pageContext, null, 2) + } + } + + useCopilotReadable({ + description: contextDescription, + value: contextValue, + }) + return null } DocumentTitle.propTypes = { title: PropTypes.string, + pageContext: PropTypes.object, } DocumentTitle.defaultProps = { title: null, + pageContext: null, } export default DocumentTitle diff --git a/browser/src/GenePage/GenePage.tsx b/browser/src/GenePage/GenePage.tsx index f8acc4a15..5e0ff0acf 100644 --- a/browser/src/GenePage/GenePage.tsx +++ b/browser/src/GenePage/GenePage.tsx @@ -362,7 +362,7 @@ const GenePage = ({ datasetId, gene, geneId }: Props) => { return ( - + { } diff --git a/browser/src/VariantPage/VariantPage.tsx b/browser/src/VariantPage/VariantPage.tsx index 40b358e81..d72c2d10f 100644 --- a/browser/src/VariantPage/VariantPage.tsx +++ b/browser/src/VariantPage/VariantPage.tsx @@ -814,10 +814,12 @@ const checkGeneLink = (transcript_consequences: TranscriptConsequence[] | null) const VariantPage = ({ datasetId, variantId }: VariantPageProps) => { const gene = { ensembleId: '' } + const [variantData, setVariantData] = React.useState(null) + return ( // @ts-expect-error TS(2746) FIXME: This JSX tag's 'children' prop expects a single ch... Remove this comment to see the full error message - + { gene.ensembleId = geneData.ensembleId } + // Update variant data for CopilotKit context + if (variantData?.variant_id !== variant.variant_id) { + setVariantData(variant) + } + pageContent = } diff --git a/browser/src/assistant/GnomadCopilot.tsx b/browser/src/assistant/GnomadCopilot.tsx new file mode 100644 index 000000000..0cae6d74a --- /dev/null +++ b/browser/src/assistant/GnomadCopilot.tsx @@ -0,0 +1,1433 @@ +import React, { useState, useRef, useCallback, useMemo } from 'react' +import styled, { css, createGlobalStyle } from 'styled-components' +import { useAuth0 } from '@auth0/auth0-react' +import { CopilotChat } from '@copilotkit/react-ui' +import { useCopilotAction, useCopilotAdditionalInstructions, useCopilotMessagesContext } from '@copilotkit/react-core' +import { + TextMessage, + ActionExecutionMessage, + ResultMessage, + AgentStateMessage, + ImageMessage, +} from '@copilotkit/runtime-client-gql' +import { useHistory, useLocation } from 'react-router-dom' +import { Button, PrimaryButton } from '@gnomad/ui' +import { useMCPStateRender } from './hooks/useMCPStateRender' +import { useGnomadVariantActions } from './gmd/hooks/useGnomadVariantActions' +import { useJuhaActions } from './gmd/hooks/useJuhaActions' +// @ts-expect-error TS(2307) FIXME: Cannot find module '@fortawesome/fontawesome-free/... Remove this comment to see the full error message +import ExpandIcon from '@fortawesome/fontawesome-free/svgs/solid/expand.svg' +// @ts-expect-error TS(2307) FIXME: Cannot find module '@fortawesome/fontawesome-free/... Remove this comment to see the full error message +import CompressIcon from '@fortawesome/fontawesome-free/svgs/solid/compress.svg' +// @ts-expect-error TS(2307) FIXME: Cannot find module '@fortawesome/fontawesome-free/... Remove this comment to see the full error message +import SettingsIcon from '@fortawesome/fontawesome-free/svgs/solid/cog.svg' +// @ts-expect-error TS(2307) FIXME: Cannot find module '@fortawesome/fontawesome-free/... Remove this comment to see the full error message +import UserShieldIcon from '@fortawesome/fontawesome-free/svgs/solid/user-shield.svg' +// @ts-expect-error TS(2307) FIXME: Cannot find module '@fortawesome/fontawesome-free/... Remove this comment to see the full error message +import CloseIcon from '@fortawesome/fontawesome-free/svgs/solid/times.svg' +// @ts-expect-error TS(2307) FIXME: Cannot find module '@fortawesome/fontawesome-free/... Remove this comment to see the full error message +import RobotIcon from '@fortawesome/fontawesome-free/svgs/solid/robot.svg' +import '@copilotkit/react-ui/styles.css' +import { ChatHistorySidebar } from './components/ChatHistorySidebar' +import { ChatSettingsView } from './components/settings/ChatSettingsView' +import { AdminView } from './components/admin/AdminView' +import { NavigationBar } from './components/NavigationBar' +import { CustomAssistantMessage } from './components/CustomAssistantMessage' +import Login from '../auth/Login' +import Logout from '../auth/Logout' +import { useCurrentUser } from '../auth/useCurrentUser' +// @ts-expect-error TS(2307) +import SignOutIcon from '@fortawesome/fontawesome-free/svgs/solid/sign-out-alt.svg' + +const PageContainer = styled.div` + display: flex; + height: 100vh; + width: 100%; + overflow: hidden; + position: relative; +` + +const MainContent = styled.div` + flex: 1; + overflow: auto; + display: flex; + flex-direction: column; + min-width: 300px; +` + +const ChatPanel = styled.div<{ width: number; mode: 'side' | 'fullscreen' }>` + display: flex; + flex-direction: column; + background: white; + min-width: 300px; + position: relative; + box-sizing: border-box; + + ${(props) => + props.mode === 'side' && + css` + width: ${props.width}px; + max-width: 80%; + overflow: hidden; + padding-right: 8px; + `} + + ${(props) => + props.mode === 'fullscreen' && + css` + position: fixed; + top: 0; + right: 0; + width: 100vw; + height: 100vh; + z-index: 1000; + max-width: 100%; + overflow: hidden; + `} +` + +const ChatWrapper = styled.div` + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +` + +const ContextUpdateBanner = styled.div` + position: absolute; + top: 60px; + left: 20px; + right: 20px; + z-index: 100; + padding: 8px 12px; + background: rgba(227, 242, 253, 0.95); + border: 1px solid #90caf9; + border-radius: 4px; + font-size: 13px; + font-weight: 500; + color: #1976d2; + text-align: center; + animation: fadeInDown 0.3s ease-out; +` + +const ResizeHandle = styled.div` + width: 4px; + background-color: #e0e0e0; + cursor: col-resize; + flex-shrink: 0; + transition: background-color 0.2s; + + &:hover { + background-color: #0d79d0; + } + + &:active { + background-color: #0d79d0; + } +` + +const LogoutButton = styled.button` + position: absolute; + top: 10px; + right: 100px; + z-index: 99999; + padding: 8px; + background: white; + border: 1px solid #e0e0e0; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; + pointer-events: auto; + + img { + width: 16px; + height: 16px; + opacity: 0.6; + display: block; + } + + &:hover { + background: #f7f7f7; + border-color: #0d79d0; + } + + &:hover img { + opacity: 1; + } +` + +const CloseButton = styled.button` + position: absolute; + top: 10px; + right: 20px; + z-index: 99999; + padding: 8px; + background: white; + border: 1px solid #e0e0e0; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; + pointer-events: auto; + + img { + width: 16px; + height: 16px; + opacity: 0.6; + display: block; + } + + &:hover { + background: #f7f7f7; + border-color: #d32f2f; + } + + &:hover img { + opacity: 1; + } +` + +const SettingsButton = styled.button` + position: absolute; + top: 10px; + right: 100px; + z-index: 99999; + padding: 8px; + background: white; + border: 1px solid #e0e0e0; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; + pointer-events: auto; + + img { + width: 16px; + height: 16px; + opacity: 0.6; + display: block; + } + + &:hover { + background: #f7f7f7; + border-color: #0d79d0; + } + + &:hover img { + opacity: 1; + } +` + +const AdminButton = styled.button` + position: absolute; + top: 10px; + right: 60px; + z-index: 99999; + padding: 8px; + background: white; + border: 1px solid #e0e0e0; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; + pointer-events: auto; + + img { + width: 16px; + height: 16px; + opacity: 0.6; + display: block; + } + + &:hover { + background: #f7f7f7; + border-color: #0d79d0; + } + + &:hover img { + opacity: 1; + } +` + +const FullscreenButton = styled.button` + position: absolute; + top: 10px; + right: 60px; + z-index: 99999; + padding: 8px; + background: white; + border: 1px solid #e0e0e0; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; + pointer-events: auto; + + img { + width: 16px; + height: 16px; + opacity: 0.6; + display: block; + } + + &:hover { + background: #f7f7f7; + border-color: #0d79d0; + } + + &:hover img { + opacity: 1; + } +` + +const BadgeContainer = styled.div` + display: flex; + gap: 8px; + padding: 8px 12px; + flex-wrap: wrap; + background: white; + border-top: 1px solid #e0e0e0; + flex-shrink: 0; +` + +const ModelBadge = styled.div` + padding: 6px 12px; + background: #f7f7f7; + border: 1px solid #e0e0e0; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + color: #666; + display: flex; + align-items: center; + gap: 8px; + + img { + width: 14px; + height: 14px; + opacity: 0.7; + } +` + +const ContextBadge = styled.div` + padding: 6px 12px; + background: #e3f2fd; + border: 1px solid #90caf9; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + color: #1976d2; + display: flex; + align-items: center; + gap: 6px; + max-width: calc(100% - 200px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + .context-type { + font-weight: 600; + text-transform: uppercase; + font-size: 11px; + } + + .context-id { + font-family: monospace; + opacity: 0.9; + } +` + +const FullscreenContainer = styled.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + z-index: 1000; + background: white; +` + +const FullscreenChatArea = styled.div` + flex: 1; + display: flex; + flex-direction: column; + position: relative; +` + +const ChatLoadingState = styled.div` + display: flex; + justify-content: center; + align-items: center; + height: 100%; + color: #666; + font-size: 14px; + background-color: #fafafa; +` + +const ToggleButton = styled.button` + position: fixed; + bottom: 24px; + right: 24px; + z-index: 1000; + padding: 12px 24px; + border-radius: 8px; + border: 1px solid #ddd; + background-color: #fff; + color: #333; + font-size: 16px; + font-weight: 500; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + cursor: pointer; + transition: all 0.2s ease-in-out; + + &:hover { + background-color: #f7f7f7; + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12); + } +` + +interface SavedPrompt { + id: string + name: string + prompt: string +} + +const StyledCopilotChat = styled(CopilotChat)` + height: 100%; + position: relative; + z-index: 1; + overflow: hidden; + + /* CSS Custom Properties for theming */ + --copilot-kit-primary-color: #0d79d0; + --copilot-kit-background-color: white; + --copilot-kit-header-background: #f7f7f7; + --copilot-kit-separator-color: rgba(0, 0, 0, 0.08); + --copilot-kit-border-radius: 0.5rem; + + /* Messages container */ + .copilotKitMessages { + padding: 1rem; + padding-top: calc(1rem + 40px); + overflow-x: hidden; + overflow-y: auto; + } + + /* Hide scrollbar unless needed */ + .copilotKitMessages::-webkit-scrollbar { + width: 8px; + } + + .copilotKitMessages::-webkit-scrollbar-track { + background: transparent; + } + + .copilotKitMessages::-webkit-scrollbar-thumb { + background: #d0d0d0; + border-radius: 4px; + } + + .copilotKitMessages::-webkit-scrollbar-thumb:hover { + background: #a0a0a0; + } + + /* Individual message bubbles */ + .copilotKitMessage { + border-radius: 0.75rem; + } + + /* Input container */ + .copilotKitInputContainer { + width: calc(100% - 48px) !important; + margin: 0 auto !important; + padding: 0 8px !important; + box-sizing: border-box !important; + } + + /* Input area */ + .copilotKitInput { + border-radius: 0.75rem; + border: 1px solid var(--copilot-kit-separator-color) !important; + } + + /* Style suggestion chips */ + .copilotKitMessages footer .suggestions .suggestion { + font-size: 14px !important; + border-radius: 0.5rem; + } + + .copilotKitMessages footer .suggestions button:not(:disabled):hover { + background-color: #f0f9ff; + border-color: var(--copilot-kit-primary-color); + transform: scale(1.03); + } +` + + +interface PageContext { + gene_id?: string + symbol?: string + name?: string + variant_id?: string + caid?: string + rsids?: string[] + chrom?: string + start?: number + stop?: number + reference_genome?: string +} + +// This new component will contain all auth-related logic for the chat. +const AuthenticatedChatView = ({ + suggestions, + isLoadingHistory, + threadId, +}: { + suggestions: { title: string; message: string }[] + isLoadingHistory: boolean + threadId: string +}) => { + const { isAuthenticated, isLoading, error, logout, getAccessTokenSilently } = useAuth0() + const chatContainerRef = useRef(null) + + // Track suggestion clicks + const trackSuggestionClick = async (suggestion: { title: string; message: string }) => { + try { + const headers: Record = { + 'Content-Type': 'application/json', + } + + // Try to get token, but don't fail if unavailable + try { + const token = await getAccessTokenSilently() + headers.Authorization = `Bearer ${token}` + } catch (e) { + // Continue without token - analytics endpoint will work without userId + } + + await fetch('/api/copilotkit/analytics/event', { + method: 'POST', + headers, + body: JSON.stringify({ + threadId, + eventType: 'suggestion_click', + payload: { + title: suggestion.title, + message: suggestion.message, + }, + }), + }) + } catch (error) { + console.error('Failed to track suggestion click:', error) + } + } + + // Add click listener for suggestion pills + React.useEffect(() => { + const handleSuggestionClick = (event: MouseEvent) => { + const target = event.target as HTMLElement + // Check if clicked element is a suggestion button + const suggestionButton = target.closest('.suggestion') + if (suggestionButton && suggestionButton instanceof HTMLElement) { + // Extract the title from the button text + const buttonText = suggestionButton.textContent || '' + // Find matching suggestion from our suggestions array + const matchedSuggestion = suggestions.find(s => s.title === buttonText) + if (matchedSuggestion) { + trackSuggestionClick(matchedSuggestion) + } + } + } + + const container = chatContainerRef.current + if (container) { + container.addEventListener('click', handleSuggestionClick) + return () => { + container.removeEventListener('click', handleSuggestionClick) + } + } + }, [suggestions, threadId]) + + const submitFeedback = async (feedback: any) => { + try { + const token = await getAccessTokenSilently() + await fetch('/api/copilotkit/feedback', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(feedback), + }) + } catch (error) { + console.error('Failed to submit feedback:', error) + } + } + + const handleThumbsUp = (message: any) => { + submitFeedback({ + messageId: message.id, + threadId, + source: 'message', + rating: 1, + }) + } + + const handleThumbsDown = (message: any) => { + submitFeedback({ + messageId: message.id, + threadId, + source: 'message', + rating: -1, + }) + } + + if (isLoading) { + return Authenticating... + } + + if (error) { + return ( +
+

Error authenticating

+

{error.message}

+ +
+ ) + } + + if (!isAuthenticated) { + return + } + + return ( +
+ {isLoadingHistory ? ( + Loading conversation... + ) : ( + } + onThumbsUp={handleThumbsUp} + onThumbsDown={handleThumbsDown} + /> + )} + {/* The logout button is rendered here for authenticated users */} + logout({ logoutParams: { returnTo: window.location.origin } })} + title="Log out" + > + Log Out + +
+ ) +} + +export function GnomadCopilot({ + children, + selectedModel, + setSelectedModel, + customPrompt, + setCustomPrompt, + savedPrompts, + setSavedPrompts, + activePromptId, + setActivePromptId, + pageContext, + threadId, + onNewChat, + onSelectThread, +}: { + children: React.ReactNode + selectedModel: string + setSelectedModel: (model: string) => void + customPrompt: string + setCustomPrompt: (prompt: string) => void + savedPrompts: SavedPrompt[] + setSavedPrompts: (prompts: SavedPrompt[]) => void + activePromptId: string | null + setActivePromptId: (id: string | null) => void + pageContext?: PageContext | null + threadId: string + onNewChat: () => void + onSelectThread: (threadId: string) => void +}) { + const history = useHistory() + const location = useLocation() + const { user: currentUser } = useCurrentUser() + const canAccessAdmin = currentUser?.role === 'admin' || currentUser?.role === 'viewer' + + // Initialize chat display mode from query parameter + const getInitialChatMode = (): 'closed' | 'side' | 'fullscreen' => { + const params = new URLSearchParams(location.search) + const chatMode = params.get('chat') + if (chatMode === 'fullscreen') return 'fullscreen' + if (chatMode === 'side') return 'side' + if (chatMode === 'closed') return 'closed' + return 'side' // default + } + + const [chatDisplayMode, setChatDisplayModeState] = useState<'closed' | 'side' | 'fullscreen'>(getInitialChatMode()) + + // Sync state with URL when location changes (e.g., browser back/forward) + React.useEffect(() => { + const params = new URLSearchParams(location.search) + const chatMode = params.get('chat') + if (chatMode === 'fullscreen') { + setChatDisplayModeState('fullscreen') + } else if (chatMode === 'side') { + setChatDisplayModeState('side') + } else if (chatMode === 'closed') { + setChatDisplayModeState('closed') + } else { + setChatDisplayModeState('side') // default + } + }, [location.search]) + + // Wrapper function to update both state and URL + const setChatDisplayMode = useCallback((mode: 'closed' | 'side' | 'fullscreen') => { + setChatDisplayModeState(mode) + + // Update URL query parameter - read current location from window + const currentSearch = window.location.search + const params = new URLSearchParams(currentSearch) + if (mode === 'side') { + // 'side' is the default, so we can remove the parameter + params.delete('chat') + } else { + params.set('chat', mode) + } + + const newSearch = params.toString() + const newUrl = `${window.location.pathname}${newSearch ? `?${newSearch}` : ''}${window.location.hash}` + history.replace(newUrl) + }, [history]) + + const isChatOpen = chatDisplayMode !== 'closed' + const isAuthEnabled = process.env.REACT_APP_AUTH0_ENABLE === 'true' + const { getAccessTokenSilently, isAuthenticated } = useAuth0() + const [chatWidth, setChatWidth] = useState(window.innerWidth / 3) // Default to 1/3 of screen + const isResizing = useRef(false) + const containerRef = useRef(null) + + // State for context update notifications + const [contextNotification, setContextNotification] = useState(null) + const lastSentContext = useRef<{ threadId: string; contextId: string } | null>(null) + + // Ref to store the chat history refresh function + const chatHistoryRefreshRef = useRef<(() => void) | null>(null) + + // Get CopilotKit's message context to set messages directly + const { setMessages, messages } = useCopilotMessagesContext() + + // State for managing loading status + const [isLoadingHistory, setIsLoadingHistory] = React.useState(true) + + // Track previous message count to detect new messages + const prevMessageCountRef = useRef(0) + + // Refresh chat history when new messages are added + React.useEffect(() => { + const currentMessageCount = messages.length + const hadMessages = prevMessageCountRef.current > 0 + const hasNewMessages = currentMessageCount > prevMessageCountRef.current + + // Refresh if we have new messages and a valid threadId + if (hasNewMessages && threadId && chatHistoryRefreshRef.current) { + // Small delay to ensure backend has processed the message + setTimeout(() => { + chatHistoryRefreshRef.current?.() + }, 500) + } + + prevMessageCountRef.current = currentMessageCount + }, [messages.length, threadId]) + + // Sync chat display mode when URL changes (e.g., browser back/forward) + React.useEffect(() => { + const params = new URLSearchParams(location.search) + const chatMode = params.get('chat') + let newMode: 'closed' | 'side' | 'fullscreen' = 'side' + if (chatMode === 'fullscreen') newMode = 'fullscreen' + else if (chatMode === 'side') newMode = 'side' + else if (chatMode === 'closed') newMode = 'closed' + + if (newMode !== chatDisplayMode) { + setChatDisplayModeState(newMode) + } + }, [location.search]) + + // Format context for display badge + const getContextDisplay = useCallback(() => { + // Use pageContext if provided + if (pageContext) { + if (pageContext.gene_id && pageContext.symbol) { + return { + type: 'Gene', + id: pageContext.symbol, + detail: pageContext.name || pageContext.gene_id + } + } else if (pageContext.variant_id) { + return { + type: 'Variant', + id: pageContext.variant_id, + detail: pageContext.caid || (pageContext.rsids && pageContext.rsids.length > 0 ? pageContext.rsids[0] : '') + } + } else if (pageContext.chrom && pageContext.start && pageContext.stop) { + return { + type: 'Region', + id: `${pageContext.chrom}:${pageContext.start}-${pageContext.stop}`, + detail: '' + } + } + } + + // Fallback: Parse from URL + const isVariantPage = location.pathname.startsWith('/variant/') + const isGenePage = location.pathname.startsWith('/gene/') + const isRegionPage = location.pathname.startsWith('/region/') + + if (isVariantPage) { + const match = location.pathname.match(/\/variant\/(.+)/) + if (match) { + const variantId = decodeURIComponent(match[1].split('?')[0]) + return { type: 'Variant', id: variantId, detail: '' } + } + } else if (isGenePage) { + const match = location.pathname.match(/\/gene\/(.+)/) + if (match) { + const geneSymbol = decodeURIComponent(match[1].split('?')[0]) + return { type: 'Gene', id: geneSymbol, detail: '' } + } + } else if (isRegionPage) { + const match = location.pathname.match(/\/region\/(.+)/) + if (match) { + const regionId = decodeURIComponent(match[1].split('?')[0]) + return { type: 'Region', id: regionId, detail: '' } + } + } + + return null + }, [pageContext, location.pathname]) + + // Effect to send context updates to backend + React.useEffect(() => { + const context = getContextDisplay() + const contextId = context ? `${context.type}:${context.id}` : null + + if (!context || !threadId || !contextId) return + + // Check if we've already sent this exact context for this thread + const alreadySent = lastSentContext.current?.threadId === threadId && + lastSentContext.current?.contextId === contextId + + if (alreadySent) return + + const isNewContext = lastSentContext.current?.threadId === threadId && + lastSentContext.current?.contextId !== contextId + + lastSentContext.current = { threadId, contextId } + + const sendContext = async () => { + try { + const headers: HeadersInit = { 'Content-Type': 'application/json' } + if (isAuthEnabled && isAuthenticated) { + const token = await getAccessTokenSilently() + headers.Authorization = `Bearer ${token}` + } + await fetch(`/api/copilotkit/threads/${threadId}/context`, { + method: 'POST', + headers, + body: JSON.stringify({ context: { type: context.type, id: context.id } }), + }) + // Only show notification for navigation (not initial context) + if (isNewContext) { + setContextNotification(`Context updated to ${context.type}: ${context.id}`) + setTimeout(() => setContextNotification(null), 5000) + } + } catch (error) { + console.error('Failed to send context update:', error) + } + } + sendContext() + }, [getContextDisplay, threadId, isAuthEnabled, isAuthenticated, getAccessTokenSilently]) + + // Effect to fetch message history when the threadId changes + React.useEffect(() => { + // Note: Don't reset lastSentContext here - it tracks thread+context pairs + // Reset message count tracking when thread changes + prevMessageCountRef.current = 0 + + // If there's no threadId, this is a new chat. Clear messages and stop loading. + if (!threadId) { + setMessages([]) + setIsLoadingHistory(false) + return + } + + const fetchMessages = async () => { + setIsLoadingHistory(true) + try { + const headers: HeadersInit = {} + if (isAuthEnabled && isAuthenticated) { + const token = await getAccessTokenSilently() + headers.Authorization = `Bearer ${token}` + } + + const response = await fetch(`/api/copilotkit/threads/${threadId}/messages`, { headers }) + + if (!response.ok) { + throw new Error(`API returned status ${response.status}`) + } + const data = await response.json() + + // The backend returns the raw message object in the `rawMessage` field. + // We need to reconstruct proper CopilotKit message instances from the plain objects + const formattedMessages = data.map((msg: any, idx: number) => { + const rawMsg = msg.rawMessage + + try { + // Reconstruct the appropriate message class based on type + switch (rawMsg.type) { + case 'TextMessage': + return new TextMessage(rawMsg) + case 'ActionExecutionMessage': + console.log('[Chat History] Reconstructing ActionExecutionMessage:', JSON.stringify(rawMsg)) + // The arguments field comes from JSONB as a parsed object + // But GraphQL schema expects it as a JSON string, so stringify it + const actionData = { ...rawMsg } + if (actionData.arguments && typeof actionData.arguments !== 'string') { + actionData.arguments = JSON.stringify(actionData.arguments) + } + return new ActionExecutionMessage(actionData) + case 'ResultMessage': + console.log('[Chat History] Reconstructing ResultMessage:', JSON.stringify(rawMsg)) + // The result field comes from JSONB as a parsed object. + // But GraphQL schema expects it as a JSON string, so stringify it + const resultData = { ...rawMsg } + if (resultData.result && typeof resultData.result !== 'string') { + resultData.result = JSON.stringify(resultData.result) + } + return new ResultMessage(resultData) + case 'AgentStateMessage': + return new AgentStateMessage(rawMsg) + case 'ImageMessage': + return new ImageMessage(rawMsg) + default: + console.warn('[Chat History] Unknown message type:', rawMsg.type) + return rawMsg + } + } catch (err) { + console.error(`[Chat History] Error reconstructing message ${idx}:`, err, 'rawMsg:', rawMsg) + throw err + } + }) + console.log('[Chat History] Reconstructed', formattedMessages.length, 'messages') + formattedMessages.forEach((msg: any, idx: number) => { + console.log(`[Chat History] Message ${idx}:`, msg.constructor?.name || msg.type, msg.id) + }) + setMessages(formattedMessages) + } catch (error) { + console.error('[Chat History] Failed to fetch chat history:', error) + setMessages([]) // Clear messages on error to ensure a clean state + } finally { + setIsLoadingHistory(false) + } + } + + fetchMessages() + }, [threadId, isAuthEnabled, isAuthenticated, getAccessTokenSilently]) // This effect runs only when the threadId changes. + + // State for current view (chat, settings, admin) and section within a view + const getUrlParams = () => new URLSearchParams(location.search) + const [activeView, setActiveViewState] = useState<'chat' | 'settings' | 'admin' | null>( + () => getUrlParams().get('view') as any + ) + const [activeSection, setActiveSectionState] = useState(() => + getUrlParams().get('section') + ) + + // Sync state with URL when location changes + React.useEffect(() => { + const params = getUrlParams() + setActiveViewState(params.get('view') as any) + setActiveSectionState(params.get('section')) + }, [location.search]) + + // Function to update the view and section in the URL + const setView = useCallback( + (view: string | null, section: string | null) => { + const params = new URLSearchParams(window.location.search) + if (view) { + params.set('view', view) + } else { + params.delete('view') + } + if (section) { + params.set('section', section) + } else { + params.delete('section') + } + const newSearch = params.toString() + const newUrl = `${window.location.pathname}${newSearch ? `?${newSearch}` : ''}${ + window.location.hash + }` + history.replace(newUrl) + }, + [history] + ) + + // Helper functions for navigation + const openSettings = (section = 'general') => setView('settings', section) + const openAdmin = (section = 'feedback') => setView('admin', section) + const showChat = () => setView(null, null) + + // Navigation handler for the NavigationBar component + const handleNavigate = (view: 'chat' | 'settings' | 'admin') => { + if (view === 'chat') { + showChat() + } else if (view === 'settings') { + openSettings() + } else if (view === 'admin') { + openAdmin() + } + } + + // Handle prompt selection from dropdown + const handlePromptSelect = (promptId: string) => { + if (promptId === '') { + // "None" selected - clear prompt + setActivePromptId(null) + setCustomPrompt('') + } else { + const prompt = savedPrompts.find(p => p.id === promptId) + if (prompt) { + setActivePromptId(promptId) + setCustomPrompt(prompt.prompt) + } + } + } + + // Save current prompt with a name + const handleSavePrompt = (promptName: string) => { + if (!promptName.trim() || !customPrompt.trim()) return + + const newPrompt: SavedPrompt = { + id: Date.now().toString(), + name: promptName.trim(), + prompt: customPrompt + } + + setSavedPrompts([...savedPrompts, newPrompt]) + setActivePromptId(newPrompt.id) + } + + // Delete a saved prompt + const handleDeletePrompt = (promptId: string) => { + setSavedPrompts(savedPrompts.filter(p => p.id !== promptId)) + if (activePromptId === promptId) { + setActivePromptId(null) + setCustomPrompt('') + } + } + + // Update the active saved prompt when custom prompt changes + const handleCustomPromptChange = (newPrompt: string) => { + setCustomPrompt(newPrompt) + // If editing an active saved prompt, update it + if (activePromptId) { + setSavedPrompts(savedPrompts.map(p => + p.id === activePromptId ? { ...p, prompt: newPrompt } : p + )) + } + } + + // Format model name for display + const getModelDisplayName = (model: string) => { + const modelMap: { [key: string]: string } = { + 'gemini-2.5-flash': 'Gemini 2.5 Flash', + 'gemini-2.5-pro': 'Gemini 2.5 Pro', + 'gemini-3-flash': 'Gemini 3 Flash', + 'gemini-3-pro': 'Gemini 3 Pro', + } + return modelMap[model] || model + } + + // Use useCopilotAdditionalInstructions to dynamically set custom instructions + useCopilotAdditionalInstructions( + { + instructions: customPrompt, + available: customPrompt ? 'enabled' : 'disabled', + }, + [customPrompt] + ) + + // Initialize MCP state rendering (renders inline in chat) + useMCPStateRender() + + // Initialize gnomAD variant actions + useGnomadVariantActions() + + // Initialize Juha API actions + useJuhaActions() + + // Show "interpret this variant" suggestion only on variant pages + const isVariantPage = location.pathname.startsWith('/variant/') + const isGenePage = location.pathname.startsWith('/gene/') + const isRegionPage = location.pathname.startsWith('/region/') + + const contextDisplay = getContextDisplay() + + // Define suggestions based on the current page + const suggestions = useMemo(() => { + if (isVariantPage) { + return [ + { + title: "Display the variant summary", + message: "Please display the variant summary", + }, + { + title: "Interpret this variant", + message: "Can you help me interpret the clinical significance and population frequency of this variant?", + }, + { + title: "Is this variant too common?", + message: "Is this variant's allele frequency too high for it to cause a rare Mendelian disease?", + }, + { + title: "Analyze expression at this location (Pext)", + message: "Analyze the Pext score for this variant's location. Is it in a functionally important region that is expressed across many tissues?", + }, + { + title: "Check in silico predictors", + message: "What do in silico predictors like REVEL and CADD say about this variant?", + }, + { + title: "Find credible sets for variant", + message: "Using the Juha API, find credible sets from GWAS, eQTL, and pQTL studies for this variant.", + }, + { + title: "Check variant for colocalization", + message: "Using the Juha API, find traits that colocalize at this variant's locus.", + }, + ] + } + if (isGenePage) { + return [ + { + title: "Summarize gene constraint", + message: "Summarize this gene's constraint scores, like pLI and missense o/e.", + }, + { + title: "Check tissue expression", + message: "In which tissues is this gene most highly expressed?", + }, + { + title: "Look up Mendelian disease", + message: "Is this gene associated with any Mendelian diseases?", + }, + { + title: "Analyze expression regions (Pext)", + message: "Provide a Pext analysis for this gene to identify functionally important regions.", + }, + { + title: "Find associations in gene region", + message: "Using the Juha API, find GWAS, eQTL, and pQTL credible sets in this gene's region.", + }, + { + title: "Find QTLs for this gene", + message: "Using the Juha API, find QTLs (eQTLs, pQTLs) where this gene is the target.", + }, + { + title: "Find curated disease associations", + message: "Using the Juha API, what diseases are associated with this gene from curated sources like ClinGen and GenCC?", + }, + ] + } + if (isRegionPage) { + return [ + { + title: "Find associations in region", + message: "Using the Juha API, find GWAS, eQTL, and pQTL credible sets that overlap with this genomic region.", + }, + ] + } + return [] + }, [isVariantPage, isGenePage, isRegionPage]) + + useCopilotAction({ + name: 'navigateToVariantPage', + description: 'Navigate to the gnomAD variant page for a given variant ID.', + parameters: [ + { + name: 'variantId', + type: 'string', + description: "The variant ID, such as '1-55516888-G-GA' or an rsID like 'rs527413419'.", + required: true, + }, + { + name: 'datasetId', + type: 'string', + description: `The dataset ID to use, for example 'gnomad_r4'. If not provided, the current dataset will be used.`, + required: false, + }, + ], + handler: async ({ variantId, datasetId }) => { + // Get the current dataset from the URL if not provided + const currentUrl = new URL(window.location.href) + const currentDatasetId = currentUrl.searchParams.get('dataset') || 'gnomad_r4' + const targetDatasetId = datasetId || currentDatasetId + + const url = `/variant/${variantId}?dataset=${targetDatasetId}` + console.log(`Navigating to: ${url}`) + history.push(url) + + return { + message: `Navigating to the variant page for ${variantId}.`, + } + }, + }) + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + isResizing.current = true + document.body.style.cursor = 'col-resize' + document.body.style.userSelect = 'none' + }, []) + + const handleMouseMove = useCallback((e: MouseEvent) => { + if (!isResizing.current || !containerRef.current) return + + const containerRect = containerRef.current.getBoundingClientRect() + const newWidth = containerRect.right - e.clientX + + // Ensure width stays within bounds + const minWidth = 300 + const maxWidth = containerRect.width * 0.8 + + if (newWidth >= minWidth && newWidth <= maxWidth) { + setChatWidth(newWidth) + } + }, []) + + const handleMouseUp = useCallback(() => { + isResizing.current = false + document.body.style.cursor = '' + document.body.style.userSelect = '' + }, []) + + React.useEffect(() => { + if (chatDisplayMode === 'side') { + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + + return () => { + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + } + } + }, [chatDisplayMode, handleMouseMove, handleMouseUp]) + + return ( + <> + + {children} + {isChatOpen && chatDisplayMode === 'side' && ( + <> + + + + {activeView === 'settings' && ( + setView('settings', s)} + isAuthEnabled={isAuthEnabled} + selectedModel={selectedModel} + setSelectedModel={setSelectedModel} + customPrompt={customPrompt} + onCustomPromptChange={handleCustomPromptChange} + savedPrompts={savedPrompts} + activePromptId={activePromptId} + onPromptSelect={handlePromptSelect} + onSavePrompt={handleSavePrompt} + onDeletePrompt={handleDeletePrompt} + /> + )} + {activeView === 'admin' && canAccessAdmin && ( + setView('admin', s)} + /> + )} + {(!activeView || (activeView === 'admin' && !canAccessAdmin)) && ( + <> + {isAuthEnabled ? ( + + ) : ( + <> + {isLoadingHistory ? ( + Loading conversation... + ) : ( + + )} + + )} + {contextNotification && ( + {contextNotification} + )} + + + Model + {getModelDisplayName(selectedModel)} + + {contextDisplay && ( + + {contextDisplay.type} + {contextDisplay.id} + + )} + + + )} + setChatDisplayMode('closed')} + title="Close Assistant" + > + Close + + setChatDisplayMode('fullscreen')} + title="Enter fullscreen" + > + Enter fullscreen + + + + )} + + + {chatDisplayMode === 'fullscreen' && ( + + {isAuthEnabled && isAuthenticated && ( + { + chatHistoryRefreshRef.current = refreshFn + }} + currentContext={contextDisplay} + currentMessageCount={messages.filter(m => m.constructor?.name === 'TextMessage' || m.type === 'TextMessage').length} + /> + )} + + + {activeView === 'settings' && ( + setView('settings', s)} + isAuthEnabled={isAuthEnabled} + selectedModel={selectedModel} + setSelectedModel={setSelectedModel} + customPrompt={customPrompt} + onCustomPromptChange={handleCustomPromptChange} + savedPrompts={savedPrompts} + activePromptId={activePromptId} + onPromptSelect={handlePromptSelect} + onSavePrompt={handleSavePrompt} + onDeletePrompt={handleDeletePrompt} + /> + )} + {activeView === 'admin' && canAccessAdmin && ( + setView('admin', s)} + /> + )} + {(!activeView || (activeView === 'admin' && !canAccessAdmin)) && ( + <> + {isAuthEnabled ? ( + + ) : ( + <> + {isLoadingHistory ? ( + Loading conversation... + ) : ( + + )} + + )} + {contextNotification && ( + {contextNotification} + )} + + + Model + {getModelDisplayName(selectedModel)} + + {contextDisplay && ( + + {contextDisplay.type} + {contextDisplay.id} + + )} + + + )} + setChatDisplayMode('closed')} title="Close Assistant"> + Close + + setChatDisplayMode('side')} title="Exit fullscreen"> + Exit fullscreen + + + + )} + + {!isChatOpen && ( + setChatDisplayMode('side')}> + Ask gnomAD Assistant + + )} + + ) +} diff --git a/browser/src/assistant/components/ChatHistorySidebar.tsx b/browser/src/assistant/components/ChatHistorySidebar.tsx new file mode 100644 index 000000000..a04832272 --- /dev/null +++ b/browser/src/assistant/components/ChatHistorySidebar.tsx @@ -0,0 +1,483 @@ +import React, { useEffect, useState } from 'react' +import styled from 'styled-components' +import { useAuth0 } from '@auth0/auth0-react' +import { Button, PrimaryButton } from '@gnomad/ui' +// @ts-expect-error TS(2307) +import CommentIcon from '@fortawesome/fontawesome-free/svgs/solid/comment-dots.svg' +import { ChatModal } from './ChatModal' + +interface Thread { + threadId: string + title: string | null + updatedAt: string + messageCount: number + contexts: { type: string; id: string }[] +} + +interface ChatHistorySidebarProps { + currentThreadId: string + onNewChat: () => void + onSelectThread: (threadId: string) => void + onRefreshRef?: (refreshFn: () => void) => void + currentContext?: { type: string; id: string } | null + currentMessageCount?: number +} + +const SidebarContainer = styled.div` + width: 320px; + background: #f7f7f7; + border-right: 1px solid #e0e0e0; + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +` + +const SidebarHeader = styled.div` + padding: 16px; + border-bottom: 1px solid #e0e0e0; +` + +const NewChatButton = styled.button` + width: 100%; + padding: 10px 16px; + background: #0d79d0; + color: white; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; + + &:hover { + background: #0a5fa3; + } +` + +const ThreadList = styled.div` + flex: 1; + overflow-y: auto; + padding: 8px; +` + +const ThreadItem = styled.div<{ isActive: boolean }>` + padding: 12px; + margin-bottom: 4px; + border-radius: 6px; + cursor: pointer; + background: ${(props) => (props.isActive ? '#e3f2fd' : 'white')}; + border: 1px solid ${(props) => (props.isActive ? '#90caf9' : '#e0e0e0')}; + transition: all 0.15s; + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 8px; + + &:hover { + background: ${(props) => (props.isActive ? '#e3f2fd' : '#f5f5f5')}; + border-color: ${(props) => (props.isActive ? '#90caf9' : '#d0d0d0')}; + } +` + +const ThreadContent = styled.div` + flex: 1; + min-width: 0; +` + +const DeleteButton = styled.button` + padding: 4px 8px; + background: transparent; + border: 1px solid #e0e0e0; + border-radius: 4px; + color: #666; + font-size: 11px; + cursor: pointer; + transition: all 0.15s; + flex-shrink: 0; + opacity: 0.6; + + &:hover { + background: #fee; + border-color: #d32f2f; + color: #d32f2f; + opacity: 1; + } +` + +const ThreadFeedbackButton = styled.button` + background: none; + border: none; + cursor: pointer; + padding: 4px; + opacity: 0.6; + transition: opacity 0.2s; + flex-shrink: 0; + + &:hover { + opacity: 1; + } + + img { + width: 14px; + height: 14px; + display: block; + filter: invert(50%); + } +` + +const FeedbackTextArea = styled.textarea` + width: 100%; + min-height: 100px; + padding: 8px 12px; + border: 1px solid #e0e0e0; + border-radius: 4px; + font-family: inherit; + font-size: 14px; + resize: vertical; + box-sizing: border-box; + + &:focus { + outline: none; + border-color: #0d79d0; + } +` + +const ThreadTitle = styled.div` + font-size: 13px; + font-weight: 500; + color: #333; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 4px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + line-height: 1.4; +` + +const ThreadMeta = styled.div` + font-size: 11px; + color: #666; + display: flex; + gap: 8px; +` + +const ContextList = styled.div` + margin-top: 8px; + display: flex; + flex-wrap: wrap; + gap: 4px; +` + +const ContextPill = styled.div` + background: #eef; + border: 1px solid #cce; + color: #557; + padding: 2px 6px; + border-radius: 4px; + font-size: 10px; + font-weight: 500; + white-space: nowrap; + + .context-type { + font-weight: 600; + margin-right: 4px; + } +` + +const LoadingState = styled.div` + padding: 20px; + text-align: center; + color: #666; + font-size: 13px; +` + +const formatDate = (dateString: string) => { + const date = new Date(dateString) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMs / 3600000) + const diffDays = Math.floor(diffMs / 86400000) + + if (diffMins < 1) return 'Just now' + if (diffMins < 60) return `${diffMins}m ago` + if (diffHours < 24) return `${diffHours}h ago` + if (diffDays < 7) return `${diffDays}d ago` + return date.toLocaleDateString() +} + +export function ChatHistorySidebar({ + currentThreadId, + onNewChat, + onSelectThread, + onRefreshRef, + currentContext, + currentMessageCount = 0, +}: ChatHistorySidebarProps) { + const { getAccessTokenSilently, isAuthenticated } = useAuth0() + const isAuthEnabled = process.env.REACT_APP_AUTH0_ENABLE === 'true' + const [threads, setThreads] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [feedbackModalThreadId, setFeedbackModalThreadId] = useState(null) + const [feedbackText, setFeedbackText] = useState('') + const [isSubmittingFeedback, setIsSubmittingFeedback] = useState(false) + + // Fetch threads function (extracted so it can be called manually) + const fetchThreads = React.useCallback(async (isInitialLoad = false) => { + if (isAuthEnabled && !isAuthenticated) { + setThreads([]) + setLoading(false) + return + } + + try { + // Only show loading spinner on initial load, not on refreshes + if (isInitialLoad) { + setLoading(true) + } + + const headers: HeadersInit = {} + if (isAuthEnabled) { + const token = await getAccessTokenSilently() + headers.Authorization = `Bearer ${token}` + } + + const response = await fetch('/api/copilotkit/threads?limit=50', { headers }) + if (!response.ok) throw new Error('Failed to fetch threads') + const data = await response.json() + // Filter out empty threads (zombie threads with no messages) + const nonEmptyThreads = data.filter((thread: Thread) => thread.messageCount > 0) + setThreads(nonEmptyThreads) + setError(null) + } catch (err: any) { + setError(err.message) + } finally { + if (isInitialLoad) { + setLoading(false) + } + } + }, [isAuthEnabled, isAuthenticated, getAccessTokenSilently]) + + // Expose refresh function to parent + useEffect(() => { + if (onRefreshRef) { + // Parent refreshes should be silent (no loading state) + onRefreshRef(() => fetchThreads(false)) + } + }, [onRefreshRef, fetchThreads]) + + // Auto-fetch on mount and when dependencies change + useEffect(() => { + // Initial load + fetchThreads(true) + + // Refresh every 30 seconds (without loading state) + const interval = setInterval(() => fetchThreads(false), 30000) + return () => clearInterval(interval) + }, [fetchThreads]) + + // Silently refresh when currentThreadId changes + useEffect(() => { + if (currentThreadId) { + fetchThreads(false) + } + }, [currentThreadId, fetchThreads]) + + // Get unique contexts, prioritizing the most recent ones + const getUniqueContexts = (contexts: { type: string; id: string }[] = []) => { + if (!contexts) return [] + const unique: { [key: string]: { type: string; id: string } } = {} + // Iterate backwards to get the most recent unique contexts + for (let i = contexts.length - 1; i >= 0; i--) { + const key = `${contexts[i].type}:${contexts[i].id}` + if (!unique[key]) { + unique[key] = contexts[i] + } + } + return Object.values(unique).reverse().slice(0, 3) // Show max 3 contexts + } + + // Create optimistic thread list - include current thread if not in fetched list + const displayThreads = React.useMemo(() => { + // Check if current thread exists in fetched threads + const existingThread = threads.find(t => t.threadId === currentThreadId) + + // If current thread doesn't exist and we have a threadId, create optimistic entry + if (!existingThread && currentThreadId && currentContext) { + const optimisticThread: Thread = { + threadId: currentThreadId, + title: `Chat about ${currentContext.id}`, + updatedAt: new Date().toISOString(), + messageCount: currentMessageCount, + contexts: [currentContext] + } + // Put optimistic thread at the top + return [optimisticThread, ...threads] + } + + // If thread exists but has outdated message count, update it + if (existingThread && currentMessageCount > existingThread.messageCount) { + const updatedThreads = threads.map(t => + t.threadId === currentThreadId + ? { ...t, messageCount: currentMessageCount } + : t + ) + return updatedThreads + } + + return threads + }, [threads, currentThreadId, currentContext, currentMessageCount]) + + const handleFeedbackSubmit = async () => { + if (!feedbackText.trim() || !feedbackModalThreadId) return + + setIsSubmittingFeedback(true) + try { + const headers: Record = { + 'Content-Type': 'application/json', + } + if (isAuthEnabled && isAuthenticated) { + const token = await getAccessTokenSilently() + headers.Authorization = `Bearer ${token}` + } + await fetch('/api/copilotkit/feedback', { + method: 'POST', + headers, + body: JSON.stringify({ + threadId: feedbackModalThreadId, + source: 'thread', + feedbackText, + }), + }) + setFeedbackModalThreadId(null) + setFeedbackText('') + } catch (error) { + console.error('Failed to submit thread feedback:', error) + alert('Failed to submit feedback. Please try again.') + } finally { + setIsSubmittingFeedback(false) + } + } + + // Handle thread deletion + const handleDeleteThread = async (threadId: string, e: React.MouseEvent) => { + // Prevent the click from bubbling up to the thread item + e.stopPropagation() + + if (!confirm('Are you sure you want to delete this conversation?')) { + return + } + + try { + const headers: HeadersInit = {} + if (isAuthEnabled) { + const token = await getAccessTokenSilently() + headers.Authorization = `Bearer ${token}` + } + + const response = await fetch(`/api/copilotkit/threads/${threadId}`, { + method: 'DELETE', + headers, + }) + + if (!response.ok) throw new Error('Failed to delete thread') + + // Remove the thread from the local state + setThreads(threads.filter((t) => t.threadId !== threadId)) + + // If we deleted the current thread, start a new chat + if (threadId === currentThreadId) { + onNewChat() + } + } catch (err: any) { + console.error('Failed to delete thread:', err) + alert('Failed to delete conversation. Please try again.') + } + } + + return ( + + + + New Chat + + + + {loading && Loading history...} + + {error && Error: {error}} + + {!loading && !error && displayThreads.length === 0 && ( + No chat history yet + )} + + {displayThreads.map((thread) => ( + onSelectThread(thread.threadId)} + > + + {thread.title || 'New conversation'} + + {thread.messageCount} messages + {formatDate(thread.updatedAt)} + + + {getUniqueContexts(thread.contexts).map((context) => ( + + {context.type} + {context.id} + + ))} + + +
+ handleDeleteThread(thread.threadId, e)} + title="Delete conversation" + > + Delete + + { + e.stopPropagation() + setFeedbackModalThreadId(thread.threadId) + }} + title="Provide feedback on this conversation" + > + Feedback + +
+
+ ))} +
+ {feedbackModalThreadId && ( + setFeedbackModalThreadId(null)} + footer={ + <> + + + {isSubmittingFeedback ? 'Submitting...' : 'Submit'} + + + } + > + setFeedbackText(e.target.value)} + placeholder="Tell us what you think about this conversation..." + autoFocus + /> + + )} +
+ ) +} diff --git a/browser/src/assistant/components/ChatModal.tsx b/browser/src/assistant/components/ChatModal.tsx new file mode 100644 index 000000000..4dc20c913 --- /dev/null +++ b/browser/src/assistant/components/ChatModal.tsx @@ -0,0 +1,201 @@ +import React, { useEffect, useRef } from 'react' +import { createPortal } from 'react-dom' +import styled from 'styled-components' +import { Button } from '@gnomad/ui' + +// Styled components matching @gnomad/ui modal styles +const ModalBody = styled.div` + padding: 1rem; +` + +const mediumScreenMaxWidth = { + small: 300, + medium: 500, + large: 500, + xlarge: 500, +} + +const largeScreenMaxWidth = { + small: 300, + medium: 500, + large: 800, + xlarge: 800, +} + +const extraLargeScreenMaxWidth = { + small: 300, + medium: 500, + large: 800, + xlarge: 1360, +} + +const ModalContent = styled.div<{ size: 'small' | 'medium' | 'large' | 'xlarge' }>` + width: calc(100vw - 2em); + border: 1px solid #c8c8c8; + border-radius: 5px; + background: #fafafa; + font-size: 1rem; + max-height: 90vh; + overflow-y: auto; + overflow-x: hidden; + + @media (min-width: 576px) { + max-width: ${(props) => mediumScreenMaxWidth[props.size]}px; + } + @media (min-width: 992px) { + max-width: ${(props) => largeScreenMaxWidth[props.size]}px; + } + @media (min-width: 1400px) { + max-width: ${(props) => extraLargeScreenMaxWidth[props.size]}px; + } +` + +const ModalFooter = styled.footer` + display: flex; + justify-content: flex-end; + padding: 1rem; + border-top: 1px solid #e9ecef; +` + +const ModalHeader = styled.header` + display: flex; + flex-direction: row; + justify-content: space-between; + padding: 1rem; + border-bottom: 1px solid #e9ecef; +` + +const ModalTitle = styled.h2` + margin: 0; +` + +const ModalHeaderCloseButton = styled.button` + padding: 1rem; + border: none; + margin: -1rem -1rem -1rem auto; + appearance: none; + background: none; + color: #0008; + cursor: pointer; + font-size: 16px; + + &:focus { + color: #000; + } + + &:hover { + color: #000; + } +` + +// Overlay that covers the entire chat area +const Overlay = styled.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 100000; + padding: 2em 0; + box-sizing: border-box; + overflow-y: auto; +` + +interface ChatModalProps { + id?: string + title: string + children: React.ReactNode + footer?: React.ReactNode + onRequestClose: () => void + size?: 'small' | 'medium' | 'large' | 'xlarge' +} + +let nextId = 0 +function getId() { + const id = `${nextId}` + nextId += 1 + return id +} + +/** + * Custom Modal component that renders within the chat container + * using a portal. This ensures modals appear correctly when the + * chat is in fullscreen or side panel mode. + */ +export const ChatModal: React.FC = ({ + id, + title, + children, + footer, + onRequestClose, + size = 'medium', +}) => { + const modalId = id || `chat-modal-${getId()}` + const modalRef = useRef(null) + + const renderedFooter = + footer === undefined ? : footer + + // Handle escape key + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onRequestClose() + } + } + + document.addEventListener('keydown', handleEscape) + return () => document.removeEventListener('keydown', handleEscape) + }, [onRequestClose]) + + // Focus the modal when it opens + useEffect(() => { + if (modalRef.current) { + modalRef.current.focus() + } + }, []) + + // Handle click outside to close + const handleOverlayClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onRequestClose() + } + } + + const modalContent = ( + + + + {title} + + + + + {children} + {renderedFooter && {renderedFooter}} + + + ) + + // Render directly into document.body using a portal + // This ensures the modal appears above everything + return createPortal(modalContent, document.body) +} diff --git a/browser/src/assistant/components/CustomAssistantMessage.tsx b/browser/src/assistant/components/CustomAssistantMessage.tsx new file mode 100644 index 000000000..78402f2bb --- /dev/null +++ b/browser/src/assistant/components/CustomAssistantMessage.tsx @@ -0,0 +1,144 @@ +import React, { useState, useEffect, useRef } from 'react' +import { AssistantMessage, AssistantMessageProps } from '@copilotkit/react-ui' +import { useAuth0 } from '@auth0/auth0-react' +import { Button, PrimaryButton } from '@gnomad/ui' +import styled from 'styled-components' +import { ChatModal } from './ChatModal' + +const TextArea = styled.textarea` + width: 100%; + min-height: 100px; + padding: 8px 12px 8px 8px; + border: 1px solid #e0e0e0; + border-radius: 4px; + font-family: inherit; + font-size: 14px; + resize: vertical; + box-sizing: border-box; + + &:focus { + outline: none; + border-color: #0d79d0; + } +` + +const MessageWrapper = styled.div` + position: relative; +` + +interface CustomAssistantMessageProps extends AssistantMessageProps { + threadId?: string +} + +export const CustomAssistantMessage: React.FC = (props) => { + const [isFeedbackModalOpen, setIsFeedbackModalOpen] = useState(false) + const [feedbackText, setFeedbackText] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + const { getAccessTokenSilently, isAuthenticated } = useAuth0() + const wrapperRef = useRef(null) + + const handleOpenFeedbackModal = () => { + setIsFeedbackModalOpen(true) + } + + const handleFeedbackSubmit = async () => { + if (!feedbackText.trim()) return + + setIsSubmitting(true) + try { + const headers: Record = { + 'Content-Type': 'application/json', + } + + // Add authorization header if authenticated + if (isAuthenticated) { + try { + const token = await getAccessTokenSilently() + headers.Authorization = `Bearer ${token}` + } catch (error) { + console.warn('Failed to get access token, submitting as anonymous', error) + } + } + + await fetch('/api/copilotkit/feedback', { + method: 'POST', + headers, + body: JSON.stringify({ + messageId: props.message?.id, + threadId: props.threadId, + source: 'message', + feedbackText, + }), + }) + setIsFeedbackModalOpen(false) + setFeedbackText('') + } catch (error) { + console.error('Failed to submit feedback:', error) + alert('Failed to submit feedback. Please try again.') + } finally { + setIsSubmitting(false) + } + } + + // Add click handlers to thumbs up/down buttons to open feedback modal + useEffect(() => { + if (!wrapperRef.current) return + + const thumbsUpButton = wrapperRef.current.querySelector('button[aria-label="Thumbs up"]') + const thumbsDownButton = wrapperRef.current.querySelector('button[aria-label="Thumbs down"]') + + const handleThumbsClick = (e: Event) => { + // Let the original click handler run first + setTimeout(() => { + setIsFeedbackModalOpen(true) + }, 0) + } + + if (thumbsUpButton) { + thumbsUpButton.addEventListener('click', handleThumbsClick) + } + if (thumbsDownButton) { + thumbsDownButton.addEventListener('click', handleThumbsClick) + } + + return () => { + if (thumbsUpButton) { + thumbsUpButton.removeEventListener('click', handleThumbsClick) + } + if (thumbsDownButton) { + thumbsDownButton.removeEventListener('click', handleThumbsClick) + } + } + }, [props.message?.id]) // Re-run when message changes + + return ( + + + + {isFeedbackModalOpen && ( + setIsFeedbackModalOpen(false)} + footer={ + <> + + + {isSubmitting ? 'Submitting...' : 'Submit'} + + + } + > +