Skip to content

Conversation

@rossmanko
Copy link
Contributor

@rossmanko rossmanko commented Aug 27, 2025

Summary by CodeRabbit

  • New Features

    • Paginated chat history and messages with infinite scroll and top/bottom loading indicators.
    • Per-chat options menu with Delete and keyboard activation.
    • Pro subscription flow: Upgrade to Pro, Stripe checkout, and billing-portal redirect.
    • User menu shows avatar, name, and Pro/Free status.
  • Improvements

    • Redesigned sidebar and history list with smoother chat switching and improved scroll-to-bottom behavior.
    • Plan-based rate limiting and entitlement checks to differentiate Pro vs Free users.
    • Visual text truncation masks for sidebar items.
  • Bug Fixes

    • Dropdown interactions no longer trigger accidental chat navigation.
  • Documentation

    • Removed Redis rate-limiting docs; added Stripe and per-plan env examples.

@coderabbitai
Copy link

coderabbitai bot commented Aug 27, 2025

Caution

Review failed

The pull request is closed.

Walkthrough

Introduces paginated chats and messages with infinite scroll, per-chat deletion, sidebar restructuring, and a new chat initialization/switching flow in global state. Adds Pro entitlement checks, WorkOS+Stripe upgrade and billing flows, per-plan rate limiting, related API routes, and UI updates (headers, user nav, loaders).

Changes

Cohort / File(s) Summary
Sidebar & Chat item
app/components/Sidebar.tsx, app/components/SidebarHistory.tsx, app/components/SidebarUserNav.tsx, app/components/ChatItem.tsx, app/globals.css
Sidebar refactored to MainSidebar with paginated history; new SidebarHistory and SidebarUserNav components; ChatItem adds hover/dropdown controls and delete mutation; CSS vars for sidebar text masking.
Messages, scrolling & chat page
app/components/Messages.tsx, app/hooks/useMessageScroll.ts, app/hooks/useChatHandlers.ts, app/components/chat.tsx
Messages gains top-pagination and loading UI; useMessageScroll exposes options and stopScroll; useChatHandlers now consumes global flags; chat page uses paginated messages and new initializeChat/initializeNewChat flows and MainSidebar.
Global state & entitlements
app/contexts/GlobalState.tsx, app/api/entitlements/route.ts, lib/auth/get-user-id.ts
GlobalState extended with isSwitchingChats, hasActiveChat, shouldFetchMessages, pro-plan flags, initializeChat/initializeNewChat; entitlements route refreshes WorkOS session; new getUserIDAndPro returns userId + isPro.
Convex: pagination & delete
convex/chats.ts, convex/messages.ts
Server-side pagination added for chats and messages; messages returned newest-first for load-more; updateChatTodos replaced by deleteChat that verifies ownership and deletes chat + messages.
API identity & rate limiting
app/api/chat/route.ts, lib/rate-limit.ts
Chat route uses getUserIDAndPro; checkRateLimit signature now (userId, isPro) and enforces per-plan limits via env vars.
Stripe & WorkOS subscription
app/api/stripe.ts, app/api/workos.ts, app/api/subscribe/route.ts, lib/actions/billing-portal.ts, app/hooks/useUpgrade.ts, app/components/ChatHeader.tsx
Adds Stripe and WorkOS clients; subscribe route creates/links org and Stripe customer and returns Checkout URL; billing portal action; useUpgrade hook and Upgrade-to-Pro CTA in header.
Removed / replaced UI
app/components/UserDropdownMenu.tsx
Deletes legacy UserDropdownMenu; replaced by SidebarUserNav.
Docs, env, deps
.env.local.example, README.md, package.json
Env vars split FREE/PRO rate limits and added STRIPE key; README removed Redis rate-limiting docs; added @workos-inc/node and stripe deps.
Miscellaneous
lib/moderation.ts
Changed a console.log to console.error in moderation no-results branch.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant U as User
  participant MS as Messages (UI)
  participant GS as GlobalState
  participant CQ as Convex<br/>messages.getMessagesByChatId

  U->>MS: Scroll near top
  MS->>GS: Check isSwitchingChats & paginationStatus
  alt CanLoadMore and not switching
    MS->>CQ: loadMore(28) with paginationOpts
    CQ-->>MS: page (newest-first)
    MS->>MS: prepend messages, show top loader
  else Exhausted or switching
    MS-->>U: No load
  end
Loading
sequenceDiagram
  autonumber
  participant U as User
  participant CI as ChatItem
  participant CH as convex.chats.deleteChat
  participant GS as GlobalState
  participant R as Router

  U->>CI: Click ellipsis → Delete
  CI->>CH: mutation(chatId)
  CH-->>CI: success
  alt Deleted active chat
    CI->>GS: initializeNewChat()
    CI->>R: navigate("/")
  else Deleted inactive chat
    CI-->>U: stay in place
  end
Loading
sequenceDiagram
  autonumber
  participant U as User
  participant H as ChatHeader
  participant HU as useUpgrade
  participant SUB as /api/subscribe
  participant WO as WorkOS
  participant ST as Stripe

  U->>H: Click "Upgrade to Pro"
  H->>HU: handleUpgrade()
  HU->>SUB: POST
  SUB->>WO: Find/Create org, membership
  SUB->>ST: Find/Create customer, create Checkout session
  ST-->>SUB: session.url
  SUB-->>HU: { url }
  HU->>U: router.push(url)
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

Hops on the sidebar, I nibble the threads,
Pages of chats now paginate their beds.
Snip! goes delete, then I bounce back home—
Pro stars glimmer; to Stripe we roam.
WorkOS hums, entitlements glow—I'm off to code and roam! 🐇✨

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.


📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 298d6ed and b9bf9f3.

📒 Files selected for processing (8)
  • app/api/stripe.ts (1 hunks)
  • app/api/subscribe/route.ts (1 hunks)
  • app/components/ChatItem.tsx (3 hunks)
  • app/components/Sidebar.tsx (5 hunks)
  • app/contexts/GlobalState.tsx (6 hunks)
  • app/hooks/useChatHandlers.ts (2 hunks)
  • app/hooks/useUpgrade.ts (1 hunks)
  • lib/actions/billing-portal.ts (1 hunks)
✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch daily-branch-2025-08-27

🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbit in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbit in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbit gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbit read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbit help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbit ignore or @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbit summary or @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbit or @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@vercel
Copy link

vercel bot commented Aug 27, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
hackerai Canceled Canceled Aug 29, 2025 1:31pm

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (6)
convex/chats.ts (4)

37-81: getChatById lacks ownership checks; expose only the caller’s chat.

Currently allows reading any chat by id. Either make it service-key only, or switch to user-auth and verify ownership. Since the client calls this directly, prefer user-auth.

Apply:

