Skip to content

Commit e61ae50

Browse files
Improved anonymous access handling: add concept of chat visibility and readonly chats
1 parent 14e9660 commit e61ae50

File tree

17 files changed

+301
-224
lines changed

17 files changed

+301
-224
lines changed

packages/db/prisma/migrations/20250629000247_add_chat_name/migration.sql

Lines changed: 0 additions & 2 deletions
This file was deleted.

packages/db/prisma/migrations/20250701184742_add_created_by_field_to_chat/migration.sql

Lines changed: 0 additions & 11 deletions
This file was deleted.

packages/db/prisma/migrations/20250628232128_add_chat_entity/migration.sql renamed to packages/db/prisma/migrations/20250722201612_add_chat_table/migration.sql

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,23 @@
1+
-- CreateEnum
2+
CREATE TYPE "ChatVisibility" AS ENUM ('PRIVATE', 'PUBLIC');
3+
14
-- CreateTable
25
CREATE TABLE "Chat" (
36
"id" TEXT NOT NULL,
7+
"name" TEXT,
8+
"createdById" TEXT NOT NULL,
49
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
510
"updatedAt" TIMESTAMP(3) NOT NULL,
611
"orgId" INTEGER NOT NULL,
12+
"visibility" "ChatVisibility" NOT NULL DEFAULT 'PRIVATE',
13+
"isReadonly" BOOLEAN NOT NULL DEFAULT false,
714
"messages" JSONB NOT NULL,
815

916
CONSTRAINT "Chat_pkey" PRIMARY KEY ("id")
1017
);
1118

19+
-- AddForeignKey
20+
ALTER TABLE "Chat" ADD CONSTRAINT "Chat_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
21+
1222
-- AddForeignKey
1323
ALTER TABLE "Chat" ADD CONSTRAINT "Chat_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;

packages/db/prisma/schema.prisma

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ enum StripeSubscriptionStatus {
3535
INACTIVE
3636
}
3737

