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', () => {