-export const getChatById = query({
-  args: { serviceKey: v.optional(v.string()), id: v.string() },
+export const getChatById = query({
+  args: { id: v.string() },
   returns: v.union(
     v.object({
       _id: v.id("chats"),
       _creationTime: v.number(),
       id: v.string(),
       title: v.string(),
       user_id: v.string(),
       finish_reason: v.optional(v.string()),
       todos: v.optional(
         v.array(
           v.object({
             id: v.string(),
             content: v.string(),
             status: v.union(
               v.literal("pending"),
               v.literal("in_progress"),
               v.literal("completed"),
               v.literal("cancelled"),
             ),
           }),
         ),
       ),
       update_time: v.number(),
     }),
     v.null(),
   ),
   handler: async (ctx, args) => {
-    // Verify service role key
-    validateServiceKey(args.serviceKey);
-
-    try {
-      const chat = await ctx.db
-        .query("chats")
-        .withIndex("by_chat_id", (q) => q.eq("id", args.id))
-        .first();
-      return chat || null;
+    const identity = await ctx.auth.getUserIdentity();
+    if (!identity) {
+      throw new Error("Unauthorized");
+    }
+    try {
+      const chat = await ctx.db
+        .query("chats")
+        .withIndex("by_chat_id", (q) => q.eq("id", args.id))
+        .first();
+      if (!chat) return null;
+      if (chat.user_id !== identity.subject) {
+        throw new Error("Unauthorized: Chat does not belong to user");
+      }
+      return chat;
     } catch (error) {
       console.error("Failed to get chat by id:", error);
       return null;
     }
   },
 });

Follow-up: If any server-to-server caller needs unrestricted access, add a separate internalQuery (e.g., internal.chats.getByIdService) guarded by requireServiceKey.


86-112: saveChat should be service-key only or user-auth; pick one and enforce.

Given prior learnings, this is intended for backend use. Enforce the key.

Apply:

-    // Verify service role key
-    validateServiceKey(args.serviceKey);
+    // Verify service role key
+    requireServiceKey(args.serviceKey);

Optionally, remove serviceKey entirely and require ctx.auth + ownership if this must be callable from clients.


117-189: updateChat auth model inconsistent; enforce chosen model.

Same issue as saveChat. Either require service key (backend-only) or require ctx.auth and verify ownership.

Apply:

-    // Verify service role key
-    validateServiceKey(args.serviceKey);
+    // Verify service role key
+    requireServiceKey(args.serviceKey);

If switching to user-auth, drop serviceKey arg and verify owner like deleteChat.


37-39: Restrict getChatById to authenticated users and remove the serviceKey arg

  • Verified that neither api.chats.saveChat nor api.chats.updateChat are ever imported or called in any .ts/.tsx client code; all invocations live in lib/db/actions.ts (server-only) via process.env.CONVEX_SERVICE_ROLE_KEY – so those mutations remain backend-only as intended.
  • However, getChatById in convex/chats.ts still declares args: { serviceKey: v.optional(v.string()), id: v.string() } and uses validateServiceKey alone, meaning callers without a key (i.e. unauthenticated clients) can fetch any chat by id.
  • To tighten security and align with our pattern:
    • Remove the serviceKey argument from getChatById.
    • In the handler, require ctx.auth.userId and call the existing verifyChatOwnership internal query before fetching.
    • Throw an unauthorized error if ctx.auth is missing or the chat doesn’t belong to the user.

Locations to update:

  • convex/chats.ts (around lines 37–39 and handler at ~67)
app/components/chat.tsx (2)

166-188: Switching to a chat with zero messages leaves stale messages.

Early return on empty results prevents clearing local messages when the target chat has 0 messages.

Apply:

-  useEffect(() => {
-    if (!paginatedMessages.results || paginatedMessages.results.length === 0)
-      return;
-    // Messages come from server in descending order, reverse for chronological display
-    const uiMessages = convertToUIMessages(
-      [...paginatedMessages.results].reverse(),
-    );
-    // Merge strategy: Only sync from Convex if:
-    // 1. We have no local messages (initial load)
-    // 2. Convex has more messages than local (new messages from server)
-    // 3. Message IDs differ (switching chats or real-time updates)
-    const shouldSync =
-      messages.length === 0 ||
-      uiMessages.length > messages.length ||
-      (uiMessages.length > 0 &&
-        messages.length > 0 &&
-        uiMessages[0]?.id !== messages[0]?.id);
-    if (shouldSync) {
-      setMessages(uiMessages);
-    }
-  }, [paginatedMessages.results, setMessages, messages]);
+  useEffect(() => {
+    if (!paginatedMessages.results) return;
+    // Messages come from server in descending order, reverse for chronological display
+    const uiMessages = convertToUIMessages(
+      [...paginatedMessages.results].reverse(),
+    );
+    // Sync when lengths differ or first id differs (covers switching) — also clears when server is empty
+    const shouldSync =
+      messages.length !== uiMessages.length ||
+      (uiMessages[0]?.id !== messages[0]?.id);
+    if (shouldSync) {
+      setMessages(uiMessages);
+    }
+  }, [paginatedMessages.results, setMessages, messages]);

85-89: Potential auth mismatch for getChatById—confirm backend alignment

The client‐side call in app/components/chat.tsx is invoking useQuery(api.chats.getChatById, …) under end-user credentials, whereas the server-side helper in lib/db/actions.ts still calls the same API with a serviceKey. To safely expose this query to authenticated users (and remove reliance on the service-role key), you’ll need to update your query definition in the functions layer to enforce the correct ACL rules and drop the serviceKey requirement.

• lib/db/actions.ts (lines 10–14):

export async function getChatById({ id }: { id: string }) {
  return convex.query(api.chats.getChatById, {
    serviceKey: process.env.CONVEX_SERVICE_ROLE_KEY!,
    id,
  });
}

– Internal, privileged call using the service role key.

• app/components/chat.tsx (lines 85–89):

const chatData = useQuery(
  api.chats.getChatById,
  id || currentChatId ? { id: chatId } : "skip",
);

– Public client call under user auth.

Action items:

  1. Modify your getChatById query in the functions (e.g. functions/chats.ts) to validate that the requesting user is authorized and remove any dependency on serviceKey.
  2. Ensure no other parts of the codebase still expect the serviceKey version of this query.
  3. Verify that row-level security (chat membership checks) is enforced in the query itself.
🧹 Nitpick comments (20)
app/globals.css (2)

95-110: Sidebar mask: remove duplicate stop and prep for Safari masks

There’s a duplicated “transparent 100%” stop. Also, ensure consumers set both mask-image and -webkit-mask-image for Safari.

Apply this diff to remove the duplicate stop:

   --sidebar-mask: linear-gradient(
     90deg,
     #000 0%,
     #000 95%,
-    transparent 100%,
-    transparent 100%
+    transparent 100%
   );

Example usage (outside this file):

/* Example consumer */
.sidebar-title {
  -webkit-mask-image: var(--sidebar-mask);
  mask-image: var(--sidebar-mask);
  mask-mode: luminance;
}

(Optional a11y fallback)

@media (forced-colors: active) {
  .sidebar-title {
    -webkit-mask-image: none;
    mask-image: none;
  }
}

156-170: Dark theme mask: same duplicate stop

Mirror the fix in dark theme.

Apply this diff:

   --sidebar-mask: linear-gradient(
     90deg,
     #000 0%,
     #000 95%,
-    transparent 100%,
-    transparent 100%
+    transparent 100%
   );
app/hooks/useMessageScroll.ts (1)

10-29: Unify return type to Promise for ergonomic consumption

Mixed boolean | Promise can complicate callers. Consider always returning a Promise.

Apply this diff:

-  const scrollToBottom = useCallback(
-    (options?: {
-      force?: boolean;
-      instant?: boolean;
-    }): boolean | Promise<boolean> => {
+  const scrollToBottom = useCallback(
+    (options?: {
+      force?: boolean;
+      instant?: boolean;
+    }): Promise<boolean> => {
       if (options?.instant) {
         const scrollContainer = stickToBottom.scrollRef.current;
-        if (scrollContainer) {
-          scrollContainer.scrollTop = scrollContainer.scrollHeight;
-        }
-        return true;
+        if (!scrollContainer) return Promise.resolve(false);
+        scrollContainer.scrollTop = scrollContainer.scrollHeight;
+        return Promise.resolve(true);
       }
 
-      return stickToBottom.scrollToBottom({
+      return Promise.resolve(
+        stickToBottom.scrollToBottom({
           animation: "smooth",
           preserveScrollPosition: !options?.force,
-      });
+        }),
+      );
     },
     [stickToBottom.scrollToBottom, stickToBottom.scrollRef],
   );
app/contexts/GlobalState.tsx (2)

67-69: Signature mismatch: fromRoute param declared but not implemented

Either remove the param from the type or accept it in the implementation to avoid confusion.

Apply this diff to accept (and ignore) the param for now:

-  initializeChat: (chatId: string, fromRoute?: boolean) => void;
+  initializeChat: (chatId: string, fromRoute?: boolean) => void;

And below (implementation):

-  const initializeChat = useCallback((chatId: string) => {
+  const initializeChat = useCallback((chatId: string, _fromRoute?: boolean) => {

114-123: isSwitchingChats is set but never reset here

Ensure some consumer resets setIsSwitchingChats(false) after messages load, or expose a finalize helper in context to standardize this.

Optional helper (outside this hunk):

// In interface:
completeChatSwitch: () => void;

// In provider:
const completeChatSwitch = useCallback(() => setIsSwitchingChats(false), []);

// In value:
completeChatSwitch,
app/components/SidebarUserNav.tsx (3)

50-71: Use a button for the trigger for keyboard/a11y

Div as trigger harms semantics; switch to a button.

Apply this diff:

-        <DropdownMenuTrigger asChild>
-          <div className="flex items-center gap-3 p-3 cursor-pointer hover:bg-sidebar-accent/50 rounded-md transition-colors">
+        <DropdownMenuTrigger asChild>
+          <button
+            type="button"
+            className="flex items-center gap-3 p-3 hover:bg-sidebar-accent/50 rounded-md transition-colors"
+          >
             ...
-          </div>
+          </button>

21-23: Prefer router navigation to avoid full page reload on logout

Use Next.js router push/replace for smoother UX (unless server requires hard reload).

Apply this diff:

+import { useRouter } from "next/navigation";
 ...
 const SidebarUserNav = () => {
-  const { user } = useAuth();
+  const { user } = useAuth();
+  const router = useRouter();
 ...
   const handleSignOut = async () => {
-    window.location.href = "/logout";
+    router.push("/logout");
   };

91-94: DropdownMenuItem “variant” prop may not exist

If your DropdownMenuItem doesn’t support variant, style via className instead.

Apply this diff if needed:

-          <DropdownMenuItem onClick={handleSignOut} variant="destructive">
+          <DropdownMenuItem
+            onClick={handleSignOut}
+            className="text-destructive focus:bg-destructive/10"
+          >
app/components/SidebarHistory.tsx (3)

31-55: Rebind scroll listener on element changes + use passive listener

The effect won’t reattach if containerRef.current swaps (e.g., overlay toggles). Also, prefer passive listeners for scroll.

Apply:

-  React.useEffect(() => {
-    const handleScroll = () => {
+  React.useEffect(() => {
+    const handleScroll = () => {
       if (
         !containerRef?.current ||
         !loadMore ||
         paginationStatus !== "CanLoadMore"
       ) {
         return;
       }
 
       const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
 
       // Check if we're near the bottom (within 100px)
       if (scrollTop + clientHeight >= scrollHeight - 100) {
-        loadMore(28); // Load 28 more chats
+        loadMore(PAGE_SIZE); // Load more chats
       }
     };
 
-    const container = containerRef?.current;
-    if (container) {
-      container.addEventListener("scroll", handleScroll);
-      return () => container.removeEventListener("scroll", handleScroll);
-    }
-  }, [containerRef, loadMore, paginationStatus]);
+    const el = containerRef?.current;
+    if (!el) return;
+    el.addEventListener("scroll", handleScroll, { passive: true });
+    return () => el.removeEventListener("scroll", handleScroll);
+  }, [containerRef?.current, loadMore, paginationStatus]);

9-20: Tighten prop types; avoid any[]; refine ref typing

Define a minimal chat shape and simplify ref typing.

-interface SidebarHistoryProps {
-  chats: any[];
+type ChatListItem = { _id: string; id: string; title: string };
+interface SidebarHistoryProps {
+  chats: ChatListItem[];
   currentChatId: string | null;
   handleNewChat: () => void;
   paginationStatus?:
     | "LoadingFirstPage"
     | "CanLoadMore"
     | "LoadingMore"
     | "Exhausted";
   loadMore?: (numItems: number) => void;
-  containerRef?: React.RefObject<HTMLDivElement | null>;
+  containerRef?: React.RefObject<HTMLDivElement>;
 }

22-29: De-duplicate magic number with a PAGE_SIZE constant

Promote 28 to a constant for consistency with other components.

 const SidebarHistory: React.FC<SidebarHistoryProps> = ({
@@
-      if (scrollTop + clientHeight >= scrollHeight - 100) {
-        loadMore(28); // Load 28 more chats
+      if (scrollTop + clientHeight >= scrollHeight - 100) {
+        loadMore(PAGE_SIZE);
       }

Add near the top (module scope):

+"use client";
+
+const PAGE_SIZE = 28;

Also applies to: 43-46

app/components/Messages.tsx (2)

127-135: Scroll listener may not attach on ref changes + use passive listener

If scrollRef.current changes after first render, the listener won’t rebind. Also use a passive listener.

-  useEffect(() => {
-    const scrollElement = scrollRef.current;
-    if (!scrollElement) return;
-
-    scrollElement.addEventListener("scroll", handleScroll);
-    return () => scrollElement.removeEventListener("scroll", handleScroll);
-  }, [handleScroll]);
+  useEffect(() => {
+    const el = scrollRef.current;
+    if (!el) return;
+    el.addEventListener("scroll", handleScroll, { passive: true });
+    return () => el.removeEventListener("scroll", handleScroll);
+  }, [scrollRef?.current, handleScroll]);

108-126: Guard against burst loadMore and centralize page size

Minor: centralize 28 and keep the trigger minimal.

-import Loading from "@/components/ui/loading";
+import Loading from "@/components/ui/loading";
+const PAGE_SIZE = 28;
@@
-    if (scrollTop < 100) {
-      loadMore(28); // Load 28 more messages
+    if (scrollTop < 100) {
+      loadMore(PAGE_SIZE);
     }

Also applies to: 121-124

app/components/Sidebar.tsx (1)

72-76: Avoid hardcoding initialNumItems across the app

Extract PAGE_SIZE for consistency with SidebarHistory and Messages.

-import { usePaginatedQuery } from "convex/react";
+import { usePaginatedQuery } from "convex/react";
+const PAGE_SIZE = 28;
@@
-  const paginatedChats = usePaginatedQuery(
+  const paginatedChats = usePaginatedQuery(
     api.chats.getUserChats,
     user ? {} : "skip",
-    { initialNumItems: 28 },
+    { initialNumItems: PAGE_SIZE },
   );
app/components/ChatItem.tsx (2)

18-22: Remove unused isActive prop; rely on global state

The local prop is unused; prefer single source of truth.

ChatItem:

-interface ChatItemProps {
+interface ChatItemProps {
   id: string;
   title: string;
-  isActive?: boolean;
 }
@@
-const ChatItem: React.FC<ChatItemProps> = ({ id, title, isActive = false }) => {
+const ChatItem: React.FC<ChatItemProps> = ({ id, title }) => {

SidebarHistory:

         <ChatItem
           key={chat._id}
           id={chat.id}
           title={chat.title}
-          isActive={currentChatId === chat.id}
         />

Also applies to: 24-24, 95-101


55-70: Add delete confirmation + user feedback

Prevent accidental deletions and surface outcomes.

+import { toast } from "sonner";
@@
-  const handleDelete = async (e: React.MouseEvent) => {
+  const handleDelete = async (e: React.MouseEvent) => {
     e.preventDefault();
     e.stopPropagation();
 
     try {
+      if (!window.confirm("Delete this chat? This cannot be undone.")) return;
       await deleteChat({ chatId: id });
+      toast.success("Chat deleted");
 
       // If we're deleting the currently active chat, navigate to home
       if (isCurrentlyActive) {
         initializeNewChat();
         router.push("/");
       }
     } catch (error) {
       console.error("Failed to delete chat:", error);
+      toast.error("Failed to delete chat");
     }
   };

I can swap this to an AlertDialog if you prefer.

convex/messages.ts (1)

74-80: Return shape: prefer continueCursor: undefined for empty results

Avoid inventing an empty string; align with Convex’s paginated types.

       return {
         page: [],
         isDone: true,
-        continueCursor: "",
+        continueCursor: undefined,
       };
@@
       return {
         page: [],
         isDone: true,
-        continueCursor: "",
+        continueCursor: undefined,
       };

Also applies to: 99-105

convex/chats.ts (3)

191-229: Pagination return shape OK; minor polish.

  • Comment says “Transform the page…” but code returns result untouched. Either transform or update the comment.
  • Consider adding an explicit returns schema for consistency.

Possible tidy:

-      // Transform the page data to include only needed fields
-      return result;
+      // Return paginated chats for the authenticated user
+      return result;

233-281: deleteChat correctness OK; improve scalability and reduce double fetch.

  • You fetch the chat twice (verifyChatOwnership + query). Return the doc’s _id from verifyChatOwnership to avoid re-query, or inline the ownership check here.
  • For large chats, deleting after collect() can load many docs at once. Prefer chunked pagination.

Apply:

-      // Verify chat ownership
-      await ctx.runQuery(internal.chats.verifyChatOwnership, {
+      // Verify chat ownership
+      await ctx.runQuery(internal.chats.verifyChatOwnership, {
         chatId: args.chatId,
         userId: user.subject,
       });
-
-      // Find the chat
-      const chat = await ctx.db
-        .query("chats")
-        .withIndex("by_chat_id", (q) => q.eq("id", args.chatId))
-        .first();
+      // Find the chat (to get _id)
+      const chat = await ctx.db
+        .query("chats")
+        .withIndex("by_chat_id", (q) => q.eq("id", args.chatId))
+        .first();
@@
-      // Delete all messages associated with this chat
-      const messages = await ctx.db
-        .query("messages")
-        .withIndex("by_chat_id", (q) => q.eq("chat_id", args.chatId))
-        .collect();
-
-      for (const message of messages) {
-        await ctx.db.delete(message._id);
-      }
+      // Delete messages in chunks to limit memory usage
+      while (true) {
+        const page = await ctx.db
+          .query("messages")
+          .withIndex("by_chat_id", (q) => q.eq("chat_id", args.chatId))
+          .order("asc")
+          .take(256);
+        if (page.length === 0) break;
+        for (const m of page) {
+          await ctx.db.delete(m._id);
+        }
+      }

Optional: Change verifyChatOwnership to return the chat _id so the extra chat fetch isn’t needed.


12-32: Minor: internal verifyChatOwnership could return the chat id to avoid re-query.

Apply:

-  returns: v.null(),
+  returns: v.object({ _id: v.id("chats") }),
@@
-    return null;
+    return { _id: chat._id };

Then use the returned _id in deleteChat.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 8510a87 and ed283ad.

📒 Files selected for processing (13)
  • app/components/ChatItem.tsx (3 hunks)
  • app/components/Messages.tsx (4 hunks)
  • app/components/Sidebar.tsx (6 hunks)
  • app/components/SidebarHistory.tsx (1 hunks)
  • app/components/SidebarUserNav.tsx (1 hunks)
  • app/components/UserDropdownMenu.tsx (0 hunks)
  • app/components/chat.tsx (9 hunks)
  • app/contexts/GlobalState.tsx (6 hunks)
  • app/globals.css (2 hunks)
  • app/hooks/useChatHandlers.ts (1 hunks)
  • app/hooks/useMessageScroll.ts (1 hunks)
  • convex/chats.ts (3 hunks)
  • convex/messages.ts (4 hunks)
💤 Files with no reviewable changes (1)
  • app/components/UserDropdownMenu.tsx
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-08-27T12:38:06.615Z
Learnt from: RostyslavManko
PR: hackerai-tech/hackerai#14
File: convex/chats.ts:4-24
Timestamp: 2025-08-27T12:38:06.615Z
Learning: Convex functions with serviceKey parameters are designed for backend service-to-service communication and don't require ctx.auth.getUserIdentity() checks. The serviceKey validation against process.env.CONVEX_SERVICE_ROLE_KEY provides sufficient authentication for these backend operations.

Applied to files:

  • convex/chats.ts
🧬 Code graph analysis (9)
app/components/SidebarUserNav.tsx (2)
components/ui/dropdown-menu.tsx (6)
  • DropdownMenu (242-242)
  • DropdownMenuTrigger (244-244)
  • DropdownMenuContent (245-245)
  • DropdownMenuLabel (247-247)
  • DropdownMenuSeparator (252-252)
  • DropdownMenuItem (248-248)
components/ui/avatar.tsx (3)
  • Avatar (53-53)
  • AvatarImage (53-53)
  • AvatarFallback (53-53)
app/components/SidebarHistory.tsx (1)
components/ui/loading.tsx (1)
  • Loading (7-14)
app/components/Messages.tsx (1)
components/ui/loading.tsx (1)
  • Loading (7-14)
app/hooks/useChatHandlers.ts (1)
app/contexts/GlobalState.tsx (1)
  • useGlobalState (202-208)
app/components/ChatItem.tsx (4)
app/contexts/GlobalState.tsx (1)
  • useGlobalState (202-208)
hooks/use-mobile.ts (1)
  • useIsMobile (5-21)
convex/chats.ts (1)
  • deleteChat (233-281)
convex/_generated/api.js (2)
  • api (21-21)
  • api (21-21)
app/components/Sidebar.tsx (1)
app/contexts/GlobalState.tsx (1)
  • useGlobalState (202-208)
convex/messages.ts (1)
convex/_generated/server.js (2)
  • query (29-29)
  • query (29-29)
app/components/chat.tsx (4)
app/contexts/GlobalState.tsx (1)
  • useGlobalState (202-208)
lib/utils.ts (1)
  • convertToUIMessages (34-42)
app/hooks/useMessageScroll.ts (1)
  • useMessageScroll (4-38)
app/hooks/useChatHandlers.ts (1)
  • useChatHandlers (20-148)
convex/chats.ts (2)
convex/_generated/server.js (4)
  • query (29-29)
  • query (29-29)
  • mutation (49-49)
  • mutation (49-49)
convex/_generated/api.js (2)
  • internal (22-22)
  • internal (22-22)
🔇 Additional comments (25)
app/hooks/useMessageScroll.ts (3)

5-8: Good defaults for stick-to-bottom behavior


28-29: Confirm dependency stability

If useStickToBottom returns stable method refs, this is fine. If not, depend on the object or expose the method via useMemo in the lib to avoid stale closures.


32-37: Return shape looks good

Forwarding refs and stopScroll aligns with the new API.

app/contexts/GlobalState.tsx (6)

31-40: New chat state flags: LGTM


86-89: Initial values are sane


110-112: resetChat now clears activity/fetch flags

Nice catch to avoid stale fetches.


125-132: initializeNewChat: LGTM


166-172: Exposing new flags and setters: LGTM


191-193: New init methods exposed: LGTM

app/hooks/useChatHandlers.ts (1)

29-40: No remaining prop usages; hook callers updated correctly

Verified that all invocations of useChatHandlers only pass the expected chatId and messages arguments—none of the deprecated flags (shouldFetchMessages, setShouldFetchMessages, setHasActiveChat, etc.) are still being supplied as props. Specifically:

  • app/components/chat.tsx at line 204: only chatId and messages are passed to useChatHandlers (confirmed via ripgrep)
  • The removed flags now live solely in GlobalState.tsx and are consumed internally by the hook

Since no callers remain that pass the old props, the refactor is complete and changes can be approved.

app/components/SidebarUserNav.tsx (1)

53-59: Verify WorkOS user fields

Confirm profilePictureUrl and email property names match the WorkOS user object to avoid undefined values.

app/components/SidebarHistory.tsx (1)

94-101: Chat list rendering looks good

Keys, props, and active state wiring are correct.

app/components/Messages.tsx (2)

142-147: Top loading indicator integration is solid

Good UX signal during pagination.


82-89: Confirm message ordering vs. UI expectations

Backend returns newest-first; this component maps messages as-is. Ensure upstream normalizes order to chronological for chat UX and for findLastAssistantMessageIndex assumptions.

Would you like me to add a small adapter to reverse pages before render if needed?

app/components/Sidebar.tsx (1)

114-122: SidebarHistory wiring LGTM

Passing results, status, loadMore, and the scroll container ref aligns with the pagination design.

Also applies to: 145-153

app/components/ChatItem.tsx (1)

50-53: Confirm initializeChat(id, true) signature/semantics

Ensure the second arg is expected and correct; otherwise align with the new API.

convex/messages.ts (2)

55-59: Args validation for pagination is correct

Using paginationOptsValidator is the right approach.


82-89: Newest-first ordering: verify UI handles display order

Since results are desc, ensure the client renders chronologically or reverses as needed to keep newest at bottom and avoid loader/index logic drift.

I can add a small adapter on the client to normalize order; say the word.

convex/chats.ts (1)

3-5: Heads-up: import of paginationOptsValidator and internal is correct. LGTM.

app/components/chat.tsx (6)

77-83: Paginated messages integration looks good.

Using usePaginatedQuery with initialNumItems: 28 and passing status/loadMore to Messages is clean.


150-153: Title sync precedence is correct. LGTM.

Server title always winning avoids UI drift.


194-201: Instant scroll-on-switch is correct.

Good UX improvement with force+instant then resetting the flag.


268-271: Messages props: new pagination contract wired correctly.

Passing paginationStatus/loadMore/isSwitchingChats aligns with the updated Messages signature.


235-236: Sidebar replacement LGTM.

MainSidebar integration on desktop and mobile overlay looks consistent.


54-75: Initialization helpers are idempotent and guards prevent redundant calls

I’ve confirmed that both initializeChat and initializeNewChat simply reset global state to fixed values—repeated invocations leave state unchanged (e.g., setting flags to true/false or clearing arrays to empty) and thus are idempotent. Combined with the hasInitializedRouteId and hasInitializedNewChat guards in the effect, redundant initializations cannot occur. No further changes are needed.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
app/api/chat/route.ts (2)

36-38: Propagate client abort to the stream.

Currently logs only; abort the controller so downstream respects cancellation.

-  req.signal.addEventListener("abort", () => {
-    console.log("Request aborted");
-  });
+  req.signal.addEventListener("abort", () => {
+    console.log("Request aborted");
+    controller.abort("client aborted");
+  });

41-53: Validate request payload and guard empty messages.

Prevent crashes in handleInitialChatAndUserMessage when messages is empty and regenerate is false.

   } = await req.json();
 
-    const { userId, isPro } = await getUserIDAndPro(req);
+    if (!Array.isArray(messages)) {
+      throw new ChatSDKError(
+        "bad_request:chat",
+        "'messages' must be an array."
+      );
+    }
+    if (!regenerate && messages.length === 0) {
+      throw new ChatSDKError(
+        "bad_request:chat",
+        "'messages' cannot be empty unless regenerate=true."
+      );
+    }
+
+    const { userId, isPro } = await getUserIDAndPro(req);

Also applies to: 61-67

app/hooks/useChatHandlers.ts (1)

51-60: Use hasActiveChat instead of messages.length === 0

This avoids misclassification when messages are not yet fetched.

-      if (messages.length === 0) {
+      if (!hasActiveChat) {
         setChatTitle(null);
         setCurrentChatId(chatId);
         window.history.replaceState({}, "", `/c/${chatId}`);
         if (!shouldFetchMessages) {
           setShouldFetchMessages(true);
         }
         setHasActiveChat(true);
       }
🧹 Nitpick comments (13)
.env.local.example (2)

41-42: Rate limit keys: document defaults.

Consider adding commented example values so developers know expected magnitudes, e.g., PRO_RATE_LIMIT_REQUESTS=200, FREE_RATE_LIMIT_REQUESTS=50.


53-53: Add trailing newline.

dotenv-linter flagged missing EOF newline. Apply:

-# STRIPE_SECRET_KEY=
+# STRIPE_SECRET_KEY=
+
lib/auth/get-user-id.ts (1)

12-22: De-duplicate session fetch: implement getUserID via getUserIDAndPro.

Avoid repeating authkit/session logic and keep behavior in one place.

-export const getUserID = async (req: NextRequest): Promise<string> => {
-  try {
-    const { authkit } = await import("@workos-inc/authkit-nextjs");
-    const { session } = await authkit(req);
-
-    if (!session?.user?.id) {
-      throw new ChatSDKError("unauthorized:auth");
-    }
-
-    return session.user.id;
-  } catch (error) {
-    if (error instanceof ChatSDKError) {
-      throw error;
-    }
-
-    console.error("Failed to get user session:", error);
-    throw new ChatSDKError("unauthorized:auth");
-  }
-};
+export const getUserID = async (req: NextRequest): Promise<string> => {
+  const { userId } = await getUserIDAndPro(req);
+  return userId;
+};

Also applies to: 40-43

app/api/chat/route.ts (1)

98-116: Optional: guard analytics errors.

Wrap PostHog capture with try/catch to avoid impacting tool calls.

-              if (posthog) {
-                posthog.capture({
-                  distinctId: userId,
-                  event: "hackerai-" + chunk.chunk.toolName,
-                });
-              }
+              if (posthog) {
+                try {
+                  posthog.capture({
+                    distinctId: userId,
+                    event: "hackerai-" + chunk.chunk.toolName,
+                  });
+                } catch {}
+              }
app/components/SidebarUpgrade.tsx (1)

61-67: Accessibility: reflect loading on the button.

Expose loading state to assistive tech.

       <Button
         onClick={handleSubscribe}
         disabled={loading}
+        aria-busy={loading}
+        aria-live="polite"
         className="w-full bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700 text-white border-0 shadow-sm"
         size="sm"
       >
app/components/SidebarUserNav.tsx (1)

96-100: Verify DropdownMenuItem supports variant="destructive".

If not supported in your UI lib, switch to a class-based style.

-          <DropdownMenuItem onClick={handleSignOut} variant="destructive">
+          <DropdownMenuItem
+            onClick={handleSignOut}
+            className="text-destructive focus:text-destructive"
+          >
             <LogOut className="mr-2 h-4 w-4" />
             <span>Log out</span>
           </DropdownMenuItem>
app/hooks/useChatHandlers.ts (2)

97-107: Add error handling around regeneration

Network or mutation failures currently throw and leave UI in an odd state. Wrap in try/catch and log.

-  const handleRegenerate = async () => {
-    await deleteLastAssistantMessage({ chatId });
-
-    regenerate({
-      body: {
-        mode,
-        todos,
-        regenerate: true,
-      },
-    });
-  };
+  const handleRegenerate = async () => {
+    try {
+      await deleteLastAssistantMessage({ chatId });
+      regenerate({
+        body: {
+          mode,
+          todos,
+          regenerate: true,
+        },
+      });
+    } catch (error) {
+      console.error("Failed to regenerate:", error);
+    }
+  };

123-127: Preserve non-text parts when editing a message

Overwriting parts to a single text part can drop attachments/inline elements. Update the first text part if present; otherwise fallback to a single text part.

-      updatedMessages[editedMessageIndex] = {
-        ...updatedMessages[editedMessageIndex],
-        parts: [{ type: "text", text: newContent }],
-      };
+      const old = updatedMessages[editedMessageIndex];
+      updatedMessages[editedMessageIndex] = {
+        ...old,
+        parts:
+          old.parts?.map((p, i) =>
+            p.type === "text" && i === 0 ? { ...p, text: newContent } : p,
+          ) ?? [{ type: "text", text: newContent }],
+      };
app/api/subscribe/route.ts (1)

24-31: Selecting the first membership may bind the wrong org

If a user belongs to multiple orgs, reusing the first membership is arbitrary. Prefer an org already linked to a Stripe customer (stripeCustomerId) or allow the client to specify target org.

I can add a filter to prefer organizations with organization.stripeCustomerId or expose org selection from the UI.

app/contexts/GlobalState.tsx (2)

135-142: Deduplicate chat reset logic via initializeNewChat

resetChat largely mirrors initializeNewChat. Reuse it and keep a single source of truth.

   const resetChat = () => {
-    setInput("");
-    setChatTitle(null);
-    setTodos([]);
-    setIsTodoPanelExpanded(false);
-    setHasActiveChat(false);
-    setShouldFetchMessages(false);
+    setInput("");
+    initializeNewChat();
   };

187-225: Stabilize context value to reduce downstream re-renders

value is recreated every render. Wrap it in useMemo or split the context to hot/cold slices if perf becomes an issue.

I can provide a targeted useMemo refactor if you see render thrash in the sidebar/messages trees.

app/components/Sidebar.tsx (2)

31-38: Add accessible labels to the sidebar toggle button

       <Button
         variant="ghost"
         size="sm"
         className="h-7 w-7 p-0"
         onClick={handleCloseSidebar}
+        aria-label="Close sidebar"
       >

41-49: Add accessible label to “New Chat” button

       <Button
         onClick={handleNewChat}
         variant="ghost"
         size="sm"
         className="h-8 w-8 p-0"
         title="New Chat"
+        aria-label="New chat"
       >
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between ed283ad and 9669112.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (15)
  • .env.local.example (1 hunks)
  • README.md (0 hunks)
  • app/api/chat/route.ts (4 hunks)
  • app/api/stripe.ts (1 hunks)
  • app/api/subscribe/route.ts (1 hunks)
  • app/api/workos.ts (1 hunks)
  • app/components/Sidebar.tsx (6 hunks)
  • app/components/SidebarUpgrade.tsx (1 hunks)
  • app/components/SidebarUserNav.tsx (1 hunks)
  • app/contexts/GlobalState.tsx (7 hunks)
  • app/hooks/useChatHandlers.ts (1 hunks)
  • lib/auth/get-user-id.ts (1 hunks)
  • lib/auth/refresh-session.ts (1 hunks)
  • lib/rate-limit.ts (2 hunks)
  • package.json (2 hunks)
💤 Files with no reviewable changes (1)
  • README.md
🧰 Additional context used
🧬 Code graph analysis (8)
app/components/SidebarUpgrade.tsx (1)
app/contexts/GlobalState.tsx (1)
  • useGlobalState (234-240)
app/components/SidebarUserNav.tsx (1)
app/contexts/GlobalState.tsx (1)
  • useGlobalState (234-240)
app/api/subscribe/route.ts (1)
lib/auth/get-user-id.ts (1)
  • getUserID (12-30)
lib/auth/get-user-id.ts (1)
lib/errors.ts (1)
  • ChatSDKError (36-74)
app/contexts/GlobalState.tsx (3)
types/chat.ts (3)
  • SidebarContent (24-24)
  • ChatMode (3-3)
  • Todo (38-42)
lib/utils/todo-utils.ts (1)
  • mergeTodos (11-37)
lib/auth/refresh-session.ts (1)
  • refreshAuthkitSession (5-8)
app/hooks/useChatHandlers.ts (1)
app/contexts/GlobalState.tsx (1)
  • useGlobalState (234-240)
app/api/chat/route.ts (4)
lib/auth/get-user-id.ts (1)
  • getUserIDAndPro (40-64)
lib/rate-limit.ts (1)
  • checkRateLimit (6-68)
lib/db/actions.ts (1)
  • handleInitialChatAndUserMessage (66-129)
lib/ai/tools/index.ts (1)
  • createTools (17-73)
app/components/Sidebar.tsx (1)
app/contexts/GlobalState.tsx (1)
  • useGlobalState (234-240)
🪛 dotenv-linter (3.3.0)
.env.local.example

[warning] 53-53: [EndingBlankLine] No blank line at the end of the file

(EndingBlankLine)

🔇 Additional comments (8)
lib/auth/get-user-id.ts (1)

51-56: Confirm entitlement shape—no change needed
session.entitlements is exposed as a string[] per AuthKit, so using session.entitlements || [] and checking includes is correct.

lib/rate-limit.ts (1)

33-41: No changes needed: reset is epoch ms, no resetAfter, and “5 h” is valid.

Upstash’s limit().reset returns a Unix timestamp in milliseconds, so the current calculation of timeDiff, hours, and minutes is correct. There is no resetAfter property on the API—compute any TTL from reset if needed. The window string "5 h" is a valid format.

app/api/chat/route.ts (1)

55-60: LGTM on identity propagation and rate-limiting usage.

Using getUserIDAndPro and passing userId consistently through the flow looks good.

Also applies to: 69-77, 98-107

app/components/SidebarUpgrade.tsx (1)

52-57: LGTM on conditional rendering for auth/pro.

Rendering nothing for unauthenticated or already-pro users is appropriate.

Also applies to: 59-83

app/components/SidebarUserNav.tsx (1)

17-25: LGTM on user display and status.

Initials/name fallbacks and “Pro/Free” status wiring to global state look clean.

Also applies to: 66-73

app/api/subscribe/route.ts (1)

37-41: Confirm WorkOS role slug

"admin" must exist as a role slug in your WorkOS project. If it doesn’t, membership creation will fail at runtime.

Would you like me to make the role configurable via env (e.g., WORKOS_DEFAULT_ROLE_SLUG) and add a fallback?

app/contexts/GlobalState.tsx (1)

144-154: isSwitchingChats never reset here

You set setIsSwitchingChats(true) but don’t clear it in this provider. Ensure some consumer resets it to false after initial messages load; otherwise components depending on it may degrade UX.

I can wire a small hook in Messages.tsx to clear it after the first successful fetch if you want.

app/components/Sidebar.tsx (1)

73-77: Skip pattern for usePaginatedQuery is likely incorrect

Passing the string "skip" as args is probably not supported by Convex and may cause type/runtime errors. Prefer undefined (or the official skip token, if provided by your Convex version).

   const paginatedChats = usePaginatedQuery(
     api.chats.getUserChats,
-    user ? {} : "skip",
+    user ? {} : undefined,
     { initialNumItems: 28 },
   );

If Convex exposes a skip symbol, use that instead (e.g., import { skipToken } from the lib).

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

♻️ Duplicate comments (2)
lib/auth/get-user-id.ts (1)

102-105: Harden entitlement parsing and make plan key configurable

Coerce to a string array and read the plan key from env to tolerate shape changes and renames.

-    // Check if user has pro entitlements
-    const entitlements = session.entitlements || [];
-    const isPro = entitlements.includes("pro-monthly-plan");
+    // Check if user has pro entitlements
+    const PRO_ENTITLEMENT_KEY =
+      process.env.PRO_ENTITLEMENT_KEY ?? "pro-monthly-plan";
+    const rawEntitlements = (session as any)?.entitlements;
+    const entitlements: string[] = Array.isArray(rawEntitlements)
+      ? rawEntitlements.map(String)
+      : [];
+    const isPro = entitlements.includes(PRO_ENTITLEMENT_KEY);
app/components/SidebarUserNav.tsx (1)

58-60: Use a semantic button for the trigger.

Replace the clickable div with a <button> to improve accessibility and keyboard support.

-        <DropdownMenuTrigger asChild>
-          <div className="flex items-center gap-3 p-3 cursor-pointer hover:bg-sidebar-accent/50 rounded-md transition-colors">
+        <DropdownMenuTrigger asChild>
+          <button
+            type="button"
+            aria-label="User menu"
+            className="flex items-center gap-3 p-3 cursor-pointer hover:bg-sidebar-accent/50 rounded-md transition-colors"
+          >-          </div>
+          </button>
🧹 Nitpick comments (14)
lib/auth/get-user-id.ts (2)

3-3: Remove stale commented import

Drop the commented WorkOS import to avoid confusion.

-// import { WorkOS } from "@workos-inc/node";

41-82: Delete large commented-out legacy implementation

Dead code adds noise and risks divergence. Remove or move to internal docs.

-// export const getUserIDAndPro = async (
-//   req: NextRequest,
-// ): Promise<{ userId: string; isPro: boolean }> => {
-//   ...
-// };
app/api/entitlements/route.ts (2)

31-36: Mark response as non-cacheable

Avoid stale entitlement states in intermediaries.

-    const response = NextResponse.json({
-      entitlements: entitlements || [],
-      hasProPlan,
-    });
+    const response = NextResponse.json(
+      { entitlements: Array.isArray(entitlements) ? entitlements : [], hasProPlan },
+      { headers: { "Cache-Control": "no-store" } },
+    );

39-44: Tighten cookie options

Keep httpOnly/sameSite; make secure conditional for local dev and set path.

-      response.cookies.set("wos-session", sealedSession, {
-        httpOnly: true,
-        sameSite: "lax",
-        secure: true,
-      });
+      response.cookies.set("wos-session", sealedSession, {
+        httpOnly: true,
+        sameSite: "lax",
+        secure: process.env.NODE_ENV === "production",
+        path: "/",
+      });
app/contexts/GlobalState.tsx (3)

73-75: Align initializeChat signature with type (second param)

Either drop fromRoute from the type or accept it in the impl to avoid confusion at call sites.

-  initializeChat: (chatId: string, fromRoute?: boolean) => void;
+  initializeChat: (chatId: string, fromRoute?: boolean) => void;
@@
-  const initializeChat = useCallback((chatId: string) => {
+  const initializeChat = useCallback((chatId: string, _fromRoute?: boolean) => {
     setIsSwitchingChats(true);

Also applies to: 163-170


110-149: Prevent state updates after unmount in entitlement fetch

Add a cancellation flag.

   useEffect(() => {
-    const checkProPlan = async () => {
+    let cancelled = false;
+    const checkProPlan = async () => {
       if (user) {
         setIsCheckingProPlan(true);
         try {
           const response = await fetch("/api/entitlements", {
             credentials: "include", // Ensure cookies are sent
           });
@@
-          const data = await response.json();
-          setHasProPlan(data.hasProPlan || false);
+          const data = await response.json();
+          if (!cancelled) setHasProPlan(!!data.hasProPlan);
         } catch (error) {
           console.error(
             "💥 [GlobalState] Failed to fetch entitlements:",
             error,
           );
-          setHasProPlan(false);
+          if (!cancelled) setHasProPlan(false);
         } finally {
-          setIsCheckingProPlan(false);
+          if (!cancelled) setIsCheckingProPlan(false);
         }
       } else {
         setHasProPlan(false);
         setIsCheckingProPlan(false);
       }
     };
 
     checkProPlan();
+    return () => {
+      cancelled = true;
+    };
   }, [user]);

172-178: Also reset isSwitchingChats in initializeNewChat

Same rationale as resetChat.

   const initializeNewChat = useCallback(() => {
+    setIsSwitchingChats(false);
     setCurrentChatId(null);
     setShouldFetchMessages(false);
     setHasActiveChat(false);
     setTodos([]);
     setIsTodoPanelExpanded(false);
   }, []);
app/components/ChatHeader.tsx (2)

84-101: Use Next.js router for client navigation instead of window.location

This preserves SPA behavior and avoids full reloads.

-import React from "react";
+import React from "react";
+import { useRouter } from "next/navigation";
@@
-  const handleSignIn = () => {
-    window.location.href = "/login";
-  };
-
-  const handleSignUp = () => {
-    window.location.href = "/signup";
-  };
+  const router = useRouter();
+  const handleSignIn = () => router.push("/login");
+  const handleSignUp = () => router.push("/signup");

Also applies to: 143-161


58-78: DRY the duplicated upgrade CTA (desktop/mobile)

Extract the button into a small local component to reduce duplication and keep styles in sync.

Example (add near top of file):

const UpgradeCta: React.FC<{
  loading: boolean;
  onClick: () => void;
  size?: "sm" | "default";
}> = ({ loading, onClick, size = "default" }) => (
  <Button
    onClick={onClick}
    disabled={loading}
    className="flex items-center gap-1 rounded-full py-2 ps-2.5 pe-3 text-sm font-medium bg-[#F1F1FB] text-[#5D5BD0] hover:bg-[#E4E4F6] dark:bg-[#373669] dark:text-[#DCDBF6] dark:hover:bg-[#414071] border-0 transition-all duration-200"
    size={size}
  >
    {loading ? (
      <>
        <Loader2 className={size === "sm" ? "mr-1 h-3 w-3 animate-spin" : "mr-2 h-4 w-4 animate-spin"} />
        Upgrading...
      </>
    ) : (
      <>
        <Sparkle className={size === "sm" ? "mr-1 h-3 w-3 fill-current" : "mr-2 h-4 w-4 fill-current"} />
        Upgrade to Pro
      </>
    )}
  </Button>
);

Then replace both inline button blocks with:

<UpgradeCta loading={upgradeLoading} onClick={handleUpgrade} size="default" />

and

<UpgradeCta loading={upgradeLoading} onClick={handleUpgrade} size="sm" />

Also applies to: 119-139

lib/actions/billing-portal.ts (2)

3-7: Prefer SDK over manual fetch to WorkOS.

If app/api/workos.ts exports the official WorkOS SDK client, use its Organizations API (e.g., workos.organizations.getOrganization(organizationId)) to avoid relying on baseURL internals and hand-rolled headers. This reduces surface for subtle bugs and keeps auth handling consistent. I can provide a patch once I see the client shape in app/api/workos.ts.


19-23: Harden return_url handling.

Build URLs with new URL() to avoid malformed returns and double slashes; also consider preserving the current pathname when relevant (e.g., billing/account page) to improve UX after returning from Stripe.

app/components/SidebarUserNav.tsx (3)

115-118: variant="destructive" likely unsupported on DropdownMenuItem.

Shadcn’s DropdownMenuItem doesn’t accept a variant prop. Use a class or a dedicated Destructive style token.

-          <DropdownMenuItem onClick={handleSignOut} variant="destructive">
+          <DropdownMenuItem onClick={handleSignOut} className="text-destructive focus:text-destructive">
             <LogOut className="mr-2 h-4 w-4" />
             <span>Log out</span>
           </DropdownMenuItem>

29-31: Prefer auth provider sign-out over hard redirect.

If WorkOS/AuthKit exposes a sign-out helper, use it to clear session cookies and revoke tokens; otherwise a GET to /logout must exist server-side.

-  const handleSignOut = async () => {
-    window.location.href = "/logout";
-  };
+  const handleSignOut = async () => {
+    window.location.href = "/logout"; // replace with SDK signOut() if available
+  };

21-28: Edge case: Pro label may flicker while checking entitlements.

While isCheckingProPlan is true, consider rendering a skeleton/spinner or deferring the "Pro/Free" label to avoid brief incorrect status.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 9669112 and 298d6ed.

📒 Files selected for processing (10)
  • app/api/entitlements/route.ts (1 hunks)
  • app/api/subscribe/route.ts (1 hunks)
  • app/components/ChatHeader.tsx (4 hunks)
  • app/components/Sidebar.tsx (6 hunks)
  • app/components/SidebarUserNav.tsx (1 hunks)
  • app/contexts/GlobalState.tsx (6 hunks)
  • app/hooks/useUpgrade.ts (1 hunks)
  • lib/actions/billing-portal.ts (1 hunks)
  • lib/auth/get-user-id.ts (2 hunks)
  • lib/moderation.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • app/api/subscribe/route.ts
  • app/components/Sidebar.tsx
🧰 Additional context used
🧬 Code graph analysis (5)
app/components/SidebarUserNav.tsx (3)
app/contexts/GlobalState.tsx (1)
  • useGlobalState (251-257)
app/hooks/useUpgrade.ts (1)
  • useUpgrade (5-50)
lib/actions/billing-portal.ts (1)
  • redirectToBillingPortal (8-26)
lib/actions/billing-portal.ts (2)
app/api/workos.ts (1)
  • workos (7-7)
app/api/stripe.ts (1)
  • stripe (5-5)
lib/auth/get-user-id.ts (1)
lib/errors.ts (1)
  • ChatSDKError (36-74)
app/contexts/GlobalState.tsx (2)
types/chat.ts (3)
  • SidebarContent (24-24)
  • ChatMode (3-3)
  • Todo (38-42)
lib/utils/todo-utils.ts (1)
  • mergeTodos (11-37)
app/components/ChatHeader.tsx (2)
app/contexts/GlobalState.tsx (1)
  • useGlobalState (251-257)
app/hooks/useUpgrade.ts (1)
  • useUpgrade (5-50)
🔇 Additional comments (2)
lib/moderation.ts (1)

33-33: LGTM: error-level log is appropriate for empty moderation results

Severity bump is sensible in this failure path.

app/components/SidebarUserNav.tsx (1)

108-113: Remove config verification — Server Actions stable by default.
Next.js 15.5.0 ships with stable Server Actions enabled by default; no experimental.serverActions flag is required (nextjs.org, dev.to)

Likely an incorrect or invalid review comment.

@rossmanko rossmanko merged commit fc187d4 into main Aug 29, 2025
1 of 3 checks passed
@coderabbitai coderabbitai bot mentioned this pull request Sep 9, 2025
@coderabbitai coderabbitai bot mentioned this pull request Sep 22, 2025
@coderabbitai coderabbitai bot mentioned this pull request Oct 19, 2025
@coderabbitai coderabbitai bot mentioned this pull request Nov 14, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants