diff --git a/CHANGELOG.md b/CHANGELOG.md index 1262b27db..0e164a5ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `wa_user_created` PostHog event fired on successful user sign-up. [#933](https://github.com/sourcebot-dev/sourcebot/pull/933) - Added `wa_askgh_login_wall_prompted` PostHog event fired when an unauthenticated user attempts to ask a question on Ask GitHub. [#933](https://github.com/sourcebot-dev/sourcebot/pull/933) - Added Bitbucket Server (Data Center) OAuth 2.0 SSO identity provider support (`provider: "bitbucket-server"`). [#934](https://github.com/sourcebot-dev/sourcebot/pull/934) +- Added login wall when anonymous users try to send messages on duplicated chats (askgh experiment). [#939](https://github.com/sourcebot-dev/sourcebot/pull/939) - Added `GET /api/ee/user` endpoint that returns the authenticated owner's user info (name, email, createdAt, updatedAt). [#940](https://github.com/sourcebot-dev/sourcebot/pull/940) - Added `selectedReposCount` to the `wa_chat_message_sent` PostHog event to track the number of selected repositories when users ask questions. [#941](https://github.com/sourcebot-dev/sourcebot/pull/941) diff --git a/packages/web/src/app/[domain]/askgh/[owner]/[repo]/components/landingPage.tsx b/packages/web/src/app/[domain]/askgh/[owner]/[repo]/components/landingPage.tsx index d54bb030c..8a6aabf02 100644 --- a/packages/web/src/app/[domain]/askgh/[owner]/[repo]/components/landingPage.tsx +++ b/packages/web/src/app/[domain]/askgh/[owner]/[repo]/components/landingPage.tsx @@ -5,18 +5,12 @@ import { SearchModeSelector } from "@/app/[domain]/components/searchModeSelector import { Separator } from "@/components/ui/separator"; import { ChatBox } from "@/features/chat/components/chatBox"; import { ChatBoxToolbar } from "@/features/chat/components/chatBox/chatBoxToolbar"; -import { LoginModal } from "./loginModal"; +import { LoginModal } from "@/app/components/loginModal"; import { NotConfiguredErrorBanner } from "@/features/chat/components/notConfiguredErrorBanner"; import { LanguageModelInfo, RepoSearchScope } from "@/features/chat/types"; import { useCreateNewChatThread } from "@/features/chat/useCreateNewChatThread"; import { getRepoImageSrc } from '@/lib/utils'; -import type { IdentityProviderMetadata } from "@/lib/identityProviders"; -import { Descendant, Transforms } from "slate"; -import { useSlate } from "slate-react"; -import { useCallback, useEffect, useMemo, useState, useRef } from "react"; -import { captureEvent } from "@/hooks/useCaptureEvent"; - -const PENDING_MESSAGE_KEY = "askgh_pending_message"; +import { useMemo, useState } from "react"; interface LandingPageProps { languageModels: LanguageModelInfo[]; @@ -24,7 +18,6 @@ interface LandingPageProps { repoDisplayName?: string; imageUrl?: string | null; repoId: number; - providers: IdentityProviderMetadata[]; isAuthenticated: boolean; } @@ -34,14 +27,10 @@ export const LandingPage = ({ repoDisplayName, imageUrl, repoId, - providers, isAuthenticated, }: LandingPageProps) => { - const editor = useSlate(); - const { createNewChatThread, isLoading } = useCreateNewChatThread(); + const { createNewChatThread, isLoading, loginWall } = useCreateNewChatThread({ isAuthenticated }); const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false); - const [isLoginModalOpen, setIsLoginModalOpen] = useState(false); - const hasRestoredPendingMessage = useRef(false); const isChatBoxDisabled = languageModels.length === 0; const selectedSearchScopes = useMemo(() => [ @@ -53,45 +42,6 @@ export const LandingPage = ({ } satisfies RepoSearchScope, ], [repoDisplayName, repoName]); - // Intercept submit to check auth status - const handleSubmit = useCallback((children: Descendant[]) => { - if (!isAuthenticated) { - captureEvent('wa_askgh_login_wall_prompted', {}); - // Store message in sessionStorage to survive OAuth redirect - sessionStorage.setItem(PENDING_MESSAGE_KEY, JSON.stringify(children)); - setIsLoginModalOpen(true); - return; - } - createNewChatThread(children, selectedSearchScopes); - }, [isAuthenticated, createNewChatThread, selectedSearchScopes]); - - // Restore pending message to editor and auto-submit after login - useEffect(() => { - if (isAuthenticated && !hasRestoredPendingMessage.current) { - const stored = sessionStorage.getItem(PENDING_MESSAGE_KEY); - if (stored) { - hasRestoredPendingMessage.current = true; - sessionStorage.removeItem(PENDING_MESSAGE_KEY); - try { - const message = JSON.parse(stored) as Descendant[]; - - // Restore the message content to the editor by replacing all nodes - // Remove all existing nodes - while (editor.children.length > 0) { - Transforms.removeNodes(editor, { at: [0] }); - } - // Insert the restored content at the beginning - Transforms.insertNodes(editor, message, { at: [0] }); - - // Allow the UI to render the restored text before auto-submitting - createNewChatThread(message, selectedSearchScopes); - } catch (error) { - console.error('Failed to restore pending message:', error); - } - } - } - }, [isAuthenticated, editor, createNewChatThread, selectedSearchScopes]); - const imageSrc = imageUrl ? getRepoImageSrc(imageUrl, repoId) : undefined; const displayName = repoDisplayName ?? repoName; @@ -119,7 +69,9 @@ export const LandingPage = ({
{ + createNewChatThread(children, selectedSearchScopes); + }} className="min-h-[50px]" isRedirecting={isLoading} languageModels={languageModels} @@ -155,11 +107,11 @@ export const LandingPage = ({
) -} \ No newline at end of file +} diff --git a/packages/web/src/app/[domain]/askgh/[owner]/[repo]/page.tsx b/packages/web/src/app/[domain]/askgh/[owner]/[repo]/page.tsx index ee1ad7d48..e7de26d95 100644 --- a/packages/web/src/app/[domain]/askgh/[owner]/[repo]/page.tsx +++ b/packages/web/src/app/[domain]/askgh/[owner]/[repo]/page.tsx @@ -8,7 +8,6 @@ import { CustomSlateEditor } from "@/features/chat/customSlateEditor"; import { RepoIndexedGuard } from "./components/repoIndexedGuard"; import { LandingPage } from "./components/landingPage"; import { getConfiguredLanguageModelsInfo } from "@/features/chat/actions"; -import { getIdentityProviderMetadata } from "@/lib/identityProviders"; import { auth } from "@/auth"; interface PageProps { @@ -48,7 +47,6 @@ export default async function GitHubRepoPage(props: PageProps) { const repoInfo = await unwrapServiceError(getRepoInfo(repoId)); const languageModels = await unwrapServiceError(getConfiguredLanguageModelsInfo()); - const providers = getIdentityProviderMetadata(); return ( @@ -59,7 +57,6 @@ export default async function GitHubRepoPage(props: PageProps) { repoDisplayName={repoInfo.displayName ?? undefined} imageUrl={repoInfo.imageUrl ?? undefined} repoId={repoInfo.id} - providers={providers} isAuthenticated={!!session?.user} /> diff --git a/packages/web/src/app/[domain]/chat/[id]/page.tsx b/packages/web/src/app/[domain]/chat/[id]/page.tsx index b277dfc1c..67a057926 100644 --- a/packages/web/src/app/[domain]/chat/[id]/page.tsx +++ b/packages/web/src/app/[domain]/chat/[id]/page.tsx @@ -18,7 +18,6 @@ import { ChatVisibility } from '@sourcebot/db'; import { Metadata } from 'next'; import { SBChatMessage } from '@/features/chat/types'; import { env, hasEntitlement } from '@sourcebot/shared'; - import { captureEvent } from '@/lib/posthog'; interface PageProps { diff --git a/packages/web/src/app/[domain]/chat/components/landingPageChatBox.tsx b/packages/web/src/app/[domain]/chat/components/landingPageChatBox.tsx index 75af7dc5f..f7c0742ab 100644 --- a/packages/web/src/app/[domain]/chat/components/landingPageChatBox.tsx +++ b/packages/web/src/app/[domain]/chat/components/landingPageChatBox.tsx @@ -10,19 +10,22 @@ import { useState } from "react"; import { useLocalStorage } from "usehooks-ts"; import { SearchModeSelector } from "../../components/searchModeSelector"; import { NotConfiguredErrorBanner } from "@/features/chat/components/notConfiguredErrorBanner"; +import { LoginModal } from "@/app/components/loginModal"; interface LandingPageChatBox { languageModels: LanguageModelInfo[]; repos: RepositoryQuery[]; searchContexts: SearchContextQuery[]; + isAuthenticated: boolean; } export const LandingPageChatBox = ({ languageModels, repos, searchContexts, + isAuthenticated, }: LandingPageChatBox) => { - const { createNewChatThread, isLoading } = useCreateNewChatThread(); + const { createNewChatThread, isLoading, loginWall } = useCreateNewChatThread({ isAuthenticated }); const [selectedSearchScopes, setSelectedSearchScopes] = useLocalStorage("selectedSearchScopes", [], { initializeWithValue: false }); const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false); const isChatBoxDisabled = languageModels.length === 0; @@ -65,6 +68,13 @@ export const LandingPageChatBox = ({ {isChatBoxDisabled && ( )} + + ) -} \ No newline at end of file +} diff --git a/packages/web/src/app/[domain]/chat/page.tsx b/packages/web/src/app/[domain]/chat/page.tsx index 9ff8bf78c..dd5124cbb 100644 --- a/packages/web/src/app/[domain]/chat/page.tsx +++ b/packages/web/src/app/[domain]/chat/page.tsx @@ -103,6 +103,7 @@ export default async function Page(props: PageProps) { languageModels={languageModels} repos={allRepos} searchContexts={searchContexts} + isAuthenticated={!!session} /> diff --git a/packages/web/src/app/[domain]/askgh/[owner]/[repo]/components/loginModal.tsx b/packages/web/src/app/components/loginModal.tsx similarity index 100% rename from packages/web/src/app/[domain]/askgh/[owner]/[repo]/components/loginModal.tsx rename to packages/web/src/app/components/loginModal.tsx diff --git a/packages/web/src/features/chat/actions.ts b/packages/web/src/features/chat/actions.ts index 4fa872979..86a9292fb 100644 --- a/packages/web/src/features/chat/actions.ts +++ b/packages/web/src/features/chat/actions.ts @@ -981,6 +981,16 @@ export const _getAISDKLanguageModelAndOptions = async (config: LanguageModel): P } +export const getAskGhLoginWallData = async () => sew(async () => { + const isEnabled = env.EXPERIMENT_ASK_GH_ENABLED === 'true'; + if (!isEnabled) { + return { isEnabled: false as const, providers: [] }; + } + + const { getIdentityProviderMetadata } = await import('@/lib/identityProviders'); + return { isEnabled: true as const, providers: getIdentityProviderMetadata() }; +}); + const extractLanguageModelKeyValuePairs = async ( pairs: { [k: string]: string | Token; diff --git a/packages/web/src/features/chat/components/chatThread/chatThread.tsx b/packages/web/src/features/chat/components/chatThread/chatThread.tsx index e1f5e6424..8ce8fcf04 100644 --- a/packages/web/src/features/chat/components/chatThread/chatThread.tsx +++ b/packages/web/src/features/chat/components/chatThread/chatThread.tsx @@ -28,12 +28,17 @@ import { NotConfiguredErrorBanner } from '../notConfiguredErrorBanner'; import useCaptureEvent from '@/hooks/useCaptureEvent'; import { SignInPromptBanner } from './signInPromptBanner'; import { DuplicateChatDialog } from '@/app/[domain]/chat/components/duplicateChatDialog'; +import { LoginModal } from '@/app/components/loginModal'; +import type { IdentityProviderMetadata } from '@/lib/identityProviders'; +import { getAskGhLoginWallData } from '../../actions'; import { useParams } from 'next/navigation'; type ChatHistoryState = { scrollOffset?: number; } +const PENDING_MESSAGE_STORAGE_KEY = "askgh_chat_pending_message"; + interface ChatThreadProps { id?: string | undefined; initialMessages?: SBChatMessage[]; @@ -71,6 +76,9 @@ export const ChatThread = ({ const params = useParams<{ domain: string }>(); const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false); const [isDuplicateDialogOpen, setIsDuplicateDialogOpen] = useState(false); + const [isLoginModalOpen, setIsLoginModalOpen] = useState(false); + const [loginWallProviders, setLoginWallProviders] = useState([]); + const hasRestoredPendingMessage = useRef(false); const captureEvent = useCaptureEvent(); // Initial state is from attachments that exist in in the chat history. @@ -200,6 +208,38 @@ export const ChatThread = ({ hasSubmittedInputMessage.current = true; }, [inputMessage, sendMessage]); + // Restore pending message after OAuth redirect (askgh login wall) + useEffect(() => { + if (!isAuthenticated || !isOwner || hasRestoredPendingMessage.current) { + return; + } + + const stored = sessionStorage.getItem(PENDING_MESSAGE_STORAGE_KEY); + if (!stored) { + return; + } + + hasRestoredPendingMessage.current = true; + sessionStorage.removeItem(PENDING_MESSAGE_STORAGE_KEY); + + try { + const { chatId: storedChatId, children } = JSON.parse(stored) as { chatId: string; children: Descendant[] }; + + // Only restore if we're on the same chat that stored the pending message + if (storedChatId !== chatId) { + return; + } + + const text = slateContentToString(children); + const mentions = getAllMentionElements(children); + const message = createUIMessage(text, mentions.map(({ data }) => data), selectedSearchScopes); + sendMessage(message); + setIsAutoScrollEnabled(true); + } catch (error) { + console.error('Failed to restore pending message:', error); + } + }, [isAuthenticated, isOwner, chatId, sendMessage, selectedSearchScopes]); + // Track scroll position changes. useEffect(() => { const scrollElement = scrollAreaRef.current?.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement; @@ -287,7 +327,18 @@ export const ChatThread = ({ } }, [error]); - const onSubmit = useCallback((children: Descendant[], editor: CustomEditor) => { + const onSubmit = useCallback(async (children: Descendant[], editor: CustomEditor) => { + if (!isAuthenticated) { + const result = await getAskGhLoginWallData(); + if (!isServiceError(result) && result.isEnabled) { + captureEvent('wa_askgh_login_wall_prompted', {}); + sessionStorage.setItem(PENDING_MESSAGE_STORAGE_KEY, JSON.stringify({ chatId, children })); + setLoginWallProviders(result.providers); + setIsLoginModalOpen(true); + return; + } + } + const text = slateContentToString(children); const mentions = getAllMentionElements(children); @@ -297,7 +348,7 @@ export const ChatThread = ({ setIsAutoScrollEnabled(true); resetEditor(editor); - }, [sendMessage, selectedSearchScopes]); + }, [sendMessage, selectedSearchScopes, isAuthenticated, captureEvent, chatId]); const onDuplicate = useCallback(async (newName: string): Promise => { if (!defaultChatId) { @@ -449,6 +500,13 @@ export const ChatThread = ({ )} + + ); } diff --git a/packages/web/src/features/chat/useCreateNewChatThread.ts b/packages/web/src/features/chat/useCreateNewChatThread.ts index 2e50fea98..d9af1c9de 100644 --- a/packages/web/src/features/chat/useCreateNewChatThread.ts +++ b/packages/web/src/features/chat/useCreateNewChatThread.ts @@ -1,28 +1,39 @@ 'use client'; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Descendant } from "slate"; import { createUIMessage, getAllMentionElements } from "./utils"; import { slateContentToString } from "./utils"; import { useToast } from "@/components/hooks/use-toast"; import { useRouter } from "next/navigation"; -import { createChat } from "./actions"; +import { createChat, getAskGhLoginWallData } from "./actions"; import { isServiceError } from "@/lib/utils"; import { createPathWithQueryParams } from "@/lib/utils"; import { SearchScope, SET_CHAT_STATE_SESSION_STORAGE_KEY, SetChatStatePayload } from "./types"; import { useSessionStorage } from "usehooks-ts"; import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"; +import type { IdentityProviderMetadata } from "@/lib/identityProviders"; +import useCaptureEvent from "@/hooks/useCaptureEvent"; -export const useCreateNewChatThread = () => { +const PENDING_NEW_CHAT_KEY = "askgh_pending_new_chat"; + +interface UseCreateNewChatThreadOptions { + isAuthenticated?: boolean; +} + +export const useCreateNewChatThread = ({ isAuthenticated = false }: UseCreateNewChatThreadOptions = {}) => { const [isLoading, setIsLoading] = useState(false); const { toast } = useToast(); const router = useRouter(); const [, setChatState] = useSessionStorage(SET_CHAT_STATE_SESSION_STORAGE_KEY, null); + const [loginWallState, setLoginWallState] = useState<{ isOpen: boolean; providers: IdentityProviderMetadata[] }>({ isOpen: false, providers: [] }); + const hasRestoredPendingMessage = useRef(false); + const captureEvent = useCaptureEvent(); - const createNewChatThread = useCallback(async (children: Descendant[], selectedSearchScopes: SearchScope[]) => { + const doCreateChat = useCallback(async (children: Descendant[], selectedSearchScopes: SearchScope[]) => { const text = slateContentToString(children); const mentions = getAllMentionElements(children); - + const inputMessage = createUIMessage(text, mentions.map((mention) => mention.data), selectedSearchScopes); setIsLoading(true); @@ -46,8 +57,52 @@ export const useCreateNewChatThread = () => { router.refresh(); }, [router, toast, setChatState]); + const createNewChatThread = useCallback(async (children: Descendant[], selectedSearchScopes: SearchScope[]) => { + if (!isAuthenticated) { + const result = await getAskGhLoginWallData(); + if (!isServiceError(result) && result.isEnabled) { + captureEvent('wa_askgh_login_wall_prompted', {}); + sessionStorage.setItem(PENDING_NEW_CHAT_KEY, JSON.stringify({ children, selectedSearchScopes })); + setLoginWallState({ isOpen: true, providers: result.providers }); + return; + } + } + + doCreateChat(children, selectedSearchScopes); + }, [isAuthenticated, captureEvent, doCreateChat]); + + // Restore pending message after OAuth redirect + useEffect(() => { + if (!isAuthenticated || hasRestoredPendingMessage.current) { + return; + } + + const stored = sessionStorage.getItem(PENDING_NEW_CHAT_KEY); + if (!stored) { + return; + } + + hasRestoredPendingMessage.current = true; + sessionStorage.removeItem(PENDING_NEW_CHAT_KEY); + + try { + const { children, selectedSearchScopes } = JSON.parse(stored) as { + children: Descendant[]; + selectedSearchScopes: SearchScope[]; + }; + doCreateChat(children, selectedSearchScopes); + } catch (error) { + console.error('Failed to restore pending message:', error); + } + }, [isAuthenticated, doCreateChat]); + return { createNewChatThread, isLoading, + loginWall: { + isOpen: loginWallState.isOpen, + providers: loginWallState.providers, + onOpenChange: (open: boolean) => setLoginWallState(prev => ({ ...prev, isOpen: open })), + }, }; -} \ No newline at end of file +} diff --git a/packages/web/src/features/git/listCommitsApi.test.ts b/packages/web/src/features/git/listCommitsApi.test.ts index 3cc332339..39fbf7529 100644 --- a/packages/web/src/features/git/listCommitsApi.test.ts +++ b/packages/web/src/features/git/listCommitsApi.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { listCommits } from './listCommitsApi'; import * as dateUtils from './dateUtils'; @@ -63,8 +63,8 @@ describe('searchCommits', () => { const mockGitLog = vi.fn(); const mockGitRaw = vi.fn(); const mockCwd = vi.fn(); - const mockSimpleGit = simpleGit as unknown as vi.Mock; - const mockExistsSync = existsSync as unknown as vi.Mock; + const mockSimpleGit = simpleGit as unknown as Mock; + const mockExistsSync = existsSync as unknown as Mock; beforeEach(() => { vi.clearAllMocks(); diff --git a/packages/web/src/features/git/utils.test.ts b/packages/web/src/features/git/utils.test.ts index 787144efc..e4aa5f8e2 100644 --- a/packages/web/src/features/git/utils.test.ts +++ b/packages/web/src/features/git/utils.test.ts @@ -1,4 +1,14 @@ -import { expect, test } from 'vitest'; +import { expect, test, vi } from 'vitest'; + +vi.mock('@sourcebot/shared', () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + import { buildFileTree, isPathValid, normalizePath } from './utils'; test('normalizePath adds a trailing slash and strips leading slashes', () => {