Skip to content

Commit 808dc94

Browse files
msukkariclaude
andauthored
feat(web): show login wall on duplicated chat for askgh (#939)
* feat(web): show login wall when anonymous user sends message on duplicated chat When EXPERIMENT_ASK_GH_ENABLED is true, anonymous users who duplicate a public chat and try to send a follow-up message are now shown a login modal instead of sending the message. After OAuth redirect, the pending message is automatically restored and submitted. Also fixes vi.Mock type errors in listCommitsApi.test.ts by importing Mock type directly from vitest. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: add CHANGELOG entry for #939 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(web): mock @sourcebot/shared in utils.test.ts to fix server env error The test was failing because importing utils.ts transitively pulled in server-side environment variables via createLogger, which crashes in the jsdom test environment. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(web): move askgh login wall into useCreateNewChatThread hook Centralizes the login wall logic in the hook so all chat creation entry points are guarded automatically. This adds the login wall to the general /chat landing page and removes the duplicate manual logic from the askgh landing page. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 564480d commit 808dc94

File tree

12 files changed

+169
-76
lines changed

12 files changed

+169
-76
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
- Added `wa_user_created` PostHog event fired on successful user sign-up. [#933](https://github.com/sourcebot-dev/sourcebot/pull/933)
1515
- 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)
1616
- Added Bitbucket Server (Data Center) OAuth 2.0 SSO identity provider support (`provider: "bitbucket-server"`). [#934](https://github.com/sourcebot-dev/sourcebot/pull/934)
17+
- Added login wall when anonymous users try to send messages on duplicated chats (askgh experiment). [#939](https://github.com/sourcebot-dev/sourcebot/pull/939)
1718
- 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)
1819
- 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)
1920

packages/web/src/app/[domain]/askgh/[owner]/[repo]/components/landingPage.tsx

Lines changed: 10 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,19 @@ import { SearchModeSelector } from "@/app/[domain]/components/searchModeSelector
55
import { Separator } from "@/components/ui/separator";
66
import { ChatBox } from "@/features/chat/components/chatBox";
77
import { ChatBoxToolbar } from "@/features/chat/components/chatBox/chatBoxToolbar";
8-
import { LoginModal } from "./loginModal";
8+
import { LoginModal } from "@/app/components/loginModal";
99
import { NotConfiguredErrorBanner } from "@/features/chat/components/notConfiguredErrorBanner";
1010
import { LanguageModelInfo, RepoSearchScope } from "@/features/chat/types";
1111
import { useCreateNewChatThread } from "@/features/chat/useCreateNewChatThread";
1212
import { getRepoImageSrc } from '@/lib/utils';
13-
import type { IdentityProviderMetadata } from "@/lib/identityProviders";
14-
import { Descendant, Transforms } from "slate";
15-
import { useSlate } from "slate-react";
16-
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
17-
import { captureEvent } from "@/hooks/useCaptureEvent";
18-
19-
const PENDING_MESSAGE_KEY = "askgh_pending_message";
13+
import { useMemo, useState } from "react";
2014

2115
interface LandingPageProps {
2216
languageModels: LanguageModelInfo[];
2317
repoName: string;
2418
repoDisplayName?: string;
2519
imageUrl?: string | null;
2620
repoId: number;
27-
providers: IdentityProviderMetadata[];
2821
isAuthenticated: boolean;
2922
}
3023

@@ -34,14 +27,10 @@ export const LandingPage = ({
3427
repoDisplayName,
3528
imageUrl,
3629
repoId,
37-
providers,
3830
isAuthenticated,
3931
}: LandingPageProps) => {
40-
const editor = useSlate();
41-
const { createNewChatThread, isLoading } = useCreateNewChatThread();
32+
const { createNewChatThread, isLoading, loginWall } = useCreateNewChatThread({ isAuthenticated });
4233
const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false);
43-
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
44-
const hasRestoredPendingMessage = useRef(false);
4534
const isChatBoxDisabled = languageModels.length === 0;
4635

4736
const selectedSearchScopes = useMemo(() => [
@@ -53,45 +42,6 @@ export const LandingPage = ({
5342
} satisfies RepoSearchScope,
5443
], [repoDisplayName, repoName]);
5544

56-
// Intercept submit to check auth status
57-
const handleSubmit = useCallback((children: Descendant[]) => {
58-
if (!isAuthenticated) {
59-
captureEvent('wa_askgh_login_wall_prompted', {});
60-
// Store message in sessionStorage to survive OAuth redirect
61-
sessionStorage.setItem(PENDING_MESSAGE_KEY, JSON.stringify(children));
62-
setIsLoginModalOpen(true);
63-
return;
64-
}
65-
createNewChatThread(children, selectedSearchScopes);
66-
}, [isAuthenticated, createNewChatThread, selectedSearchScopes]);
67-
68-
// Restore pending message to editor and auto-submit after login
69-
useEffect(() => {
70-
if (isAuthenticated && !hasRestoredPendingMessage.current) {
71-
const stored = sessionStorage.getItem(PENDING_MESSAGE_KEY);
72-
if (stored) {
73-
hasRestoredPendingMessage.current = true;
74-
sessionStorage.removeItem(PENDING_MESSAGE_KEY);
75-
try {
76-
const message = JSON.parse(stored) as Descendant[];
77-
78-
// Restore the message content to the editor by replacing all nodes
79-
// Remove all existing nodes
80-
while (editor.children.length > 0) {
81-
Transforms.removeNodes(editor, { at: [0] });
82-
}
83-
// Insert the restored content at the beginning
84-
Transforms.insertNodes(editor, message, { at: [0] });
85-
86-
// Allow the UI to render the restored text before auto-submitting
87-
createNewChatThread(message, selectedSearchScopes);
88-
} catch (error) {
89-
console.error('Failed to restore pending message:', error);
90-
}
91-
}
92-
}
93-
}, [isAuthenticated, editor, createNewChatThread, selectedSearchScopes]);
94-
9545
const imageSrc = imageUrl ? getRepoImageSrc(imageUrl, repoId) : undefined;
9646
const displayName = repoDisplayName ?? repoName;
9747

@@ -119,7 +69,9 @@ export const LandingPage = ({
11969
<div className="w-full max-w-[800px]">
12070
<div className="border rounded-md w-full shadow-sm">
12171
<ChatBox
122-
onSubmit={handleSubmit}
72+
onSubmit={(children) => {
73+
createNewChatThread(children, selectedSearchScopes);
74+
}}
12375
className="min-h-[50px]"
12476
isRedirecting={isLoading}
12577
languageModels={languageModels}
@@ -155,11 +107,11 @@ export const LandingPage = ({
155107
</div>
156108

157109
<LoginModal
158-
isOpen={isLoginModalOpen}
159-
onOpenChange={setIsLoginModalOpen}
160-
providers={providers}
110+
isOpen={loginWall.isOpen}
111+
onOpenChange={loginWall.onOpenChange}
112+
providers={loginWall.providers}
161113
callbackUrl={typeof window !== 'undefined' ? window.location.href : ''}
162114
/>
163115
</div>
164116
)
165-
}
117+
}

packages/web/src/app/[domain]/askgh/[owner]/[repo]/page.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import { CustomSlateEditor } from "@/features/chat/customSlateEditor";
88
import { RepoIndexedGuard } from "./components/repoIndexedGuard";
99
import { LandingPage } from "./components/landingPage";
1010
import { getConfiguredLanguageModelsInfo } from "@/features/chat/actions";
11-
import { getIdentityProviderMetadata } from "@/lib/identityProviders";
1211
import { auth } from "@/auth";
1312

1413
interface PageProps {
@@ -48,7 +47,6 @@ export default async function GitHubRepoPage(props: PageProps) {
4847

4948
const repoInfo = await unwrapServiceError(getRepoInfo(repoId));
5049
const languageModels = await unwrapServiceError(getConfiguredLanguageModelsInfo());
51-
const providers = getIdentityProviderMetadata();
5250

5351
return (
5452
<RepoIndexedGuard initialRepoInfo={repoInfo}>
@@ -59,7 +57,6 @@ export default async function GitHubRepoPage(props: PageProps) {
5957
repoDisplayName={repoInfo.displayName ?? undefined}
6058
imageUrl={repoInfo.imageUrl ?? undefined}
6159
repoId={repoInfo.id}
62-
providers={providers}
6360
isAuthenticated={!!session?.user}
6461
/>
6562
</CustomSlateEditor>

packages/web/src/app/[domain]/chat/[id]/page.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import { ChatVisibility } from '@sourcebot/db';
1818
import { Metadata } from 'next';
1919
import { SBChatMessage } from '@/features/chat/types';
2020
import { env, hasEntitlement } from '@sourcebot/shared';
21-
2221
import { captureEvent } from '@/lib/posthog';
2322

2423
interface PageProps {

packages/web/src/app/[domain]/chat/components/landingPageChatBox.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,22 @@ import { useState } from "react";
1010
import { useLocalStorage } from "usehooks-ts";
1111
import { SearchModeSelector } from "../../components/searchModeSelector";
1212
import { NotConfiguredErrorBanner } from "@/features/chat/components/notConfiguredErrorBanner";
13+
import { LoginModal } from "@/app/components/loginModal";
1314

1415
interface LandingPageChatBox {
1516
languageModels: LanguageModelInfo[];
1617
repos: RepositoryQuery[];
1718
searchContexts: SearchContextQuery[];
19+
isAuthenticated: boolean;
1820
}
1921

2022
export const LandingPageChatBox = ({
2123
languageModels,
2224
repos,
2325
searchContexts,
26+
isAuthenticated,
2427
}: LandingPageChatBox) => {
25-
const { createNewChatThread, isLoading } = useCreateNewChatThread();
28+
const { createNewChatThread, isLoading, loginWall } = useCreateNewChatThread({ isAuthenticated });
2629
const [selectedSearchScopes, setSelectedSearchScopes] = useLocalStorage<SearchScope[]>("selectedSearchScopes", [], { initializeWithValue: false });
2730
const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false);
2831
const isChatBoxDisabled = languageModels.length === 0;
@@ -65,6 +68,13 @@ export const LandingPageChatBox = ({
6568
{isChatBoxDisabled && (
6669
<NotConfiguredErrorBanner className="mt-4" />
6770
)}
71+
72+
<LoginModal
73+
isOpen={loginWall.isOpen}
74+
onOpenChange={loginWall.onOpenChange}
75+
providers={loginWall.providers}
76+
callbackUrl={typeof window !== 'undefined' ? window.location.href : ''}
77+
/>
6878
</div >
6979
)
70-
}
80+
}

packages/web/src/app/[domain]/chat/page.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ export default async function Page(props: PageProps) {
103103
languageModels={languageModels}
104104
repos={allRepos}
105105
searchContexts={searchContexts}
106+
isAuthenticated={!!session}
106107
/>
107108
</CustomSlateEditor>
108109

packages/web/src/app/[domain]/askgh/[owner]/[repo]/components/loginModal.tsx renamed to packages/web/src/app/components/loginModal.tsx

File renamed without changes.

packages/web/src/features/chat/actions.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -981,6 +981,16 @@ export const _getAISDKLanguageModelAndOptions = async (config: LanguageModel): P
981981

982982
}
983983

984+
export const getAskGhLoginWallData = async () => sew(async () => {
985+
const isEnabled = env.EXPERIMENT_ASK_GH_ENABLED === 'true';
986+
if (!isEnabled) {
987+
return { isEnabled: false as const, providers: [] };
988+
}
989+
990+
const { getIdentityProviderMetadata } = await import('@/lib/identityProviders');
991+
return { isEnabled: true as const, providers: getIdentityProviderMetadata() };
992+
});
993+
984994
const extractLanguageModelKeyValuePairs = async (
985995
pairs: {
986996
[k: string]: string | Token;

packages/web/src/features/chat/components/chatThread/chatThread.tsx

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,17 @@ import { NotConfiguredErrorBanner } from '../notConfiguredErrorBanner';
2828
import useCaptureEvent from '@/hooks/useCaptureEvent';
2929
import { SignInPromptBanner } from './signInPromptBanner';
3030
import { DuplicateChatDialog } from '@/app/[domain]/chat/components/duplicateChatDialog';
31+
import { LoginModal } from '@/app/components/loginModal';
32+
import type { IdentityProviderMetadata } from '@/lib/identityProviders';
33+
import { getAskGhLoginWallData } from '../../actions';
3134
import { useParams } from 'next/navigation';
3235

3336
type ChatHistoryState = {
3437
scrollOffset?: number;
3538
}
3639

40+
const PENDING_MESSAGE_STORAGE_KEY = "askgh_chat_pending_message";
41+
3742
interface ChatThreadProps {
3843
id?: string | undefined;
3944
initialMessages?: SBChatMessage[];
@@ -71,6 +76,9 @@ export const ChatThread = ({
7176
const params = useParams<{ domain: string }>();
7277
const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false);
7378
const [isDuplicateDialogOpen, setIsDuplicateDialogOpen] = useState(false);
79+
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
80+
const [loginWallProviders, setLoginWallProviders] = useState<IdentityProviderMetadata[]>([]);
81+
const hasRestoredPendingMessage = useRef(false);
7482
const captureEvent = useCaptureEvent();
7583

7684
// Initial state is from attachments that exist in in the chat history.
@@ -200,6 +208,38 @@ export const ChatThread = ({
200208
hasSubmittedInputMessage.current = true;
201209
}, [inputMessage, sendMessage]);
202210

211+
// Restore pending message after OAuth redirect (askgh login wall)
212+
useEffect(() => {
213+
if (!isAuthenticated || !isOwner || hasRestoredPendingMessage.current) {
214+
return;
215+
}
216+
217+
const stored = sessionStorage.getItem(PENDING_MESSAGE_STORAGE_KEY);
218+
if (!stored) {
219+
return;
220+
}
221+
222+
hasRestoredPendingMessage.current = true;
223+
sessionStorage.removeItem(PENDING_MESSAGE_STORAGE_KEY);
224+
225+
try {
226+
const { chatId: storedChatId, children } = JSON.parse(stored) as { chatId: string; children: Descendant[] };
227+
228+
// Only restore if we're on the same chat that stored the pending message
229+
if (storedChatId !== chatId) {
230+
return;
231+
}
232+
233+
const text = slateContentToString(children);
234+
const mentions = getAllMentionElements(children);
235+
const message = createUIMessage(text, mentions.map(({ data }) => data), selectedSearchScopes);
236+
sendMessage(message);
237+
setIsAutoScrollEnabled(true);
238+
} catch (error) {
239+
console.error('Failed to restore pending message:', error);
240+
}
241+
}, [isAuthenticated, isOwner, chatId, sendMessage, selectedSearchScopes]);
242+
203243
// Track scroll position changes.
204244
useEffect(() => {
205245
const scrollElement = scrollAreaRef.current?.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement;
@@ -287,7 +327,18 @@ export const ChatThread = ({
287327
}
288328
}, [error]);
289329

290-
const onSubmit = useCallback((children: Descendant[], editor: CustomEditor) => {
330+
const onSubmit = useCallback(async (children: Descendant[], editor: CustomEditor) => {
331+
if (!isAuthenticated) {
332+
const result = await getAskGhLoginWallData();
333+
if (!isServiceError(result) && result.isEnabled) {
334+
captureEvent('wa_askgh_login_wall_prompted', {});
335+
sessionStorage.setItem(PENDING_MESSAGE_STORAGE_KEY, JSON.stringify({ chatId, children }));
336+
setLoginWallProviders(result.providers);
337+
setIsLoginModalOpen(true);
338+
return;
339+
}
340+
}
341+
291342
const text = slateContentToString(children);
292343
const mentions = getAllMentionElements(children);
293344

@@ -297,7 +348,7 @@ export const ChatThread = ({
297348
setIsAutoScrollEnabled(true);
298349

299350
resetEditor(editor);
300-
}, [sendMessage, selectedSearchScopes]);
351+
}, [sendMessage, selectedSearchScopes, isAuthenticated, captureEvent, chatId]);
301352

302353
const onDuplicate = useCallback(async (newName: string): Promise<string | null> => {
303354
if (!defaultChatId) {
@@ -449,6 +500,13 @@ export const ChatThread = ({
449500
</div>
450501
)}
451502
</div>
503+
504+
<LoginModal
505+
isOpen={isLoginModalOpen}
506+
onOpenChange={setIsLoginModalOpen}
507+
providers={loginWallProviders}
508+
callbackUrl={typeof window !== 'undefined' ? window.location.href : ''}
509+
/>
452510
</>
453511
);
454512
}

0 commit comments

Comments
 (0)