38+
enum ChatVisibility {
39+
PRIVATE
40+
PUBLIC
41+
}
42+
3843
model Repo {
3944
id Int @id @default(autoincrement())
4045
name String
@@ -330,5 +335,8 @@ model Chat {
330335
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
331336
orgId Int
332337
338+
visibility ChatVisibility @default(PRIVATE)
339+
isReadonly Boolean @default(false)
340+
333341
messages Json // This is a JSON array of `Message` types from @ai-sdk/ui-utils.
334342
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ interface ChatThreadPanelProps {
1313
chatBoxToolbarProps: Omit<ChatBoxToolbarProps, "selectedRepos" | "onSelectedReposChange">;
1414
order: number;
1515
messages: SBChatMessage[];
16+
isChatReadonly: boolean;
1617
}
1718

1819
export const ChatThreadPanel = ({
1920
chatBoxToolbarProps,
2021
order,
2122
messages,
23+
isChatReadonly,
2224
}: ChatThreadPanelProps) => {
2325
// @note: we are guaranteed to have a chatId because this component will only be
2426
// mounted when on a /chat/[id] route.
@@ -62,6 +64,7 @@ export const ChatThreadPanel = ({
6264
chatBoxToolbarProps={chatBoxToolbarProps}
6365
selectedRepos={selectedRepos}
6466
onSelectedReposChange={setSelectedRepos}
67+
isChatReadonly={isChatReadonly}
6568
/>
6669
</div>
6770
</ResizablePanel>
Lines changed: 60 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
import { getRepos } from '@/actions';
2-
import { getConfiguredLanguageModelsInfo, loadChatMessages } from '@/features/chat/actions';
2+
import { getUserChatHistory, getConfiguredLanguageModelsInfo, getChatInfo } from '@/features/chat/actions';
33
import { ServiceErrorException } from '@/lib/serviceError';
44
import { isServiceError } from '@/lib/utils';
55
import { ChatThreadPanel } from './components/chatThreadPanel';
6+
import { notFound } from 'next/navigation';
7+
import { StatusCodes } from 'http-status-codes';
8+
import { TopBar } from '../../components/topBar';
9+
import { ChatName } from '../components/chatName';
10+
import { auth } from '@/auth';
11+
import { AnimatedResizableHandle } from '@/components/ui/animatedResizableHandle';
12+
import { ChatSidePanel } from '../components/chatSidePanel';
13+
import { ResizablePanelGroup } from '@/components/ui/resizable';
614

715
interface PageProps {
816
params: {
@@ -14,26 +22,65 @@ interface PageProps {
1422
export default async function Page({ params }: PageProps) {
1523
const languageModels = await getConfiguredLanguageModelsInfo();
1624
const repos = await getRepos(params.domain);
17-
const chatMessages = await loadChatMessages({ chatId: params.id }, params.domain);
25+
const chatInfo = await getChatInfo({ chatId: params.id }, params.domain);
26+
const session = await auth();
27+
const chatHistory = session ? await getUserChatHistory(params.domain) : [];
28+
29+
if (isServiceError(chatHistory)) {
30+
throw new ServiceErrorException(chatHistory);
31+
}
1832

1933
if (isServiceError(repos)) {
2034
throw new ServiceErrorException(repos);
2135
}
2236

23-
if (isServiceError(chatMessages)) {
24-
throw new ServiceErrorException(chatMessages);
37+
if (isServiceError(chatInfo)) {
38+
if (chatInfo.statusCode === StatusCodes.NOT_FOUND) {
39+
return notFound();
40+
}
41+
42+
throw new ServiceErrorException(chatInfo);
2543
}
2644

27-
const indexedRepos = repos.filter((repo) => repo.indexedAt !== undefined);
45+
const { messages, name, visibility, isReadonly } = chatInfo;
46+
47+
const indexedRepos = repos.filter((repo) => repo.indexedAt !== undefined);
2848

2949
return (
30-
<ChatThreadPanel
31-
chatBoxToolbarProps={{
32-
languageModels,
33-
repos: indexedRepos,
34-
}}
35-
messages={chatMessages}
36-
order={2}
37-
/>
50+
<>
51+
<TopBar
52+
domain={params.domain}
53+
>
54+
<div className="flex flex-row gap-2 items-center">
55+
<span className="text-muted mx-2 select-none">/</span>
56+
<ChatName
57+
name={name}
58+
visibility={visibility}
59+
id={params.id}
60+
isReadonly={isReadonly}
61+
/>
62+
</div>
63+
</TopBar>
64+
<ResizablePanelGroup
65+
direction="horizontal"
66+
>
67+
<ChatSidePanel
68+
order={1}
69+
chatHistory={chatHistory}
70+
isAuthenticated={!!session}
71+
isCollapsedInitially={true}
72+
/>
73+
<AnimatedResizableHandle />
74+
<ChatThreadPanel
75+
chatBoxToolbarProps={{
76+
languageModels,
77+
repos: indexedRepos,
78+
}}
79+
messages={messages}
80+
order={2}
81+
isChatReadonly={isReadonly}
82+
/>
83+
</ResizablePanelGroup>
84+
</>
3885
)
3986
}

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

Lines changed: 13 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -7,39 +7,29 @@ import { updateChatName } from "@/features/chat/actions";
77
import { useDomain } from "@/hooks/useDomain";
88
import { isServiceError } from "@/lib/utils";
99
import { GlobeIcon } from "@radix-ui/react-icons";
10+
import { ChatVisibility } from "@sourcebot/db";
1011
import { LockIcon } from "lucide-react";
11-
import { useSession } from "next-auth/react";
1212
import { useRouter } from "next/navigation";
13-
import { useCallback, useMemo, useState } from "react";
14-
import { useChatId } from "../useChatId";
13+
import { useCallback, useState } from "react";
1514
import { RenameChatDialog } from "./renameChatDialog";
1615

1716
interface ChatNameProps {
18-
chatHistory: {
19-
id: string;
20-
createdAt: Date;
21-
name: string | null;
22-
}[];
17+
name: string | null;
18+
visibility: ChatVisibility;
19+
id: string;
20+
isReadonly: boolean;
2321
}
2422

25-
export const ChatName = ({ chatHistory }: ChatNameProps) => {
26-
const chatId = useChatId();
23+
export const ChatName = ({ name, visibility, id, isReadonly }: ChatNameProps) => {
2724
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
2825
const { toast } = useToast();
2926
const domain = useDomain();
3027
const router = useRouter();
3128

32-
const name = useMemo(() => {
33-
return chatHistory.find((chat) => chat.id === chatId)?.name ?? null;
34-
}, [chatHistory, chatId]);
35-
3629
const onRenameChat = useCallback(async (name: string) => {
37-
if (!chatId) {
38-
return;
39-
}
4030

4131
const response = await updateChatName({
42-
chatId: chatId,
32+
chatId: id,
4333
name: name,
4434
}, domain);
4535

@@ -53,25 +43,11 @@ export const ChatName = ({ chatHistory }: ChatNameProps) => {
5343
});
5444
router.refresh();
5545
}
56-
}, [chatId, domain, toast]);
57-
58-
const { status: authStatus } = useSession();
59-
60-
const visibility = useMemo(() => {
61-
if (authStatus === 'loading') {
62-
return undefined;
63-
}
64-
65-
return authStatus === 'authenticated' ? 'private' : 'public';
66-
}, [authStatus]);
67-
68-
if (!chatId) {
69-
return null;
70-
}
46+
}, [id, domain, toast, router]);
7147

7248
return (
7349
<>
74-
<div className="mx-auto flex flex-row gap-2 items-center">
50+
<div className="flex flex-row gap-2 items-center">
7551
<Tooltip>
7652
<TooltipTrigger asChild>
7753
<p
@@ -92,17 +68,17 @@ export const ChatName = ({ chatHistory }: ChatNameProps) => {
9268
<TooltipTrigger asChild>
9369
<div>
9470
<Badge variant="outline" className="cursor-default">
95-
{visibility === 'public' ? (
71+
{visibility === ChatVisibility.PUBLIC ? (
9672
<GlobeIcon className="w-3 h-3 mr-1" />
9773
) : (
9874
<LockIcon className="w-3 h-3 mr-1" />
9975
)}
100-
{visibility === 'public' ? 'Public' : 'Private'}
76+
{visibility === ChatVisibility.PUBLIC ? (isReadonly ? 'Public (Read-only)' : 'Public') : 'Private'}
10177
</Badge>
10278
</div>
10379
</TooltipTrigger>
10480
<TooltipContent>
105-
{visibility === 'public' ? 'Anyone with the link can view this chat' : 'Only you can view this chat'}
81+
{visibility === ChatVisibility.PUBLIC ? `Anyone with the link can view this chat${!isReadonly ? ' and ask follow-up questions' : ''}.` : 'Only you can view and edit this chat.'}
10682
</TooltipContent>
10783
</Tooltip>
10884
)}

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,17 @@ interface ChatSidePanelProps {
3333
createdAt: Date;
3434
}[];
3535
isAuthenticated: boolean;
36+
isCollapsedInitially: boolean;
3637
}
3738

3839
export const ChatSidePanel = ({
3940
order,
4041
chatHistory,
4142
isAuthenticated,
43+
isCollapsedInitially,
4244
}: ChatSidePanelProps) => {
4345
const domain = useDomain();
44-
const [isCollapsed, setIsCollapsed] = useState(false);
46+
const [isCollapsed, setIsCollapsed] = useState(isCollapsedInitially);
4547
const sidePanelRef = useRef<ImperativePanelHandle>(null);
4648
const router = useRouter();
4749
const { toast } = useToast();
@@ -83,7 +85,7 @@ export const ChatSidePanel = ({
8385
});
8486
router.refresh();
8587
}
86-
}, [chatId, router, toast, domain]);
88+
}, [router, toast, domain]);
8789

8890
const onDeleteChat = useCallback(async (chatIdToDelete: string) => {
8991
if (!chatIdToDelete) {

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

Lines changed: 2 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,17 @@
1-
import { AnimatedResizableHandle } from '@/components/ui/animatedResizableHandle';
2-
import { ResizablePanelGroup } from '@/components/ui/resizable';
3-
import { ChatSidePanel } from './components/chatSidePanel';
4-
import { TopBar } from '../components/topBar';
5-
import { ChatName } from './components/chatName';
61
import { NavigationGuardProvider } from 'next-navigation-guard';
7-
import { getRecentChats } from '@/features/chat/actions';
8-
import { ServiceErrorException } from '@/lib/serviceError';
9-
import { isServiceError } from '@/lib/utils';
10-
import { auth } from '@/auth';
112

123
interface LayoutProps {
134
children: React.ReactNode;
14-
params: {
15-
domain: string;
16-
};
175
}
186

19-
export default async function Layout({ children, params: { domain } }: LayoutProps) {
20-
const session = await auth();
21-
const chatHistory = session ? await getRecentChats(domain) : [];
22-
23-
if (isServiceError(chatHistory)) {
24-
throw new ServiceErrorException(chatHistory);
25-
}
7+
export default async function Layout({ children }: LayoutProps) {
268

279
return (
2810
// @note: we use a navigation guard here since we don't support resuming streams yet.
2911
// @see: https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-message-persistence#resuming-ongoing-streams
3012
<NavigationGuardProvider>
3113
<div className="flex flex-col h-screen w-screen">
32-
<TopBar
33-
domain={domain}
34-
>
35-
{/*
36-
@note: since this layout is not scoped to the [id] route,
37-
we cannot get the chat id in this server component. Workaround
38-
here is to pass the chat history to the chat name component
39-
and let it use that to get the chat name.
40-
*/}
41-
<ChatName chatHistory={chatHistory} />
42-
</TopBar>
43-
<ResizablePanelGroup
44-
direction="horizontal"
45-
>
46-
<ChatSidePanel
47-
order={1}
48-
chatHistory={chatHistory}
49-
isAuthenticated={!!session}
50-
/>
51-
<AnimatedResizableHandle />
52-
{children}
53-
</ResizablePanelGroup>
14+
{children}
5415
</div>
5516
</NavigationGuardProvider>
5617
)

0 commit comments

Comments
 (0)