Skip to content

Commit 28c6076

Browse files
msukkariclaude
andcommitted
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>
1 parent 6208955 commit 28c6076

File tree

6 files changed

+76
-5
lines changed

6 files changed

+76
-5
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ 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";

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ResizablePanel } from '@/components/ui/resizable';
44
import { ChatThread } from '@/features/chat/components/chatThread';
55
import { LanguageModelInfo, SBChatMessage, SearchScope, SET_CHAT_STATE_SESSION_STORAGE_KEY, SetChatStatePayload } from '@/features/chat/types';
66
import { RepositoryQuery, SearchContextQuery } from '@/lib/types';
7+
import type { IdentityProviderMetadata } from '@/lib/identityProviders';
78
import { CreateUIMessage } from 'ai';
89
import { useEffect, useState } from 'react';
910
import { useChatId } from '../../useChatId';
@@ -18,6 +19,8 @@ interface ChatThreadPanelProps {
1819
isOwner: boolean;
1920
isAuthenticated: boolean;
2021
chatName?: string;
22+
providers: IdentityProviderMetadata[];
23+
isAskGhEnabled: boolean;
2124
}
2225

2326
export const ChatThreadPanel = ({
@@ -29,6 +32,8 @@ export const ChatThreadPanel = ({
2932
isOwner,
3033
isAuthenticated,
3134
chatName,
35+
providers,
36+
isAskGhEnabled,
3237
}: ChatThreadPanelProps) => {
3338
// @note: we are guaranteed to have a chatId because this component will only be
3439
// mounted when on a /chat/[id] route.
@@ -76,6 +81,8 @@ export const ChatThreadPanel = ({
7681
isOwner={isOwner}
7782
isAuthenticated={isAuthenticated}
7883
chatName={chatName}
84+
providers={providers}
85+
isAskGhEnabled={isAskGhEnabled}
7986
/>
8087
</div>
8188
</ResizablePanel>

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ 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+
import { getIdentityProviderMetadata } from '@/lib/identityProviders';
2122

2223
import { captureEvent } from '@/lib/posthog';
2324

@@ -151,6 +152,9 @@ export default async function Page(props: PageProps) {
151152

152153
const hasChatSharingEntitlement = hasEntitlement('chat-sharing');
153154

155+
const isAskGhEnabled = env.EXPERIMENT_ASK_GH_ENABLED === 'true';
156+
const providers = isAskGhEnabled ? getIdentityProviderMetadata() : [];
157+
154158
return (
155159
<div className="flex flex-col h-screen w-screen">
156160
<TopBar
@@ -197,6 +201,8 @@ export default async function Page(props: PageProps) {
197201
isOwner={isOwner}
198202
isAuthenticated={!!session}
199203
chatName={name ?? undefined}
204+
providers={providers}
205+
isAskGhEnabled={isAskGhEnabled}
200206
/>
201207
</ResizablePanelGroup>
202208
</div>

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/components/chatThread/chatThread.tsx

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,16 @@ 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';
3133
import { useParams } from 'next/navigation';
3234

3335
type ChatHistoryState = {
3436
scrollOffset?: number;
3537
}
3638

39+
const PENDING_MESSAGE_STORAGE_KEY = "askgh_chat_pending_message";
40+
3741
interface ChatThreadProps {
3842
id?: string | undefined;
3943
initialMessages?: SBChatMessage[];
@@ -46,6 +50,8 @@ interface ChatThreadProps {
4650
isOwner?: boolean;
4751
isAuthenticated?: boolean;
4852
chatName?: string;
53+
providers?: IdentityProviderMetadata[];
54+
isAskGhEnabled?: boolean;
4955
}
5056

5157
export const ChatThread = ({
@@ -60,6 +66,8 @@ export const ChatThread = ({
6066
isOwner = true,
6167
isAuthenticated = false,
6268
chatName,
69+
providers = [],
70+
isAskGhEnabled = false,
6371
}: ChatThreadProps) => {
6472
const [isErrorBannerVisible, setIsErrorBannerVisible] = useState(false);
6573
const scrollAreaRef = useRef<HTMLDivElement>(null);
@@ -71,6 +79,8 @@ export const ChatThread = ({
7179
const params = useParams<{ domain: string }>();
7280
const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false);
7381
const [isDuplicateDialogOpen, setIsDuplicateDialogOpen] = useState(false);
82+
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
83+
const hasRestoredPendingMessage = useRef(false);
7484
const captureEvent = useCaptureEvent();
7585

7686
// Initial state is from attachments that exist in in the chat history.
@@ -200,6 +210,38 @@ export const ChatThread = ({
200210
hasSubmittedInputMessage.current = true;
201211
}, [inputMessage, sendMessage]);
202212

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

290332
const onSubmit = useCallback((children: Descendant[], editor: CustomEditor) => {
333+
if (isAskGhEnabled && !isAuthenticated) {
334+
captureEvent('wa_askgh_login_wall_prompted', {});
335+
sessionStorage.setItem(PENDING_MESSAGE_STORAGE_KEY, JSON.stringify({ chatId, children }));
336+
setIsLoginModalOpen(true);
337+
return;
338+
}
339+
291340
const text = slateContentToString(children);
292341
const mentions = getAllMentionElements(children);
293342

@@ -297,7 +346,7 @@ export const ChatThread = ({
297346
setIsAutoScrollEnabled(true);
298347

299348
resetEditor(editor);
300-
}, [sendMessage, selectedSearchScopes]);
349+
}, [sendMessage, selectedSearchScopes, isAskGhEnabled, isAuthenticated, captureEvent, chatId]);
301350

302351
const onDuplicate = useCallback(async (newName: string): Promise<string | null> => {
303352
if (!defaultChatId) {
@@ -449,6 +498,15 @@ export const ChatThread = ({
449498
</div>
450499
)}
451500
</div>
501+
502+
{isAskGhEnabled && (
503+
<LoginModal
504+
isOpen={isLoginModalOpen}
505+
onOpenChange={setIsLoginModalOpen}
506+
providers={providers}
507+
callbackUrl={typeof window !== 'undefined' ? window.location.href : ''}
508+
/>
509+
)}
452510
</>
453511
);
454512
}

packages/web/src/features/git/listCommitsApi.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect, vi, beforeEach } from 'vitest';
1+
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
22
import { listCommits } from './listCommitsApi';
33
import * as dateUtils from './dateUtils';
44

@@ -63,8 +63,8 @@ describe('searchCommits', () => {
6363
const mockGitLog = vi.fn();
6464
const mockGitRaw = vi.fn();
6565
const mockCwd = vi.fn();
66-
const mockSimpleGit = simpleGit as unknown as vi.Mock;
67-
const mockExistsSync = existsSync as unknown as vi.Mock;
66+
const mockSimpleGit = simpleGit as unknown as Mock;
67+
const mockExistsSync = existsSync as unknown as Mock;
6868

6969
beforeEach(() => {
7070
vi.clearAllMocks();

0 commit comments

Comments
 (0)