diff --git a/CHANGELOG.md b/CHANGELOG.md index 313632b24..77673b590 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- Redesigned the app layout with a new collapsible sidebar navigation, replacing the previous top navigation bar. [#1097](https://github.com/sourcebot-dev/sourcebot/pull/1097) + ## [4.16.8] - 2026-04-09 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 943898eae..f7400eeaa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,6 +57,18 @@ if (!value) { return; } if (condition) doSomething(); ``` +## Conditional ClassNames + +Use `cn()` from `@/lib/utils` for conditional classNames instead of template literal interpolation: + +```tsx +// Correct +className={cn("border-b transition-colors", isActive ? "border-foreground" : "border-transparent")} + +// Incorrect +className={`border-b transition-colors ${isActive ? "border-foreground" : "border-transparent"}`} +``` + ## Tailwind CSS Use Tailwind color classes directly instead of CSS variable syntax: diff --git a/packages/db/tools/scriptRunner.ts b/packages/db/tools/scriptRunner.ts index 9732b9a84..599409053 100644 --- a/packages/db/tools/scriptRunner.ts +++ b/packages/db/tools/scriptRunner.ts @@ -7,6 +7,7 @@ import { injectUserData } from "./scripts/inject-user-data"; import { confirmAction } from "./utils"; import { injectRepoData } from "./scripts/inject-repo-data"; import { testRepoQueryPerf } from "./scripts/test-repo-query-perf"; +import { injectChatData } from "./scripts/inject-chat-data"; export interface Script { run: (prisma: PrismaClient) => Promise; @@ -19,12 +20,13 @@ export const scripts: Record = { "inject-user-data": injectUserData, "inject-repo-data": injectRepoData, "test-repo-query-perf": testRepoQueryPerf, + "inject-chat-data": injectChatData, } const parser = new ArgumentParser(); parser.add_argument("--url", { required: true, help: "Database URL" }); parser.add_argument("--script", { required: true, help: "Script to run" }); -const args = parser.parse_args(); +const [args] = parser.parse_known_args(); (async () => { if (!(args.script in scripts)) { diff --git a/packages/db/tools/scripts/inject-chat-data.ts b/packages/db/tools/scripts/inject-chat-data.ts new file mode 100644 index 000000000..89c0befa4 --- /dev/null +++ b/packages/db/tools/scripts/inject-chat-data.ts @@ -0,0 +1,86 @@ +import { Script } from "../scriptRunner"; +import { PrismaClient } from "../../dist"; +import { confirmAction } from "../utils"; + +const chatNames = [ + "How does the auth middleware work?", + "Explain the search indexing pipeline", + "Where are API routes defined?", + "How to add a new database migration", + "What is the repo sync process?", + "Understanding the chat architecture", + "How does SSO integration work?", + "Explain the permission model", + "Where is the webhook handler?", + "How to configure environment variables", + "Understanding the billing system", + "How does the worker process jobs?", + "Explain the caching strategy", + "Where are the shared types defined?", + "How does code search ranking work?", + "Understanding the notification system", + "How to add a new API endpoint", + "Explain the deployment pipeline", + "Where is error handling centralized?", + "How does real-time updates work?", + "Understanding the plugin system", + "How to write integration tests", + "Explain the file indexing process", + "Where are the email templates?", + "How does rate limiting work?", + "Understanding the monorepo structure", + "How to add a new feature flag", + "Explain the logging setup", + "Where is the GraphQL schema?", + "How does the sidebar component work?", +]; + +export const injectChatData: Script = { + run: async (prisma: PrismaClient) => { + const orgId = 1; + + const org = await prisma.org.findUnique({ + where: { id: orgId } + }); + + if (!org) { + console.error(`Organization with id ${orgId} not found.`); + return; + } + + const userIdArg = process.argv.find(arg => arg.startsWith("--user-id="))?.split("=")[1]; + + const user = userIdArg + ? await prisma.user.findUnique({ where: { id: userIdArg } }) + : await prisma.user.findFirst({ + where: { + orgs: { + some: { orgId } + } + } + }); + + if (!user) { + console.error(userIdArg + ? `User with id "${userIdArg}" not found.` + : `No user found in org ${orgId}.` + ); + return; + } + + await confirmAction(`This will create ${chatNames.length} chats for user "${user.name ?? user.email}" in org ${orgId}.`); + + for (const name of chatNames) { + await prisma.chat.create({ + data: { + name, + orgId, + createdById: user.id, + messages: [], + } + }); + } + + console.log(`Created ${chatNames.length} chats.`); + } +}; diff --git a/packages/web/package.json b/packages/web/package.json index d9533df10..393609057 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -70,7 +70,7 @@ "@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-collapsible": "^1.1.11", - "@radix-ui/react-dialog": "^1.1.4", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-hover-card": "^1.1.6", "@radix-ui/react-icons": "^1.3.0", @@ -79,13 +79,13 @@ "@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-scroll-area": "^1.1.0", "@radix-ui/react-select": "^2.1.6", - "@radix-ui/react-separator": "^1.1.0", - "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.4", "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-toggle": "^1.1.0", - "@radix-ui/react-tooltip": "^1.1.4", + "@radix-ui/react-tooltip": "^1.2.8", "@react-email/components": "^1.0.2", "@react-email/render": "^2.0.0", "@replit/codemirror-lang-csharp": "^6.2.0", @@ -114,7 +114,7 @@ "ai": "^6.0.105", "ajv": "^8.17.1", "bcryptjs": "^3.0.2", - "class-variance-authority": "^0.7.0", + "class-variance-authority": "^0.7.1", "client-only": "^0.0.1", "clsx": "^2.1.1", "cm6-graphql": "^0.2.0", @@ -149,7 +149,7 @@ "langfuse": "^3.38.4", "langfuse-vercel": "^3.38.4", "linguist-languages": "^9.3.1", - "lucide-react": "^0.517.0", + "lucide-react": "^1.7.0", "micromatch": "^4.0.8", "minidenticons": "^4.2.1", "next": "16.1.6", diff --git a/packages/web/src/app/(app)/@sidebar/(routes)/(default)/[...slug]/page.tsx b/packages/web/src/app/(app)/@sidebar/(routes)/(default)/[...slug]/page.tsx new file mode 100644 index 000000000..0959482a2 --- /dev/null +++ b/packages/web/src/app/(app)/@sidebar/(routes)/(default)/[...slug]/page.tsx @@ -0,0 +1,5 @@ +import { DefaultSidebar } from "../../../components/defaultSidebar"; + +export default async function Page() { + return ; +} diff --git a/packages/web/src/app/(app)/@sidebar/(routes)/(default)/page.tsx b/packages/web/src/app/(app)/@sidebar/(routes)/(default)/page.tsx new file mode 100644 index 000000000..c6b333f88 --- /dev/null +++ b/packages/web/src/app/(app)/@sidebar/(routes)/(default)/page.tsx @@ -0,0 +1,5 @@ +import { DefaultSidebar } from "../../components/defaultSidebar"; + +export default async function Page() { + return ; +} diff --git a/packages/web/src/app/(app)/@sidebar/(routes)/settings/[...slug]/page.tsx b/packages/web/src/app/(app)/@sidebar/(routes)/settings/[...slug]/page.tsx new file mode 100644 index 000000000..6b8474b84 --- /dev/null +++ b/packages/web/src/app/(app)/@sidebar/(routes)/settings/[...slug]/page.tsx @@ -0,0 +1,6 @@ +import { authenticatedPage } from "@/middleware/authenticatedPage"; +import { SettingsSidebar } from "../../../components/settingsSidebar"; + +export default authenticatedPage(async () => { + return ; +}); diff --git a/packages/web/src/app/(app)/@sidebar/(routes)/settings/page.tsx b/packages/web/src/app/(app)/@sidebar/(routes)/settings/page.tsx new file mode 100644 index 000000000..0de76d7b4 --- /dev/null +++ b/packages/web/src/app/(app)/@sidebar/(routes)/settings/page.tsx @@ -0,0 +1,6 @@ +import { authenticatedPage } from "@/middleware/authenticatedPage"; +import { SettingsSidebar } from "../../components/settingsSidebar"; + +export default authenticatedPage(async () => { + return ; +}); diff --git a/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/chatHistory.tsx b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/chatHistory.tsx new file mode 100644 index 000000000..b9f29a490 --- /dev/null +++ b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/chatHistory.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { ChatActionsDropdown } from "@/app/(app)/chat/components/chatActionsDropdown"; +import { DeleteChatDialog } from "@/app/(app)/chat/components/deleteChatDialog"; +import { DuplicateChatDialog } from "@/app/(app)/chat/components/duplicateChatDialog"; +import { RenameChatDialog } from "@/app/(app)/chat/components/renameChatDialog"; +import { useToast } from "@/components/hooks/use-toast"; +import { + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; +import { deleteChat, duplicateChat, updateChatName } from "@/features/chat/actions"; +import { captureEvent } from "@/hooks/useCaptureEvent"; +import { isServiceError } from "@/lib/utils"; +import { EllipsisIcon, MessagesSquareIcon } from "lucide-react"; +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { useCallback, useState } from "react"; + +export interface ChatHistoryItem { + id: string; + name: string | null; + createdAt: Date; +} + +interface ChatHistoryProps { + chatHistory: ChatHistoryItem[]; + hasMore?: boolean; +} + +export function ChatHistory({ chatHistory, hasMore }: ChatHistoryProps) { + const pathname = usePathname(); + const router = useRouter(); + const { toast } = useToast(); + + const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); + const [chatIdToRename, setChatIdToRename] = useState(null); + const [isDuplicateDialogOpen, setIsDuplicateDialogOpen] = useState(false); + const [chatIdToDuplicate, setChatIdToDuplicate] = useState(null); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [chatIdToDelete, setChatIdToDelete] = useState(null); + + const onRenameChat = useCallback(async (name: string, chatId: string): Promise => { + const response = await updateChatName({ chatId, name }); + if (isServiceError(response)) { + toast({ description: `Failed to rename chat. Reason: ${response.message}` }); + return false; + } + toast({ description: "Chat renamed successfully" }); + captureEvent('wa_chat_renamed', { chatId }); + router.refresh(); + return true; + }, [router, toast]); + + const onDeleteChat = useCallback(async (chatIdToDelete: string): Promise => { + const response = await deleteChat({ chatId: chatIdToDelete }); + if (isServiceError(response)) { + toast({ description: `Failed to delete chat. Reason: ${response.message}` }); + return false; + } + toast({ description: "Chat deleted successfully" }); + captureEvent('wa_chat_deleted', { chatId: chatIdToDelete }); + if (pathname === `/chat/${chatIdToDelete}`) { + router.push("/chat"); + } else { + router.refresh(); + } + return true; + }, [pathname, router, toast]); + + const onDuplicateChat = useCallback(async (newName: string, chatIdToDuplicate: string): Promise => { + const response = await duplicateChat({ chatId: chatIdToDuplicate, newName }); + if (isServiceError(response)) { + toast({ description: `Failed to duplicate chat. Reason: ${response.message}` }); + return null; + } + toast({ description: "Chat duplicated successfully" }); + captureEvent('wa_chat_duplicated', { chatId: chatIdToDuplicate }); + router.push(`/chat/${response.id}`); + return response.id; + }, [router, toast]); + + if (chatHistory.length === 0) { + return null; + } + + return ( + <> + + Recent Chats + + + {chatHistory.map((chat) => ( + + + + {chat.name ?? "Untitled chat"} + + + { + setChatIdToRename(chat.id); + setIsRenameDialogOpen(true); + }} + onDuplicateClick={() => { + setChatIdToDuplicate(chat.id); + setIsDuplicateDialogOpen(true); + }} + onDeleteClick={() => { + setChatIdToDelete(chat.id); + setIsDeleteDialogOpen(true); + }} + > + + + + + + ))} + {hasMore && ( + + + + + All chats + + + + )} + + + + { + if (chatIdToRename) { + return await onRenameChat(name, chatIdToRename); + } + return false; + }} + currentName={chatHistory.find((chat) => chat.id === chatIdToRename)?.name ?? "Untitled chat"} + /> + { + if (chatIdToDelete) { + return await onDeleteChat(chatIdToDelete); + } + return false; + }} + /> + { + if (chatIdToDuplicate) { + return await onDuplicateChat(newName, chatIdToDuplicate); + } + return null; + }} + currentName={chatHistory.find((chat) => chat.id === chatIdToDuplicate)?.name ?? "Untitled chat"} + /> + + ); +} diff --git a/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx new file mode 100644 index 000000000..d222518a7 --- /dev/null +++ b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx @@ -0,0 +1,87 @@ +import { cookies } from "next/headers"; +import { auth } from "@/auth"; +import { HOME_VIEW_COOKIE_NAME } from "@/lib/constants"; +import { HomeView } from "@/hooks/useHomeView"; +import { getConnectionStats, getOrgAccountRequests } from "@/actions"; +import { isServiceError } from "@/lib/utils"; +import { ServiceErrorException } from "@/lib/serviceError"; +import { __unsafePrisma } from "@/prisma"; +import { SINGLE_TENANT_ORG_ID } from "@/lib/constants"; +import { OrgRole } from "@prisma/client"; +import { SidebarBase } from "@/app/(app)/@sidebar/components/sidebarBase"; +import { Nav } from "./nav"; +import { ChatHistory } from "./chatHistory"; +import { withAuth } from "@/middleware/withAuth"; +import { sew } from "@/middleware/sew"; + +const SIDEBAR_CHAT_LIMIT = 30; + +export async function DefaultSidebar() { + const session = await auth(); + const cookieStore = await cookies(); + const homeView = (cookieStore.get(HOME_VIEW_COOKIE_NAME)?.value ?? "search") as HomeView; + + const chatHistory = session ? await getUserChatHistory() : []; + if (isServiceError(chatHistory)) { + throw new ServiceErrorException(chatHistory); + } + + const isSettingsNotificationVisible = await (async () => { + if (!session) { + return false; + } + const membership = await __unsafePrisma.userToOrg.findUnique({ + where: { orgId_userId: { orgId: SINGLE_TENANT_ORG_ID, userId: session.user.id } }, + select: { role: true }, + }); + if (membership?.role !== OrgRole.OWNER) { + return false; + } + const connectionStats = await getConnectionStats(); + const joinRequests = await getOrgAccountRequests(); + const hasConnectionNotification = !isServiceError(connectionStats) && connectionStats.numberOfConnectionsWithFirstTimeSyncJobsInProgress > 0; + const hasJoinRequestNotification = !isServiceError(joinRequests) && joinRequests.length > 0; + return hasConnectionNotification || hasJoinRequestNotification; + })(); + + return ( + + } + > + SIDEBAR_CHAT_LIMIT} + /> + + ); +} + +const getUserChatHistory = async () => sew(() => + withAuth(async ({ org, user, prisma }) => { + const chats = await prisma.chat.findMany({ + where: { + orgId: org.id, + createdById: user.id, + }, + orderBy: { + updatedAt: 'desc', + }, + take: SIDEBAR_CHAT_LIMIT + 1, + }); + + return chats.map((chat) => ({ + id: chat.id, + createdAt: chat.createdAt, + name: chat.name, + visibility: chat.visibility, + })) + }) +); \ No newline at end of file diff --git a/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/nav.tsx b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/nav.tsx new file mode 100644 index 000000000..75383e78e --- /dev/null +++ b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/nav.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; +import { BookMarkedIcon, type LucideIcon, MessageCircleIcon, MessagesSquareIcon, SearchIcon, SettingsIcon } from "lucide-react"; +import { usePathname } from "next/navigation"; +import { HomeView } from "@/hooks/useHomeView"; +import { NotificationDot } from "../../../components/notificationDot"; +import { useMemo } from "react"; + +interface NavItem { + title: string; + href: string; + icon: LucideIcon; + key: string; + requiresAuth?: boolean; +} + +interface NavProps { + isSettingsNotificationVisible?: boolean; + isSignedIn?: boolean; + homeView: HomeView; +} + +export function Nav({ isSettingsNotificationVisible, isSignedIn, homeView }: NavProps) { + const pathname = usePathname(); + + const baseItems = useMemo((): NavItem[] => { + + const searchItem: NavItem = { + title: "Code Search", + href: "/search", + icon: SearchIcon, + key: "search", + } + + const askItem: NavItem = { + title: "Ask", + href: "/chat", + icon: MessageCircleIcon, + key: "chat" + } + + return [ + ...(homeView === "search" ? [ + searchItem, + askItem, + ] : [ + askItem, + searchItem, + ]), + { + title: "Chats", + href: "/chats", + icon: MessagesSquareIcon, + key: "chats", + requiresAuth: true, + }, + { + title: "Repositories", + href: "/repos", + icon: BookMarkedIcon, + key: "repos" + }, + { + title: "Settings", + href: "/settings", + icon: SettingsIcon, + key: "settings", + requiresAuth: true + }, + ] + + + }, [homeView]); + + const isActive = (href: string) => { + if (pathname === "/") { + return ( + (homeView === "ask" && href === "/chat") || + (homeView === "search" && href === "/search") + ) + } + + if (href === "/search") { + return pathname.startsWith("/search"); + } + + if (href === "/chat") { + return pathname === "/chat"; + } + return pathname.startsWith(href); + }; + + return ( + + {baseItems.filter((item) => !item.requiresAuth || isSignedIn).map((item) => { + const showNotification = + (item.key === "settings" && isSettingsNotificationVisible); + return ( + + + + + {item.title} + {showNotification && } + + + + ); + })} + + ); +} diff --git a/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/index.tsx b/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/index.tsx new file mode 100644 index 000000000..85e3d0ae2 --- /dev/null +++ b/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/index.tsx @@ -0,0 +1,43 @@ +import { auth } from "@/auth"; +import { isServiceError } from "@/lib/utils"; +import { ServiceErrorException } from "@/lib/serviceError"; +import { getSidebarNavGroups } from "@/app/(app)/settings/layout"; +import { SidebarBase } from "../sidebarBase"; +import { Nav } from "./nav"; +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; +import { ArrowLeftIcon } from "lucide-react"; +import Link from "next/link"; + +export async function SettingsSidebar() { + const session = await auth(); + + const sidebarNavGroups = await getSidebarNavGroups(); + if (isServiceError(sidebarNavGroups)) { + throw new ServiceErrorException(sidebarNavGroups); + } + + return ( + + + + + + Back to app + + + + + } + > +