Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
msukkari marked this conversation as resolved.

### Changed
- Hide version upgrade toast for askgithub deployment (`EXPERIMENT_ASK_GH_ENABLED`). [#931](https://github.com/sourcebot-dev/sourcebot/pull/931)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,19 @@ 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[];
repoName: string;
repoDisplayName?: string;
imageUrl?: string | null;
repoId: number;
providers: IdentityProviderMetadata[];
isAuthenticated: boolean;
}

Expand All @@ -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(() => [
Expand All @@ -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;

Expand Down Expand Up @@ -119,7 +69,9 @@ export const LandingPage = ({
<div className="w-full max-w-[800px]">
<div className="border rounded-md w-full shadow-sm">
<ChatBox
onSubmit={handleSubmit}
onSubmit={(children) => {
createNewChatThread(children, selectedSearchScopes);
}}
className="min-h-[50px]"
isRedirecting={isLoading}
languageModels={languageModels}
Expand Down Expand Up @@ -155,11 +107,11 @@ export const LandingPage = ({
</div>

<LoginModal
isOpen={isLoginModalOpen}
onOpenChange={setIsLoginModalOpen}
providers={providers}
isOpen={loginWall.isOpen}
onOpenChange={loginWall.onOpenChange}
providers={loginWall.providers}
callbackUrl={typeof window !== 'undefined' ? window.location.href : ''}
/>
</div>
)
}
}
3 changes: 0 additions & 3 deletions packages/web/src/app/[domain]/askgh/[owner]/[repo]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 (
<RepoIndexedGuard initialRepoInfo={repoInfo}>
Expand All @@ -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}
/>
</CustomSlateEditor>
Expand Down
1 change: 0 additions & 1 deletion packages/web/src/app/[domain]/chat/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<SearchScope[]>("selectedSearchScopes", [], { initializeWithValue: false });
const [isContextSelectorOpen, setIsContextSelectorOpen] = useState(false);
const isChatBoxDisabled = languageModels.length === 0;
Expand Down Expand Up @@ -65,6 +68,13 @@ export const LandingPageChatBox = ({
{isChatBoxDisabled && (
<NotConfiguredErrorBanner className="mt-4" />
)}

<LoginModal
isOpen={loginWall.isOpen}
onOpenChange={loginWall.onOpenChange}
providers={loginWall.providers}
callbackUrl={typeof window !== 'undefined' ? window.location.href : ''}
/>
</div >
)
}
}
1 change: 1 addition & 0 deletions packages/web/src/app/[domain]/chat/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export default async function Page(props: PageProps) {
languageModels={languageModels}
repos={allRepos}
searchContexts={searchContexts}
isAuthenticated={!!session}
/>
</CustomSlateEditor>

Expand Down
10 changes: 10 additions & 0 deletions packages/web/src/features/chat/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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<IdentityProviderMetadata[]>([]);
const hasRestoredPendingMessage = useRef(false);
const captureEvent = useCaptureEvent();

// Initial state is from attachments that exist in in the chat history.
Expand Down Expand Up @@ -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);

Comment thread
msukkari marked this conversation as resolved.
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;
Expand Down Expand Up @@ -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);

Expand All @@ -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<string | null> => {
if (!defaultChatId) {
Expand Down Expand Up @@ -449,6 +500,13 @@ export const ChatThread = ({
</div>
)}
</div>

<LoginModal
isOpen={isLoginModalOpen}
onOpenChange={setIsLoginModalOpen}
providers={loginWallProviders}
callbackUrl={typeof window !== 'undefined' ? window.location.href : ''}
/>
</>
);
}
Loading