Skip to content

Conversation

@rossmanko
Copy link
Contributor

@rossmanko rossmanko commented Aug 24, 2025

Summary by CodeRabbit

  • New Features

    • Convex-backed real-time chat: persistent chats, autosaved titles, todos, sharable chat links (/c/{id}), new Chat UI, sidebar, user menu, and loading spinner.
  • Authentication

    • Consolidated auth flow: WorkOS integration, unified sign-in/sign-up/logout flows, global auth provider and Convex client provider.
  • Performance/Behavior

    • Improved streaming, abort/regenerate handling, per-message persistence, analytics events, terminal stream timeout extended to 60s.
  • Documentation

    • Updated README and .env examples: Convex deployment, WorkOS, OpenRouter, analytics, web search, rate limiting, and env restructuring.

@vercel
Copy link

vercel bot commented Aug 24, 2025

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

Project Deployment Preview Comments Updated (UTC)
hackerai Ready Ready Preview Comment Aug 26, 2025 4:20pm

@coderabbitai
Copy link

coderabbitai bot commented Aug 24, 2025

Caution

Review failed

The pull request is closed.

Walkthrough

Replaces WorkOS gating with unconditional AuthKit + Convex integration; adds Convex schema, server functions, Convex-backed client provider, DB wrappers, a real-time Chat component/page, chat persistence (messages, titles, todos), sidebar/UI primitives, middleware simplification, env/README reorganization, and sandbox/terminal API adjustments.

Changes

Cohort / File(s) Summary
Env & Docs
/.env.local.example, /README.md, /.cursor/rules/convex_rules.mdc
Reorganized env example into required/optional sections; added Convex keys (CONVEX_DEPLOYMENT, NEXT_PUBLIC_CONVEX_URL, CONVEX_SERVICE_ROLE_KEY placeholder), updated OPENROUTER placeholder, added WorkOS/PostHog/EXA placeholders; README restructured for Convex/WorkOS/deploy flow; added Convex best-practices doc.
Middleware & Auth Config
middleware.ts, convex/auth.config.ts, lib/auth/get-user-id.ts
Made authkitMiddleware unconditional with fixed unauthenticatedPaths; added Convex WorkOS auth config file; added server helper getUserID(req) that enforces auth and throws standardized errors.
Removed Auth Helpers
lib/auth/client.ts, lib/auth/server.ts, app/hooks/useAppAuth.ts
Deleted legacy isWorkOSEnabled gating and helper modules/hooks that returned anonymous fallbacks.
Convex Client Provider & Layout
components/ConvexClientProvider.tsx, app/layout.tsx
New ConvexClientProvider wiring AuthKit tokens to Convex React client; layout now wrapped by Convex provider and metadata expanded.
Convex Schema & Server APIs
convex/schema.ts, convex/chats.ts, convex/messages.ts
Added chats and messages tables/indices and server queries/mutations for chat/message CRUD, ownership checks, and optional service-key validation.
DB Client & Helpers
lib/db/actions.ts, lib/utils.ts, types/chat.ts
New Convex HTTP wrappers and helpers: get/save/update chats and messages, handleInitialChatAndUserMessage, updateChat; added convertToUIMessages and ChatMessage alias.
Chat Route & Streaming
app/api/chat/route.ts, lib/actions/index.ts, lib/ai/tools/index.ts, lib/ai/tools/run-terminal-cmd.ts
Chat route: added AbortController, accepts chatId/regenerate; integrates DB actions (handleInitialChatAndUserMessage, saveMessage, updateChat); streams via JsonToSseTransformStream; uses generateTitleFromUserMessageWithWriter; persists messages during streaming; createTools now exposes getTodoManager; terminal STREAM_TIMEOUT increased to 60s.
Client Chat UI & Pages
app/components/chat.tsx, app/c/[id]/page.tsx, app/page.tsx
New Chat component with Convex-backed real-time hydration, message submit/stop/regenerate handlers, per-chat page /c/[id], and landing page split into Authenticated/Unauthenticated flows.
Sidebar & Chat List
app/components/ChatSidebar.tsx, app/components/ChatItem.tsx, app/contexts/GlobalState.tsx, components/ui/sidebar.tsx
New left-hand chat sidebar, ChatItem, global state for currentChatId/chatSidebarOpen and toggles, and a comprehensive Sidebar system (provider, components, keyboard toggle, persistence).
UI Components & Tweaks
app/components/Messages.tsx, app/components/Header.tsx, app/components/Footer.tsx, app/components/ChatHeader.tsx, app/components/ComputerSidebar.tsx, app/components/ScrollToBottomButton.tsx, app/components/UserDropdownMenu.tsx, components/ui/*
Messages layout refactor (hover tracking, new DOM structure); Header/Footer use useAuth; new ChatHeader and UserDropdownMenu; added loading/avatar/input/separator/sheet/sidebar/skeleton UI primitives; spacing/position tweaks.
Auth Routes Simplified
app/callback/route.ts, app/login/route.ts, app/logout/route.ts, app/signup/route.ts
Simplified auth routes to unconditionally call AuthKit handlers (handleAuth, getSignInUrl/getSignUpUrl, signOut); removed isWorkOSEnabled guards and fallback redirects.
Sandbox & Terminal Tooling
lib/ai/tools/utils/sandbox.ts, lib/ai/tools/run-terminal-cmd.ts
Replaced Sandbox.pause/resume with betaPause/connect; adjusted reattach flow and increased STREAM_TIMEOUT_SECONDS from 5s to 60s.
Package Changes
package.json
Added/updated dependencies: convex, @convex-dev/workos, Radix UI libs, uuid, date-fns; bumped ai/SDK/exa and replaced E2B interpreter version.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant U as User (browser)
  participant UI as Client UI (Chat)
  participant API as /api/chat
  participant Auth as AuthKit (WorkOS)
  participant Convex as Convex (DB)
  participant Tools as AI Tools

  U->>UI: submit message (chatId?, messages, todos, regenerate?)
  UI->>API: POST /api/chat { chatId, messages, mode, todos, regenerate }
  API->>Auth: getUserID(req)
  Auth-->>API: userId
  API->>Convex: handleInitialChatAndUserMessage (get/save chat)
  Convex-->>API: { isNewChat }
  API->>Tools: createTools() -> { tools, getTodoManager }
  API->>API: stream assistant via JsonToSseTransformStream
  alt isNewChat
    API->>API: generateTitleFromUserMessageWithWriter(...) -> emit data-title
    API->>Convex: updateChat(title/todos/finishReason)
  end
  API->>Convex: saveMessage (per-message persistence)
  API-->>UI: SSE stream (assistant chunks + data-title events)
Loading
sequenceDiagram
  autonumber
  participant Req as Incoming Request
  participant MW as middleware.ts
  participant Auth as authkitMiddleware
  participant App as Next.js App

  Req->>MW: incoming request
  MW->>Auth: authkitMiddleware(enabled=true, unauthenticatedPaths)
  Auth-->>MW: auth context
  MW->>App: forward request (with auth)
  App-->>Req: response
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • feat: todo-write tool #11 — overlaps TodoManager and todo-write tool integration (createTools, todo persistence, message/tool wiring).
  • Feat/local terminal execution #7 — overlaps sandbox/terminal tooling and env key changes (run-terminal-cmd, sandbox utils, .env placeholders).
  • Daily branch 2025 08 17 #9 — overlaps WorkOS/auth integration and middleware/auth-route changes (middleware, getUserID, auth routes, env keys).

Poem

I nibble keys beneath the midnight screen,
Convex burrows open where the messages convene.
Titles bloom, todos hop, streams hum on the wire,
Auth and tokens stitched together by rabbit-fire.
Tiny paws shipped this patch — hop, hop, deploy! 🐇✨

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 fda5daf and 60d3e77.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (5)
  • app/components/ChatSidebar.tsx (1 hunks)
  • app/components/UserDropdownMenu.tsx (1 hunks)
  • convex/chats.ts (1 hunks)
  • convex/schema.ts (1 hunks)
  • package.json (2 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 feat/chatbot-message-persistence

🪧 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 @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai 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:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai 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 @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @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.

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

Caution

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

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

1-154: Add missing visibility mapping for the “auth” surface

The error codes themselves are registered in getMessageByErrorCode, but the visibilityBySurface map only includes the database and chat surfaces. Because unauthorized:auth uses the auth surface, its visibility will be undefined at runtime. Please add an entry for auth in visibilityBySurface (e.g., "auth": "response") so that toResponse() always produces a consistent payload.

• File: lib/errors.ts
Update the visibilityBySurface object to include:

 export const visibilityBySurface: Record<Surface, ErrorVisibility> = {
   database: "log",
   chat: "response",
+    auth:     "response",
 };
🧹 Nitpick comments (30)
lib/ai/tools/run-terminal-cmd.ts (1)

146-152: Background runs still capped at 6 minutes; let them run indefinitely

Sandbox background jobs inherit timeoutMs from commonOptions, so they can be killed at 6 minutes despite being explicitly backgrounded. Adjust to avoid unintended termination.

Apply:

-            const commonOptions = {
-              timeoutMs: MAX_COMMAND_EXECUTION_TIME,
+            const commonOptions = {
+              timeoutMs: is_background ? undefined : MAX_COMMAND_EXECUTION_TIME,
               user: "root" as const,
               cwd: "/home/user",
               onStdout: handler.stdout,
               onStderr: handler.stderr,
             };

Optional: also consider a separate, longer cap for foreground long-running commands when interactive output is expected.

app/callback/route.ts (1)

5-5: Make post-auth redirect explicit and confirm env setup to avoid 500s in dev

To avoid ambiguity and future regressions, pass returnPathname explicitly. Also, since gating was removed, ensure all required WorkOS/AuthKit env vars are present in local/staging; otherwise handleAuth() will fail at runtime.

-export const GET = handleAuth();
+export const GET = handleAuth({ returnPathname: "/" });

Verification checklist:

  • WORKOS_CLIENT_ID, WORKOS_API_KEY (or config required by your AuthKit version)
  • NEXTAUTH_URL / app URL config as required by AuthKit
  • Redirect URIs registered in the WorkOS dashboard match /callback
app/signup/route.ts (1)

4-7: Add defensive error handling around getSignUpUrl() to return a clean 5xx on misconfig

Current flow is fine; a small guard improves DX when env/config is missing.

-export async function GET() {
-  const authorizationUrl = await getSignUpUrl();
-  return redirect(authorizationUrl);
-}
+export async function GET() {
+  try {
+    const authorizationUrl = await getSignUpUrl();
+    return redirect(authorizationUrl);
+  } catch (err) {
+    console.error("Signup redirect failed:", err);
+    return new Response("Unable to start sign-up flow. Please contact support.", { status: 500 });
+  }
+}
app/login/route.ts (1)

4-7: Mirror defensive handling for sign-in flow

Same rationale as sign-up: surface a clean error when configuration is incomplete.

-export async function GET() {
-  const authorizationUrl = await getSignInUrl();
-  return redirect(authorizationUrl);
-}
+export async function GET() {
+  try {
+    const authorizationUrl = await getSignInUrl();
+    return redirect(authorizationUrl);
+  } catch (err) {
+    console.error("Login redirect failed:", err);
+    return new Response("Unable to start sign-in flow. Please contact support.", { status: 500 });
+  }
+}
lib/auth/get-user-id.ts (2)

1-3: Mark this helper as server-only to prevent accidental client bundling

This module should never end up in the client bundle. Add the server-only directive at the top.

+import "server-only";
 import type { NextRequest } from "next/server";
 import { ChatSDKError } from "@/lib/errors";

17-22: Include error cause for observability; keep response sanitized by ChatSDKError

Surface the underlying failure reason in the ChatSDKError’s cause (your error responder already controls visibility by surface).

-    if (!session?.user?.id) {
-      throw new ChatSDKError("unauthorized:auth");
-    }
+    if (!session?.user?.id) {
+      throw new ChatSDKError("unauthorized:auth", "Missing session.user.id");
+    }
 
     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");
+    console.error("Failed to get user session", { cause: error });
+    const cause = error instanceof Error ? error.message : String(error);
+    throw new ChatSDKError("unauthorized:auth", cause);
   }
app/api/chat/route.ts (2)

27-41: Authenticate before parsing the body to reduce unnecessary work on unauthenticated/rate-limited requests

Minor efficiency/readability win: extract the user id and check rate limit before req.json(). This avoids JSON parsing and allocations for requests we’ll reject anyway.

 export async function POST(req: NextRequest) {
   try {
-    const {
-      messages,
-      mode,
-      todos,
-    }: { messages: UIMessage[]; mode: ChatMode; todos?: Todo[] } =
-      await req.json();
-
-    const userID = await getUserID(req);
+    const userID = await getUserID(req);
     const userLocation = geolocation(req);
 
     // Check rate limit for the user
     await checkRateLimit(userID);
 
+    const {
+      messages,
+      mode,
+      todos,
+    }: { messages: UIMessage[]; mode: ChatMode; todos?: Todo[] } =
+      await req.json();

52-58: Optional: guard PostHog failures to keep the stream resilient

posthog.capture is async and can occasionally throw; you’re already checking client presence, but a light try/catch around capture calls can further isolate analytics from chat execution.

-      posthog.capture({
-        distinctId: userID,
-        event: "hackerai-" + mode,
-      });
+      try {
+        posthog.capture({ distinctId: userID, event: "hackerai-" + mode });
+      } catch {}

And similarly inside onChunk.

Also applies to: 106-114

convex/auth.config.ts (1)

3-20: DRY up repeated literals for maintainability

Factor repeated URL parts into constants to reduce future config drift.

-const authConfig = {
+const jwksUri = `https://api.workos.com/sso/jwks/${clientId}`;
+const baseIssuer = "https://api.workos.com/";
+const userMgmtIssuer = `https://api.workos.com/user_management/${clientId}`;
+
+const authConfig = {
   providers: [
     {
       type: "customJwt",
-      issuer: `https://api.workos.com/`,
+      issuer: baseIssuer,
-      algorithm: "RS256",
-      applicationID: clientId,
-      jwks: `https://api.workos.com/sso/jwks/${clientId}`,
+      applicationID: clientId,
+      jwksUri,
+      algorithms: ["RS256"],
     },
     {
       type: "customJwt",
-      issuer: `https://api.workos.com/user_management/${clientId}`,
-      algorithm: "RS256",
-      jwks: `https://api.workos.com/sso/jwks/${clientId}`,
-      applicationID: clientId,
+      issuer: userMgmtIssuer,
+      applicationID: clientId,
+      jwksUri,
+      algorithms: ["RS256"],
     },
   ],
 };
middleware.ts (1)

18-25: Matcher is comprehensive; consider allowing a healthcheck path without auth

If you expose a health endpoint (e.g., /health or /_health), add it to unauthenticatedPaths to keep infra probes simple.

.env.local.example (3)

9-13: Satisfy dotenv-linter ordering and quoting recommendations for WorkOS keys

The linter warns that NEXT_PUBLIC_WORKOS_REDIRECT_URI should precede WORKOS_* keys and that values needing quotes should be quoted. Reorder and quote the URI.

Run dotenv-linter locally to confirm the warnings are resolved.

-# WorkOS Authentication (Required for user management and conversation persistence)
-WORKOS_API_KEY=sk_example_123456789
-WORKOS_CLIENT_ID=client_123456789
-# generate a secure password (min 32 characters)
-WORKOS_COOKIE_PASSWORD="replace-with-a-32+char-random-secret"
-NEXT_PUBLIC_WORKOS_REDIRECT_URI=http://localhost:3000/callback
+# WorkOS Authentication (Required for user management and conversation persistence)
+# Redirect URI for WorkOS OAuth callbacks
+NEXT_PUBLIC_WORKOS_REDIRECT_URI="http://localhost:3000/callback"
+WORKOS_API_KEY=sk_example_123456789
+WORKOS_CLIENT_ID=client_123456789
+# generate a secure password (min 32 characters)
+WORKOS_COOKIE_PASSWORD="replace-with-a-32+char-random-secret"

6-7: Clarify Convex env expectations

Both CONVEX_DEPLOYMENT and NEXT_PUBLIC_CONVEX_URL are currently blank. Consider adding example values or comments pointing to convex CLI output to reduce setup friction.

-CONVEX_DEPLOYMENT=
-NEXT_PUBLIC_CONVEX_URL=
+# e.g. from `npx convex dev`/`npx convex deploy`
+CONVEX_DEPLOYMENT=
+# Example: "https://<your-deployment>.convex.cloud"
+NEXT_PUBLIC_CONVEX_URL=

19-20: Validate OpenRouter key presence at runtime

If OPENROUTER_API_KEY is missing, the app will likely fail chat requests. Consider documenting a guard or adding a startup check.

I can add a small runtime assert in server route handlers to emit a clear error if the key is absent.

app/components/ChatInterface.tsx (6)

41-61: Avoid side-effects inside prepareSendMessagesRequest or document the intent

Calling setMessages within prepareSendMessagesRequest mutates UI state during request assembly, which can be surprising and harder to trace. Consider normalizing just-in-time before sending (in handleSubmit/regenerate) or clearly document the side-effect.

-prepareSendMessagesRequest: ({ messages, body }) => {
-  // Normalize messages on the frontend before sending to API
-  const { messages: normalizedMessages, hasChanges } =
-    normalizeMessages(messages);
-
-  // Only update messages if normalization made changes
-  if (hasChanges) {
-    setMessages(normalizedMessages);
-  }
-
-  return {
-    body: {
-      messages: normalizedMessages,
-      ...body,
-    },
-  };
-},
+prepareSendMessagesRequest: ({ messages, body }) => {
+  // Normalize messages before sending to API (read-only within this callback)
+  const { messages: normalizedMessages } = normalizeMessages(messages);
+  return { body: { messages: normalizedMessages, ...body } };
+},

Alternatively, move normalization into handleSubmit and handleRegenerate before invoking sendMessage/regenerate.


68-75: Honor the merge flag for todo_write tool calls

Tool input includes a merge boolean, but the code always merges. Respect the flag to allow full replacement when requested by the tool.

- if (toolCall.toolName === "todo_write" && toolCall.input) {
-   const todoInput = toolCall.input as { merge: boolean; todos: Todo[] };
-   if (todoInput.todos) {
-     mergeTodos(todoInput.todos);
-   }
- }
+ if (toolCall.toolName === "todo_write" && toolCall.input) {
+   const { merge, todos: incoming } = toolCall.input as {
+     merge?: boolean;
+     todos?: Todo[];
+   };
+   if (incoming && incoming.length > 0) {
+     if (merge !== false) {
+       // default to merge for backward compatibility
+       mergeTodos(incoming);
+     } else {
+       // replace mode: clear then merge
+       mergeTodos(incoming.map((t) => ({ ...t })));
+     }
+   }
+ }

76-84: Surface unknown errors to users, not just ChatSDKError

Currently only non-rate-limit ChatSDKError paths show a toast; other error types are silently ignored in the UI. Add a generic fallback toast to improve debuggability.

 onError: (error) => {
   if (error instanceof ChatSDKError) {
     // For rate limit errors, let them flow to the Messages component
     // For other errors, show toast
     if (error.type !== "rate_limit") {
       toast.error(error.message);
     }
-  }
+  } else {
+    toast.error("Something went wrong. Please try again.");
+  }
 },

92-116: Consider clearing input after a successful send (minor UX nit)

Clearing immediately can drop the user's text if the request fails early. Clearing after the first token or on success improves recoverability.

-      sendMessage(
+      const promise = sendMessage(
         { text: input },
         {
           body: {
             mode,
             todos,
           },
         },
       );
-      clearInput();
+      promise.finally(() => {
+        // clear regardless once the request is accepted
+        clearInput();
+      });

86-88: Type-safety for refs

Casting refs to RefObject is safe here, but if useMessageScroll already returns MutableRefObject<HTMLDivElement | null>, consider updating Messages prop types to accept that, avoiding casts.

I can adjust Messages props to accept MutableRefObject<HTMLDivElement | null> for stricter typing.


221-224: Comment mismatch: Footer visibility

The comment says “only show when user is not logged in,” but ChatInterface renders Footer based solely on empty-state; the auth gating actually lives inside Footer. Clarify the comment to avoid confusion.

-  {/* Footer - only show when user is not logged in */}
+  {/* Footer renders only when unauthenticated (internally gated) and in empty state */}
app/components/Footer.tsx (1)

17-35: Harden target=_blank links with noopener

Add rel="noopener noreferrer" to prevent the new page from controlling window.opener. Small but good practice even for first-party links.

-          rel="noreferrer"
+          rel="noopener noreferrer"

Apply to both Terms and Privacy links.

app/components/Header.tsx (1)

11-21: Prefer next/navigation router over window.location for SPA navigation

Using router.push keeps client-side routing fast and preserves state. Also drop unnecessary async on sign-out handler.

-import { useAuth } from "@workos-inc/authkit-nextjs/components";
+import { useAuth } from "@workos-inc/authkit-nextjs/components";
+import { useRouter } from "next/navigation";
@@
-  const { user, loading } = useAuth();
+  const { user, loading } = useAuth();
+  const router = useRouter();
@@
-  const handleSignIn = () => {
-    window.location.href = "/login";
-  };
+  const handleSignIn = () => router.push("/login");
@@
-  const handleSignUp = () => {
-    window.location.href = "/signup";
-  };
+  const handleSignUp = () => router.push("/signup");
@@
-  const handleSignOut = async () => {
-    window.location.href = "/logout";
-  };
+  const handleSignOut = () => router.push("/logout");
README.md (4)

15-33: Strengthen Step 2 guidance with explicit env variables

Since conversation persistence depends on Convex and WorkOS, add a note to fill CONVEX_DEPLOYMENT, NEXT_PUBLIC_CONVEX_URL, NEXT_PUBLIC_WORKOS_REDIRECT_URI in .env.local after running npx convex dev.

 HackerAI uses **Convex** for real-time database and **WorkOS** for authentication to provide persistent conversations and user management.
 
+After initializing Convex, update the following in `.env.local`:
+- CONVEX_DEPLOYMENT
+- NEXT_PUBLIC_CONVEX_URL
+- NEXT_PUBLIC_WORKOS_REDIRECT_URI (e.g., http://localhost:3000/callback)

60-67: Fix table rendering by ensuring blank lines before/after headings and tables

Some Markdown renderers require blank lines around tables for proper formatting; this also addresses the grammar tool noise.

-## 🔑 Required Services
-
-| Service        | Purpose                                   | Get Started                             |
+## 🔑 Required Services
+
+| Service        | Purpose                                   | Get Started                             |
 | -------------- | ----------------------------------------- | --------------------------------------- |
 | **Convex**     | Real-time database & conversation storage | [convex.dev](https://convex.dev/)       |
 | **WorkOS**     | User authentication & session management  | [workos.com](https://workos.com/)       |
 | **OpenRouter** | LLM access (Claude, GPT, etc.)            | [openrouter.ai](https://openrouter.ai/) |
+

68-82: Add spacing around Optional Enhancements section and E2B table for consistent rendering

Minor Markdown spacing fixes to satisfy linters and ensure consistent rendering.

-## 🔧 Optional Enhancements
+## 🔧 Optional Enhancements
@@
-### Sandbox Mode
-
-Execute terminal commands in isolated containers instead of your local machine:
+### Sandbox Mode
+
+Execute terminal commands in isolated containers instead of your local machine:
@@
-| Service | Purpose                   | Get API Key                 |
-| ------- | ------------------------- | --------------------------- |
-| **E2B** | Secure isolated execution | [e2b.dev](https://e2b.dev/) |
+| Service | Purpose                   | Get API Key                 |
+| ------- | ------------------------- | --------------------------- |
+| **E2B** | Secure isolated execution | [e2b.dev](https://e2b.dev/) |

83-99: Quote env examples to prevent accidental shell expansion and whitespace issues

Quoting URLs/tokens in the examples reduces copy-paste errors.

-EXA_API_KEY=your_exa_api_key_here
+EXA_API_KEY="your_exa_api_key_here"
@@
-UPSTASH_REDIS_REST_URL="https://your-redis-url.upstash.io"
-UPSTASH_REDIS_REST_TOKEN="your-redis-token"
-RATE_LIMIT_REQUESTS=10
+UPSTASH_REDIS_REST_URL="https://your-redis-url.upstash.io"
+UPSTASH_REDIS_REST_TOKEN="your-redis-token"
+RATE_LIMIT_REQUESTS="10"
app/page.tsx (3)

10-13: Preserve the typed message and use Next.js router before redirecting

Capture the guest’s input so you can auto-send it post-login (aligns with “message persistence”), and prefer router.push for SPA navigation.

Apply within this range:

-const UnauthenticatedContent = () => {
-  const { clearInput } = useGlobalState();
+const UnauthenticatedContent = () => {
+  const { input, clearInput } = useGlobalState();
+  const router = useRouter();

Add this import (outside this range):

import { useRouter } from "next/navigation";

14-19: Best-effort handoff of guest input to login flow

Store the pending message to sessionStorage and include a “next” param. Clear input locally to avoid duplication.

 const handleSubmit = (e: React.FormEvent) => {
   e.preventDefault();
-    // Clear the input and redirect to login
-    clearInput();
-    window.location.href = "/login";
+  // Preserve typed message for post-login handoff, then redirect
+  const value = input.trim();
+  if (value) {
+    try {
+      sessionStorage.setItem("postLoginMessage", value);
+    } catch {
+      // ignore storage failures
+    }
+  }
+  clearInput();
+  router.push("/login?next=/");
 };

Follow-up (optional): In ChatInterface, check sessionStorage for postLoginMessage on mount and auto-send once, then remove it. I can draft that if useful.


64-75: Consider an auth “loading” fallback to avoid UI flicker

Depending on Convex/AuthKit init timing, users might briefly see the wrong branch during hydration. Consider a minimal loading/skeleton state while auth resolution occurs (e.g., derive “isLoading” from your auth hook and gate both branches behind it).

If you want, I can wire a tiny fallback component that uses your auth hook to suppress both branches until the status is known.

app/layout.tsx (2)

21-36: Metadata updates LGTM

Application name, title template, description, and keywords look coherent for SEO and sharing. Consider adding openGraph and twitter metadata later for richer previews.


58-61: Accessibility: allow pinch-zoom

Avoid disabling zoom; it harms accessibility. Recommend removing maximum-scale and user-scalable=no.

-        <meta
-          name="viewport"
-          content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
-        />
+        <meta
+          name="viewport"
+          content="width=device-width, initial-scale=1"
+        />
📜 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 7761038 and 7bfd334.

⛔ Files ignored due to path filters (6)
  • convex/_generated/api.d.ts is excluded by !**/_generated/**
  • convex/_generated/api.js is excluded by !**/_generated/**
  • convex/_generated/dataModel.d.ts is excluded by !**/_generated/**
  • convex/_generated/server.d.ts is excluded by !**/_generated/**
  • convex/_generated/server.js is excluded by !**/_generated/**
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (21)
  • .env.local.example (1 hunks)
  • README.md (2 hunks)
  • app/api/chat/route.ts (2 hunks)
  • app/callback/route.ts (1 hunks)
  • app/components/ChatInterface.tsx (1 hunks)
  • app/components/Footer.tsx (1 hunks)
  • app/components/Header.tsx (3 hunks)
  • app/hooks/useAppAuth.ts (0 hunks)
  • app/layout.tsx (3 hunks)
  • app/login/route.ts (1 hunks)
  • app/logout/route.ts (0 hunks)
  • app/page.tsx (1 hunks)
  • app/signup/route.ts (1 hunks)
  • components/ConvexClientProvider.tsx (1 hunks)
  • convex/auth.config.ts (1 hunks)
  • lib/ai/tools/run-terminal-cmd.ts (1 hunks)
  • lib/auth/client.ts (0 hunks)
  • lib/auth/get-user-id.ts (1 hunks)
  • lib/auth/server.ts (0 hunks)
  • middleware.ts (1 hunks)
  • package.json (2 hunks)
💤 Files with no reviewable changes (4)
  • app/logout/route.ts
  • app/hooks/useAppAuth.ts
  • lib/auth/server.ts
  • lib/auth/client.ts
🧰 Additional context used
🧬 Code graph analysis (7)
lib/auth/get-user-id.ts (1)
lib/errors.ts (1)
  • ChatSDKError (36-74)
app/components/ChatInterface.tsx (8)
app/contexts/GlobalState.tsx (1)
  • useGlobalState (137-143)
lib/utils.ts (1)
  • fetchWithErrorHandlers (9-29)
lib/utils/message-processor.ts (1)
  • normalizeMessages (50-130)
types/chat.ts (1)
  • Todo (36-40)
lib/errors.ts (1)
  • ChatSDKError (36-74)
app/hooks/useMessageScroll.ts (1)
  • useMessageScroll (4-22)
app/components/Messages.tsx (1)
  • Messages (22-181)
app/components/ScrollToBottomButton.tsx (1)
  • ScrollToBottomButton (11-46)
app/callback/route.ts (3)
app/login/route.ts (1)
  • GET (4-7)
app/signup/route.ts (1)
  • GET (4-7)
app/logout/route.ts (1)
  • GET (3-5)
app/signup/route.ts (2)
app/callback/route.ts (1)
  • GET (5-5)
app/login/route.ts (1)
  • GET (4-7)
app/layout.tsx (1)
components/ConvexClientProvider.tsx (1)
  • ConvexClientProvider (14-22)
app/page.tsx (3)
app/contexts/GlobalState.tsx (1)
  • useGlobalState (137-143)
app/components/ChatInput.tsx (1)
  • ChatInput (30-188)
app/components/ChatInterface.tsx (1)
  • ChatInterface (20-258)
app/login/route.ts (1)
app/signup/route.ts (1)
  • GET (4-7)
🪛 LanguageTool
README.md

[grammar] ~60-~60: There might be a mistake here.
Context: ...ourney! 🎯 --- ## 🔑 Required Services | Service | Purpose ...

(QB_NEW_EN)


[grammar] ~68-~68: There might be a mistake here.
Context: ...uter.ai/) | ## 🔧 Optional Enhancements ### Sandbox Mode Execute terminal commands i...

(QB_NEW_EN)


[grammar] ~74-~74: There might be a mistake here.
Context: ... | Get API Key | | ------- | ------------------------- | ...

(QB_NEW_EN)


[grammar] ~75-~75: There might be a mistake here.
Context: ...-------- | --------------------------- | | E2B | Secure isolated execution | ...

(QB_NEW_EN)

🪛 dotenv-linter (3.3.0)
.env.local.example

[warning] 12-12: [SpaceCharacter] The line has spaces around equal sign

(SpaceCharacter)


[warning] 12-12: [ValueWithoutQuotes] This value needs to be surrounded in quotes

(ValueWithoutQuotes)


[warning] 13-13: [UnorderedKey] The NEXT_PUBLIC_WORKOS_REDIRECT_URI key should go before the WORKOS_API_KEY key

(UnorderedKey)

🔇 Additional comments (13)
package.json (1)

16-16: Peer dependencies verified – no duplicate React trees detected

  • npm ls confirms a single React 19.1.1 instance across Next 15.5.0, @workos-inc/authkit-nextjs 2.5.0, @convex-dev/workos 0.0.1, and convex 1.26.1; no multiple React copies are pulled in.
  • All three direct packages declare peerDependencies compatible with React ^19.0.0 and Next ^15.x (convex@1.26.1, @convex-dev/workos@0.0.1, @workos-inc/authkit-nextjs@2.5.0).

Minor nit (optional): react is declared with a caret (^19.1.1), whereas react-dom is pinned exactly (19.1.1). You may wish to align them (e.g. ^19.1.1) so that both receive patch updates consistently.

lib/auth/get-user-id.ts (1)

14-16: Confirmed: authkit(req) returns { session } with session.user.id

The @workos-inc/authkit-nextjs library’s composable middleware API for Next.js App Router route handlers is indeed the recommended way to read a session from a NextRequest. When you call:

import { authkit } from "@workos-inc/authkit-nextjs";

export async function GET(req: NextRequest) {
  const { session } = await authkit(req);
  const userId = session.user.id;
  // …
}

you get back an object containing:

  • session.accessToken: string
  • session.refreshToken: string
  • session.user: { object: "user"; id: string; email: string; emailVerified: boolean; firstName?: string; lastName?: string; profilePictureUrl?: string; createdAt: string; updatedAt: string; }
  • session.impersonator? (when impersonated)
  • plus headers and authorizationUrl if you need them (github.com, workos.com)

Because this matches the official API shape in 2025, no changes are needed here.

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

15-15: Import path update to new auth helper looks good

Switching to @/lib/auth/get-user-id aligns this route with the new centralized auth helper.


140-152: Error handling is consistent with ChatSDKError contract

Catching ChatSDKError and delegating to toResponse() keeps responses consistent; unexpected errors are wrapped as offline:chat with a safe message. LGTM.

components/ConvexClientProvider.tsx (1)

7-10: AuthKitProvider client setup confirmed – no props required

The current usage of <AuthKitProvider> without any props in your App Router client components matches the official 2025 AuthKit Next.js documentation. All configuration (project ID, base URL, API keys) is handled server-side via environment variables or server handlers, so no projectId or other props should be passed into the client provider.

No changes are needed here.

middleware.ts (1)

3-16: Middleware configuration looks consistent with always-on auth

The unauthenticated path list covers the expected public routes. LGTM.

app/components/ChatInterface.tsx (1)

20-31: Overall integration looks solid

Clean separation of concerns, good use of useChat hooks, and thoughtful UI state wiring (title updates, sidebar auto-open reset, scroll helpers). Nice work.

app/components/Footer.tsx (1)

7-11: Auth-driven rendering is correct

Using useAuth and hiding the footer when loading or authenticated matches the product intent. LGTM.

app/components/Header.tsx (1)

35-67: Auth control rendering logic looks good

Clear, minimal gating on loading; correct branching on user for desktop UI. Same pattern mirrored for mobile below. LGTM.

app/page.tsx (2)

3-4: Good switch to Convex Auth wrappers

Using Authenticated/Unauthenticated from convex/react makes the split between signed-in and guest experiences clear and keeps this page lean. Nice.


43-51: Guest ChatInput configuration looks safe

Passing status="ready" with a no-op onStop means the stop controls won’t render and Enter-to-submit cleanly triggers the login redirect. Looks good.

app/layout.tsx (2)

9-9: Right place for ConvexClientProvider

Wrapping the entire app body in ConvexClientProvider centralizes auth/state wiring and keeps pages/components clean. Solid.


67-67: ConvexClientProvider is correctly configured

Confirmed that components/ConvexClientProvider.tsx begins with a "use client" directive and only instantiates one AuthKitProvider and one ConvexProviderWithAuth—no duplicate providers detected. This satisfies the requirement for a client component and proper provider ordering.


const MAX_COMMAND_EXECUTION_TIME = 6 * 60 * 1000; // 6 minutes
const STREAM_TIMEOUT_SECONDS = 5;
const STREAM_TIMEOUT_SECONDS = 60;
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

⚠️ Potential issue

Fix: local onTimeout misses cleanup (leak), and background jobs shouldn’t stream-timeout

  • Local path’s onTimeout doesn’t call handler.cleanup(), leaving timers/listeners alive. This is a real leak in long sessions.
  • Both local and sandbox paths apply a stream idle-timeout to background jobs; background tasks are expected to run without interactive output, so timing them out at 60s is misleading.

Apply the following diffs:

@@
-const STREAM_TIMEOUT_SECONDS = 60;
+const STREAM_TIMEOUT_SECONDS = 60;
@@
-            const handler = createTerminalHandler(
+            const handler = createTerminalHandler(
               (output, isStderr) =>
                 isStderr ? onStderr(output) : onStdout(output),
               {
-                timeoutSeconds: STREAM_TIMEOUT_SECONDS,
+                // Disable idle timeout for background commands
+                timeoutSeconds: is_background ? undefined : STREAM_TIMEOUT_SECONDS,
                 onTimeout: () => {
                   if (!resolved) {
                     resolved = true;
+                    // Surface a consistent timeout message to the user
+                    onStdout(TIMEOUT_MESSAGE(STREAM_TIMEOUT_SECONDS));
+                    // Ensure we release timers/listeners
+                    handler.cleanup();
                     const result = handler.getResult();
                     resolve({ result: { ...result, exitCode: null } });
                   }
                 },
               },
             );
@@
-            const handler = createTerminalHandler(
+            const handler = createTerminalHandler(
               (output) => createTerminalWriter(output),
               {
-                timeoutSeconds: STREAM_TIMEOUT_SECONDS,
+                // Disable idle timeout for background commands
+                timeoutSeconds: is_background ? undefined : STREAM_TIMEOUT_SECONDS,
                 onTimeout: () => {
                   if (!resolved) {
                     resolved = true;
                     // Send timeout message through streaming interface
                     createTerminalWriter(
                       TIMEOUT_MESSAGE(STREAM_TIMEOUT_SECONDS),
                     );
                     handler.cleanup();
                     const result = handler.getResult();
                     resolve({ result: { ...result, exitCode: null } });
                   }
                 },
               },
             );

If createTerminalHandler doesn’t accept undefined for timeoutSeconds, use a very large value (e.g., Number.MAX_SAFE_INTEGER) or add a dedicated “disableTimeout” option in that utility.

Also applies to: 66-79, 127-144

🤖 Prompt for AI Agents
In lib/ai/tools/run-terminal-cmd.ts around line 14 (and similarly for the blocks
at 66-79 and 127-144), the STREAM_TIMEOUT_SECONDS constant and its usage cause
two problems: the local onTimeout handler does not call handler.cleanup()
(leaking timers/listeners), and both local and sandbox paths apply the 60s
stream idle-timeout to background jobs which should not be timed out. Fix by
updating the local onTimeout implementation to always call handler.cleanup()
before exiting the timeout handler, and change the timeout passed for background
jobs to disable the stream timeout (either by passing undefined if
createTerminalHandler supports it, or passing a very large value like
Number.MAX_SAFE_INTEGER, or extending createTerminalHandler with a
disableTimeout flag); ensure the change is applied consistently to the
occurrences at lines ~66-79 and ~127-144 so background tasks are not subject to
the 60s idle timer.

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: 13

Caution

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

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

36-49: Validate and sanitize request body (schema) to prevent 500s from malformed input

await req.json() is unchecked; invalid shapes will break downstream logic. Add runtime validation (e.g., zod) and return a friendly error.

+import { z } from "zod";
+
+const BodySchema = z.object({
+  messages: z.array(z.any()), // refine if you have a UIMessage runtime schema
+  mode: z.union([z.literal("ask"), z.literal("agent")]),
+  chatId: z.string().min(1),
+  todos: z
+    .array(
+      z.object({
+        id: z.string(),
+        content: z.string(),
+        status: z.union([
+          z.literal("pending"),
+          z.literal("in_progress"),
+          z.literal("completed"),
+          z.literal("cancelled"),
+        ]),
+      }),
+    )
+    .optional(),
+  regenerate: z.boolean().optional(),
+});
 
 export async function POST(req: NextRequest) {
   try {
-    const {
-      messages,
-      mode,
-      todos,
-      chatId,
-      regenerate,
-    }: {
-      messages: UIMessage[];
-      mode: ChatMode;
-      chatId: string;
-      todos?: Todo[];
-      regenerate?: boolean;
-    } = await req.json();
+    const parsed = BodySchema.safeParse(await req.json());
+    if (!parsed.success) {
+      throw new ChatSDKError(
+        "bad_request:input",
+        parsed.error.errors.map((e) => e.message).join("; "),
+      );
+    }
+    const { messages, mode, todos, chatId, regenerate } = parsed.data;
♻️ Duplicate comments (1)
.env.local.example (1)

12-12: Fix invalid dotenv syntax for WORKOS_COOKIE_PASSWORD

Inline comments after the equals sign are parsed into the value; also quotes are required for secrets with spaces/special chars. Move the comment to its own line and quote the value.

Apply this diff:

-WORKOS_COOKIE_PASSWORD= # generate a secure password (min 32 characters)
+# generate a secure password (min 32 characters)
+WORKOS_COOKIE_PASSWORD="replace-with-a-32+char-random-secret"
🧹 Nitpick comments (25)
lib/ai/tools/index.ts (1)

70-72: New getter looks good; consider typing the factory’s return and clarifying mutability.

Exposing getTodoManager is consistent with getSandbox. To make downstream usage safer and catch regressions at compile time, explicitly type the factory’s return and document that callers receive a live, stateful manager (not a snapshot).

Add an explicit return type:

// Outside selected lines (type helper)
export interface ToolsFactoryResult {
  tools: typeof allTools;
  getSandbox: () => Sandbox | null;
  getTodoManager: () => TodoManager;
}

// Signature:
export const createTools = (/* args */): ToolsFactoryResult => { /* ... */ }

If you want to avoid external mutation of internal fields in the future, you can later expose a narrower interface for TodoManager via Pick<> or an adapter without changing the public getter name.

types/chat.ts (1)

1-1: Use a type-only import to avoid unnecessary runtime dependency.

UIMessage is a type; importing it as a type-only keeps the JS bundle clean and avoids potential runtime import issues if it isn’t a value export.

-import { UIMessage } from "ai";
+import type { UIMessage } from "ai";
lib/utils.ts (2)

4-6: Prefer type-only imports; avoid over-specific generics.

  • Mark ChatMessage, UIMessagePart, and Doc as type-only imports.
  • Unless you rely on UIMessagePart generics, the plain UIMessagePart[] is more future-proof and avoids mismatches if the upstream generic arity changes.
-import { ChatMessage } from "@/types/chat";
-import { UIMessagePart } from "ai";
-import { Doc } from "@/convex/_generated/dataModel";
+import type { ChatMessage } from "@/types/chat";
+import type { UIMessagePart } from "ai";
+import type { Doc } from "@/convex/_generated/dataModel";

34-42: Refactor convertToUIMessages: drop redundant sort, validate role, and default parts

The messages array you pass in is already ordered ascending by the Convex query in convex/messages.ts (it uses .order("asc") on line 86), so the local .sort(...) is redundant. However, it’s still valuable to:

  • Defensively clamp role to "user" | "assistant" | "system"
  • Provide a safe default for parts to avoid runtime errors

File: lib/utils.ts, function convertToUIMessages

Suggested diff:

 export function convertToUIMessages(
   messages: Doc<"messages">[],
 ): ChatMessage[] {
-  return [...messages]
-    .sort((a, b) => a._creationTime - b._creationTime)
-    .map((message) => {
+  return messages.map((message) => {
     const role = (["user", "assistant", "system"].includes(message.role)
       ? message.role
       : "user") as "user" | "assistant" | "system";
     return {
       id: String(message._id),
       role,
-      parts: (message.parts ?? []) as UIMessagePart[],
+      parts: (message.parts ?? []) as UIMessagePart<any, any>[],
     };
-  });
+  });
 }
components/ui/loading.tsx (2)

7-14: Avoid dynamic Tailwind classes; add basic a11y.

size-${size} will be purged in production unless safelisted. Prefer inline sizing or a fixed class map, and add role="status"/aria-label for screen readers.

-export default function Loading({ size = 12 }: LoadingProps): JSX.Element {
-  const sizeClass = `size-${size}`;
-  return (
-    <div className="flex size-full flex-col items-center justify-center">
-      <LoaderCircle className={`mt-4 ${sizeClass} animate-spin`} />
-    </div>
-  );
-}
+export default function Loading({ size = 48 }: LoadingProps): JSX.Element {
+  return (
+    <div className="flex size-full flex-col items-center justify-center" role="status" aria-label="Loading">
+      <LoaderCircle className="mt-4 animate-spin" style={{ width: size, height: size }} />
+      <span className="sr-only">Loading</span>
+    </div>
+  );
+}

If you prefer Tailwind classes, switch to a constrained map to keep JIT happy:

const sizeClass = { sm: "size-6", md: "size-8", lg: "size-12" }[variant];

1-3: Type import isn’t necessary here.

You can omit the explicit JSX return type and the react type import; inference is sufficient and reduces imports.

-import type { JSX } from "react";
-interface LoadingProps {
+interface LoadingProps {
   size?: number;
 }
 
-export default function Loading({ size = 12 }: LoadingProps): JSX.Element {
+export default function Loading({ size = 12 }: LoadingProps) {
app/c/[id]/page.tsx (2)

26-33: Unauthenticated branch spins forever; redirect to login (with return URL)

Showing a full-screen loader indefinitely for unauthenticated users degrades UX and looks like a hang. Redirect them to /login?next=/c/{id} (keep loader during redirect).

Apply this minimal change inside the Unauthenticated block:

-      <Unauthenticated>
-        <div className="h-screen bg-background flex flex-col overflow-hidden">
-          <div className="flex-1 flex items-center justify-center">
-            <Loading />
-          </div>
-        </div>
-      </Unauthenticated>
+      <Unauthenticated>
+        <AuthRedirect chatId={chatId} />
+      </Unauthenticated>

Add the helper below (anywhere in this file):

import { useEffect } from "react";
import { useRouter } from "next/navigation";

function AuthRedirect({ chatId }: { chatId: string }) {
  const router = useRouter();
  useEffect(() => {
    const next = `/c/${encodeURIComponent(chatId)}`;
    router.replace(`/login?next=${encodeURIComponent(next)}`);
  }, [router, chatId]);
  return (
    <div className="h-screen bg-background flex flex-col overflow-hidden">
      <div className="flex-1 flex items-center justify-center">
        <Loading />
      </div>
    </div>
  );
}

8-11: Guard against missing/invalid id

Routes should handle malformed URLs. If id is missing or not a string, render a 404 or navigate away to the home page.

Example:

if (!chatId || typeof chatId !== "string") {
  return <div className="p-6">Invalid chat ID.</div>;
}
convex/schema.ts (1)

29-31: Tighten role and parts validation (optional)

role should be constrained to "user" | "assistant", and parts ideally be a tagged union for known types. This prevents malformed data from breaking rendering.

Example:

role: v.union(v.literal("user"), v.literal("assistant")),
parts: v.array(v.object({ type: v.string() })), // gradually specialize per part type
app/components/Messages.tsx (1)

32-35: Micro-optimization: compute lastAssistantMessageIndex without materializing arrays

Mapping and reversing on every render allocates extra arrays. Iterate from the end once.

-  const lastAssistantMessageIndex = messages
-    .map((msg, index) => ({ msg, index }))
-    .reverse()
-    .find(({ msg }) => msg.role === "assistant")?.index;
+  let lastAssistantMessageIndex = undefined as number | undefined;
+  for (let i = messages.length - 1; i >= 0; i--) {
+    if (messages[i].role === "assistant") {
+      lastAssistantMessageIndex = i;
+      break;
+    }
+  }
app/page.tsx (1)

14-19: Use Next router instead of window.location.href for login redirection

Client-side navigation keeps app state and avoids full reload.

+import { useRouter } from "next/navigation";
 
 const UnauthenticatedContent = () => {
-  const { clearInput } = useGlobalState();
+  const { clearInput } = useGlobalState();
+  const router = useRouter();
 
   const handleSubmit = (e: React.FormEvent) => {
     e.preventDefault();
     // Clear the input and redirect to login
     clearInput();
-    window.location.href = "/login";
+    router.push("/login");
   };
app/api/chat/route.ts (2)

24-29: Remove unused import updateChatTodos

updateChatTodos is imported but not used.

-import {
-  handleInitialChatAndUserMessage,
-  saveMessage,
-  updateChat,
-  updateChatTodos,
-} from "@/lib/db/actions";
+import {
+  handleInitialChatAndUserMessage,
+  saveMessage,
+  updateChat,
+} from "@/lib/db/actions";

131-157: Consider setting an explicit error finish reason and persisting partial output on onError

If the model errors after streaming partial text, the assistant message won’t be persisted from the server side. You handle user-initiated stop in the client, but model errors won’t hit that path. Optionally save the last assistant message and mark finish_reason: "error".

app/components/chat.tsx (3)

194-205: Optimistically remove the last assistant message in UI during regenerate

Deleting from DB and then calling regenerate can momentarily show the stale assistant response until Convex pushes a new snapshot. Remove it locally first to avoid flicker.

Apply this diff:

   const handleRegenerate = async () => {
-    // Remove the last assistant message from the UI and database
+    // Remove the last assistant message from the UI and database (optimistic)
+    setMessages((prev) => {
+      const lastIdx = [...prev]
+        .map((m, i) => ({ m, i }))
+        .reverse()
+        .find(({ m }) => m.role === "assistant")?.i;
+      if (lastIdx === undefined) return prev;
+      return [...prev.slice(0, lastIdx), ...prev.slice(lastIdx + 1)];
+    });
     await deleteLastAssistantMessage({ chatId });
 
     regenerate({
       body: {
         mode,
         todos,
         regenerate: true,
       },
     });
   };

23-30: Non-reactive chatId when prop id changes

chatId is initialized once and won’t track prop id changes if the component stays mounted. If this component can receive a different id without remount, consider syncing via useEffect or document that route changes remount Chat.


231-246: Show a loading skeleton while chat header data loads

The blank title during chatData === undefined causes a small layout jump. Consider a lightweight skeleton/spinner for header text during load.

.env.local.example (2)

9-13: Place NEXT_PUBLIC_WORKOS_REDIRECT_URI before secret keys and quote URL

Aligns with dotenv-linter’s ordering hint and avoids surprises with URL characters.

Apply this diff:

-# WorkOS Authentication (Required for user management and conversation persistence)
-WORKOS_API_KEY=sk_example_123456789
-WORKOS_CLIENT_ID=client_123456789
-# generate a secure password (min 32 characters)
-WORKOS_COOKIE_PASSWORD="replace-with-a-32+char-random-secret"
-NEXT_PUBLIC_WORKOS_REDIRECT_URI=http://localhost:3000/callback
+# WorkOS Authentication (Required for user management and conversation persistence)
+NEXT_PUBLIC_WORKOS_REDIRECT_URI="http://localhost:3000/callback"
+WORKOS_API_KEY="sk_example_123456789"
+WORKOS_CLIENT_ID="client_123456789"
+# generate a secure password (min 32 characters)
+WORKOS_COOKIE_PASSWORD="replace-with-a-32+char-random-secret"

48-48: Add a trailing newline

Conforms to editor/linter expectations.

Apply this diff:

-# CONVEX_SERVICE_ROLE_KEY=
+# CONVEX_SERVICE_ROLE_KEY=
+
convex/messages.ts (3)

1-4: Remove unused import

Id is imported but unused.

Apply this diff:

-import { Id } from "./_generated/dataModel";

25-33: Optional: Prevent orphan messages in saveMessage

Even on the service-key path, consider verifying the chat exists before inserting to avoid orphaned message records if upstream logic mis-orders calls. Keep as a fast path if you’re confident in ordering guarantees.


83-89: Return ordering assumption

You rely on by_chat_id + order("asc"). If a different ordering is desired (e.g., by update_time), consider adding a composite index to avoid full scans in the future. Not blocking.

convex/chats.ts (2)

79-87: Idempotency on saveChat

Consider checking for an existing chat with the same id (using by_id) to avoid accidental duplicates if upstream retries. Upsert or short-circuit insert when present.


35-43: Service key check is good; log surface

Minor: use structured logs including chat id to aid tracing Unauthorized/Invalid service key incidents. Non-blocking.

lib/db/actions.ts (2)

76-88: Guard against empty messages when deriving initial title

Defensive check avoids runtime errors if messages is empty or parts[0] is missing.

Apply this diff:

-    const lastMessage = messages[messages.length - 1];
-    const firstPart = lastMessage.parts[0];
-    const title = (
-      firstPart.type === "text" ? firstPart.text : "New Chat"
-    )?.substring(0, 100);
+    const lastMessage = messages[messages.length - 1];
+    const firstPart = lastMessage?.parts?.[0];
+    const baseTitle =
+      firstPart && (firstPart as any).type === "text"
+        ? (firstPart as any).text
+        : "New Chat";
+    const title = (baseTitle || "New Chat").substring(0, 100);

40-60: Optional: normalize before persisting messages on server paths too

For consistency with the client stop-path normalization, consider normalizing user/assistant parts on server before saveMessage to keep the database canonical.

📜 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 7bfd334 and 30894b0.

⛔ Files ignored due to path filters (3)
  • convex/_generated/api.d.ts is excluded by !**/_generated/**
  • convex/_generated/dataModel.d.ts is excluded by !**/_generated/**
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (17)
  • .cursor/rules/convex_rules.mdc (1 hunks)
  • .env.local.example (1 hunks)
  • app/api/chat/route.ts (6 hunks)
  • app/c/[id]/page.tsx (1 hunks)
  • app/components/Messages.tsx (1 hunks)
  • app/components/chat.tsx (1 hunks)
  • app/page.tsx (1 hunks)
  • components/ui/loading.tsx (1 hunks)
  • convex/chats.ts (1 hunks)
  • convex/messages.ts (1 hunks)
  • convex/schema.ts (1 hunks)
  • lib/actions/index.ts (2 hunks)
  • lib/ai/tools/index.ts (1 hunks)
  • lib/db/actions.ts (1 hunks)
  • lib/utils.ts (2 hunks)
  • package.json (4 hunks)
  • types/chat.ts (2 hunks)
✅ Files skipped from review due to trivial changes (1)
  • .cursor/rules/convex_rules.mdc
🚧 Files skipped from review as they are similar to previous changes (1)
  • package.json
🧰 Additional context used
🧬 Code graph analysis (9)
lib/utils.ts (2)
convex/_generated/dataModel.d.ts (1)
  • Doc (30-33)
types/chat.ts (1)
  • ChatMessage (58-58)
convex/messages.ts (1)
lib/db/actions.ts (1)
  • saveMessage (40-60)
app/c/[id]/page.tsx (3)
app/page.tsx (1)
  • Page (70-81)
components/ui/loading.tsx (1)
  • Loading (7-14)
app/components/chat.tsx (1)
  • Chat (23-329)
app/components/chat.tsx (8)
app/contexts/GlobalState.tsx (1)
  • useGlobalState (137-143)
convex/messages.ts (2)
  • deleteLastAssistantMessage (157-199)
  • saveMessageFromClient (107-152)
lib/utils.ts (2)
  • convertToUIMessages (34-42)
  • fetchWithErrorHandlers (12-32)
lib/utils/message-processor.ts (1)
  • normalizeMessages (50-130)
app/hooks/useMessageScroll.ts (1)
  • useMessageScroll (4-22)
app/components/Messages.tsx (1)
  • Messages (22-170)
app/components/ChatInput.tsx (1)
  • ChatInput (30-188)
app/components/ScrollToBottomButton.tsx (1)
  • ScrollToBottomButton (11-46)
app/components/Messages.tsx (2)
app/components/MessagePartHandler.tsx (1)
  • MessagePartHandler (16-68)
app/components/MessageActions.tsx (1)
  • MessageActions (20-106)
convex/chats.ts (1)
lib/db/actions.ts (4)
  • getChatById (8-18)
  • saveChat (20-39)
  • updateChat (113-139)
  • updateChatTodos (141-164)
app/page.tsx (4)
app/contexts/GlobalState.tsx (1)
  • useGlobalState (137-143)
app/components/ChatInput.tsx (1)
  • ChatInput (30-188)
app/components/chat.tsx (1)
  • Chat (23-329)
app/c/[id]/page.tsx (1)
  • Page (8-35)
lib/db/actions.ts (3)
convex/chats.ts (4)
  • getChatById (7-57)
  • saveChat (62-93)
  • updateChat (98-176)
  • updateChatTodos (181-232)
lib/errors.ts (1)
  • ChatSDKError (36-74)
convex/messages.ts (1)
  • saveMessage (8-40)
app/api/chat/route.ts (5)
lib/auth/get-user-id.ts (1)
  • getUserID (12-30)
lib/db/actions.ts (3)
  • handleInitialChatAndUserMessage (62-111)
  • updateChat (113-139)
  • saveMessage (40-60)
lib/ai/tools/index.ts (1)
  • createTools (17-73)
lib/actions/index.ts (1)
  • generateTitleFromUserMessageWithWriter (67-90)
lib/ai/tools/utils/sandbox.ts (1)
  • pauseSandbox (152-169)
🪛 dotenv-linter (3.3.0)
.env.local.example

[warning] 12-12: [SpaceCharacter] The line has spaces around equal sign

(SpaceCharacter)


[warning] 12-12: [ValueWithoutQuotes] This value needs to be surrounded in quotes

(ValueWithoutQuotes)


[warning] 13-13: [UnorderedKey] The NEXT_PUBLIC_WORKOS_REDIRECT_URI key should go before the WORKOS_API_KEY key

(UnorderedKey)


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

(EndingBlankLine)

🔇 Additional comments (10)
lib/actions/index.ts (2)

1-1: Import change is appropriate.

Importing UIMessageStreamWriter from ai matches the new streaming title helper below. No concerns.


67-90: It looks like the public docs don’t specify whether UIMessageStreamWriter.write returns a promise. Could you please share the TypeScript declaration (or source) for UIMessageStreamWriter.write from your local node_modules/@vercel/ai (or from the GitHub repo)? For example, you might open:

node_modules/@vercel/ai/dist/index.d.ts

and copy the interface or method signature for UIMessageStreamWriter.write(...). With that snippet, we can definitively confirm if it returns a Promise (e.g. Promise<void> or Promise<boolean>), and whether await is required.

types/chat.ts (1)

58-58: Alias looks correct and improves clarity.

Re-exporting UIMessage as ChatMessage aligns domain terminology across the app without changing structure. Good move.

convex/schema.ts (1)

24-25: All update_time fields are properly set in Convex mutations

The schema’s update_time requirement has been verified against all write operations. Every mutation that inserts or patches records for the tables defining update_time uses Date.now(), so no writes will fail.

• convex/messages.ts
– Creation: line 31 sets update_time: Date.now()
– Update handler: line 143 sets update_time: Date.now()

• convex/chats.ts
– Creation: line 84 sets update_time: Date.now()
– Default values: line 152 sets update_time: Date.now()
– Patch/update: line 223 sets update_time: Date.now()

No additional changes are needed.

app/page.tsx (1)

69-80: LGTM: clean Auth split and composition

The Authenticated/Unauthenticated separation is clear, and delegating chat internals to the Chat component reduces page complexity.

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

136-141: Mapping Already Implemented – No Changes Needed

The updateChat mutation in convex/chats.ts already translates the finishReason argument into the finish_reason field expected by the schema:

  • File: convex/chats.ts at lines 159–160
    if (args.finishReason !== undefined) {
      updateData.finish_reason = args.finishReason;
    }

You can safely ignore this review comment.

Likely an incorrect or invalid review comment.


179-185: Filter out user messages in onFinish to avoid duplicate persistence

It looks like you’re already saving the initial user message in handleInitialChatAndUserMessage (unless regenerate is true). Since createUIMessageStream’s messages array appears to include both user and assistant messages, you could end up persisting the same user message twice. To guard against this, only save non-user messages in your onFinish handler:

       onFinish: async ({ messages }) => {
-        for (const message of messages) {
-          await saveMessage({
-            chatId,
-            message,
-          });
-        }
+        for (const message of messages) {
+          // only persist assistant replies here;
+          // initial user message is already saved upstream
+          if (message.role !== "user") {
+            await saveMessage({ chatId, message });
+          }
+        }
       },

Before merging, please verify that:

  • handleInitialChatAndUserMessage does indeed persist the user message once (and only once).
  • createUIMessageStream’s messages list always includes the user prompt in non-regeneration flows.
  • No other code paths call saveMessage on the same user message a second time.
app/components/chat.tsx (2)

42-46: Good use of conditional Convex query with "skip"

Using "skip" prevents unnecessary queries before a chat actually exists for new sessions. Clean and efficient.


78-96: Solid message preparation pipeline

Normalizing messages in prepareSendMessagesRequest and attaching chatId keeps the transport payload canonical. The local setMessages when hasChanges ensures UI consistency pre-flight.

lib/db/actions.ts (1)

8-18: Confirmed server-only import: safe to embed service key

I verified that lib/db/actions.ts is only imported in a server-only Next.js route handler and not bundled into any client code:

  • The only import found is in app/api/chat/route.ts (lines 26–29), which lives under Next.js’s app/api folder (always executed on the server).

No other imports of lib/db/actions.ts exist outside of server contexts, so exposing the service key here won’t leak it to client bundles.

Comment on lines +6 to +11
import { use } from "react";

export default function Page(props: { params: Promise<{ id: string }> }) {
const params = use(props.params);
const chatId = params.id;

Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Avoid use() for route params in a client component; prefer useParams

Using use(props.params) relies on React’s use() and a Promise-shaped params, which is brittle and requires React 19+. In Next.js client components, useParams() is the recommended way to access dynamic segments and avoids hydration pitfalls.

Apply this diff and confirm your React/Next versions don’t rely on use() here:

-import { use } from "react";
+import { useParams } from "next/navigation";

-export default function Page(props: { params: Promise<{ id: string }> }) {
-  const params = use(props.params);
-  const chatId = params.id;
+export default function Page() {
+  const { id } = useParams<{ id: string }>();
+  const chatId = Array.isArray(id) ? id[0] : id;

🏁 Script executed:

#!/bin/bash
grep -n "use client" "app/c/[id]/page.tsx" || echo "No 'use client' directive found"
sed -n '1,30p' "app/c/[id]/page.tsx"

Length of output: 1001


🏁 Script executed:

#!/bin/bash
# Display project dependencies to verify Next.js and React versions
sed -n '1,200p' package.json

Length of output: 1956


Replace use() with useParams() in this client component

The project is on Next.js 15.5.0 and React 19.1.1, so the useParams hook from next/navigation is fully supported. Using use() here to unwrap a Promise<{ id: string }> in a client component is brittle and can lead to hydration mismatches; switching to useParams() aligns with Next.js conventions and avoids relying on the experimental React 19 use() behavior.

Please update:

• File: app/c/[id]/page.tsx
• Lines: 6–11

Apply the following diff:

-use client
-
-import { use } from "react";
+use client
+
+import { useParams } from "next/navigation";

-export default function Page(props: { params: Promise<{ id: string }> }) {
-  const params = use(props.params);
-  const chatId = params.id;
+export default function Page() {
+  const { id } = useParams<{ id: string }>();
+  const chatId = Array.isArray(id) ? id[0] : id;

This ensures you’re using Next.js’s built-in routing hook for dynamic segments in client components.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { use } from "react";
export default function Page(props: { params: Promise<{ id: string }> }) {
const params = use(props.params);
const chatId = params.id;
use client
import { useParams } from "next/navigation";
export default function Page() {
const { id } = useParams<{ id: string }>();
const chatId = Array.isArray(id) ? id[0] : id;
🤖 Prompt for AI Agents
In app/c/[id]/page.tsx around lines 6 to 11, the component currently calls
React's experimental use() to unwrap a Promise for params; replace that with
Next.js's useParams() from next/navigation in this client component: import
useParams, remove use() and the Promise-typed props, call const params =
useParams(); then derive const chatId = params?.id as string (or handle
undefined) and adjust the component signature to not expect a
Promise<{id:string}> so it uses the supported Next.js hook and avoids hydration
mismatches.

Comment on lines 128 to 135
// Sync Convex real-time data with useChat messages
useEffect(() => {
if (!messagesData || messagesData === null) return;

const uiMessages = convertToUIMessages(messagesData);
setMessages(uiMessages);
}, [messagesData, setMessages]);

Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Avoid clobbering in-flight assistant streaming with DB snapshots

When Convex pushes a real-time update during assistant streaming, this effect replaces the partially streamed assistant message with the persisted snapshot (which usually lacks the in-progress assistant), causing a visible flicker and loss of the partial content. Gate the sync while streaming and include status in deps.

Apply this diff:

-  useEffect(() => {
-    if (!messagesData || messagesData === null) return;
-
-    const uiMessages = convertToUIMessages(messagesData);
-    setMessages(uiMessages);
-  }, [messagesData, setMessages]);
+  useEffect(() => {
+    if (!messagesData || messagesData === null) return;
+    // Don't overwrite in-progress assistant streaming with a DB snapshot
+    if (status === "streaming") return;
+    const uiMessages = convertToUIMessages(messagesData);
+    setMessages(uiMessages);
+  }, [messagesData, status, setMessages]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Sync Convex real-time data with useChat messages
useEffect(() => {
if (!messagesData || messagesData === null) return;
const uiMessages = convertToUIMessages(messagesData);
setMessages(uiMessages);
}, [messagesData, setMessages]);
// Sync Convex real-time data with useChat messages
useEffect(() => {
if (!messagesData || messagesData === null) return;
// Don't overwrite in-progress assistant streaming with a DB snapshot
if (status === "streaming") return;
const uiMessages = convertToUIMessages(messagesData);
setMessages(uiMessages);
}, [messagesData, status, setMessages]);
🤖 Prompt for AI Agents
In app/components/chat.tsx around lines 128-135, the effect currently replaces
UI messages with DB snapshots unconditionally, which clobbers any in-progress
assistant streaming; gate the sync so it does not run while an assistant message
is streaming by checking the streaming/status state (e.g., isAssistantStreaming
or messageStatuses) before calling setMessages, include that streaming/status in
the effect dependency array, and merge DB messages with current UI state so an
in-flight assistant message (partial content and streaming flag) is preserved
rather than overwritten by the persisted snapshot.

Comment on lines +68 to +76
const hasTextContent = message.parts?.some(
(part: { type: string; text?: string }) =>
(part.type === "text" && part.text && part.text.trim() !== "") ||
part.type === "step-start" ||
part.type?.startsWith("tool-"),
);

const shouldShowLoader =
isLastAssistantMessage &&
status === "streaming" &&
!hasTextContent;
const shouldShowLoader =
isLastAssistantMessage && status === "streaming" && !hasTextContent;
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Loader logic misses non-text content (e.g., terminal output), causing a stuck spinner

hasTextContent only checks text, step-start, or tool-*. Parts like data-terminal (and other non-text outputs) are renderable content but currently ignored.

Apply:

-          const hasTextContent = message.parts?.some(
-            (part: { type: string; text?: string }) =>
-              (part.type === "text" && part.text && part.text.trim() !== "") ||
-              part.type === "step-start" ||
-              part.type?.startsWith("tool-"),
-          );
+          const hasRenderableContent = message.parts?.some(
+            (part: { type: string; text?: string }) =>
+              // Any non-text part renders something
+              part.type !== "text"
+                ? true
+                : Boolean(part.text && part.text.trim() !== ""),
+          );
 
-          const shouldShowLoader =
-            isLastAssistantMessage && status === "streaming" && !hasTextContent;
+          const shouldShowLoader =
+            isLastAssistantMessage && status === "streaming" && !hasRenderableContent;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const hasTextContent = message.parts?.some(
(part: { type: string; text?: string }) =>
(part.type === "text" && part.text && part.text.trim() !== "") ||
part.type === "step-start" ||
part.type?.startsWith("tool-"),
);
const shouldShowLoader =
isLastAssistantMessage &&
status === "streaming" &&
!hasTextContent;
const shouldShowLoader =
isLastAssistantMessage && status === "streaming" && !hasTextContent;
const hasRenderableContent = message.parts?.some(
(part: { type: string; text?: string }) =>
// Any non-text part renders something
part.type !== "text"
? true
: Boolean(part.text && part.text.trim() !== ""),
);
const shouldShowLoader =
isLastAssistantMessage && status === "streaming" && !hasRenderableContent;
🤖 Prompt for AI Agents
In app/components/Messages.tsx around lines 68-76, the hasTextContent check only
recognizes text, step-start, and tool-* parts so non-text renderable parts
(e.g., data-terminal, code, html, or parts carrying non-empty payload fields)
are treated as empty and keep the loader spinning; update the predicate to treat
any part with a renderable type or non-empty render payload as content — e.g.,
expand the allowed type checks to include known renderable types like
"data-terminal", "code", "html" (and any other app-specific types), and/or
consider any part that has non-empty fields such as part.content, part.data,
part.output, or part.payload as content; ensure shouldShowLoader uses the
revised hasTextContent so the loader hides when those non-text parts are
present.

Comment on lines 45 to 51
const chat = await ctx.db
.query("chats")
.withIndex("by_user_id")
.filter((q) => q.eq(q.field("id"), args.id))
.first();

return chat || null;
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Wrong index for lookup by chat id

withIndex("by_user_id") requires an equality filter on user_id, but this query filters by id. This likely errors at runtime. Introduce and use a by_id index.

Apply this diff:

-      const chat = await ctx.db
-        .query("chats")
-        .withIndex("by_user_id")
-        .filter((q) => q.eq(q.field("id"), args.id))
-        .first();
+      const chat = await ctx.db
+        .query("chats")
+        .withIndex("by_id", (q) => q.eq("id", args.id))
+        .first();

And add this index in convex/schema.ts:

// convex/schema.ts
defineTable({
  // ...fields...
})
  .index("by_user_id", ["user_id"])
  .index("by_id", ["id"]);
🤖 Prompt for AI Agents
In convex/chats.ts around lines 45-51 the query incorrectly uses
withIndex("by_user_id") while filtering by id; replace withIndex("by_user_id")
with withIndex("by_id") so the equality filter q.eq(q.field("id"), args.id) uses
the correct index, and add the new index in convex/schema.ts by calling
.index("by_id", ["id"]) on the chats table definition to register the index.

Comment on lines 123 to 128
const chat = await ctx.db
.query("chats")
.withIndex("by_user_id")
.filter((q) => q.eq(q.field("id"), args.chatId))
.first();

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Same index issue here (client save path)

Bind the by_user_id index to the authenticated user to satisfy Convex’s index filter requirements.

Apply this diff:

-      const chat = await ctx.db
-        .query("chats")
-        .withIndex("by_user_id")
-        .filter((q) => q.eq(q.field("id"), args.chatId))
-        .first();
+      const chat = await ctx.db
+        .query("chats")
+        .withIndex("by_user_id", (q) => q.eq("user_id", user.subject))
+        .filter((q) => q.eq(q.field("id"), args.chatId))
+        .first();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const chat = await ctx.db
.query("chats")
.withIndex("by_user_id")
.filter((q) => q.eq(q.field("id"), args.chatId))
.first();
const chat = await ctx.db
.query("chats")
.withIndex("by_user_id", (q) => q.eq("user_id", user.subject))
.filter((q) => q.eq(q.field("id"), args.chatId))
.first();
🤖 Prompt for AI Agents
In convex/messages.ts around lines 123-128, the query uses
.withIndex("by_user_id") without binding the authenticated user, violating
Convex index filter requirements; update the call to .withIndex("by_user_id",
[ctx.auth?.userId]) (or the concrete userId value), ensure ctx.auth.userId
exists (throw or return an auth error if not), then keep the .filter comparing
q.field("id") to args.chatId so the index is properly bound to the current user
when fetching the chat.

Comment on lines 171 to 176
const chat = await ctx.db
.query("chats")
.withIndex("by_user_id")
.filter((q) => q.eq(q.field("id"), args.chatId))
.first();

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Same index issue (delete last assistant message)

Bind by_user_id to the current user to avoid invalid/expensive scans.

Apply this diff:

-      const chat = await ctx.db
-        .query("chats")
-        .withIndex("by_user_id")
-        .filter((q) => q.eq(q.field("id"), args.chatId))
-        .first();
+      const chat = await ctx.db
+        .query("chats")
+        .withIndex("by_user_id", (q) => q.eq("user_id", user.subject))
+        .filter((q) => q.eq(q.field("id"), args.chatId))
+        .first();
🤖 Prompt for AI Agents
In convex/messages.ts around lines 171-176, the query uses
withIndex("by_user_id") but only filters by chat id, which defeats the index and
may cause scans; update the filter to bind the index by adding a condition that
q.field("user_id") equals the current user's id (ctx.auth.userId) in addition to
matching q.field("id") to args.chatId, keeping withIndex("by_user_id") so the DB
can use that index; also ensure ctx.auth.userId is validated/present before
running the query.

Comment on lines 27 to 33
messages: defineTable({
chat_id: v.string(),
role: v.string(),
parts: v.array(v.any()),
update_time: v.number(),
}).index("by_chat_id", ["chat_id"]),
});
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Add composite index for message ordering by chat/time

Fetching messages by chat_id commonly needs chronological ordering. A composite index enables efficient ordered queries.

   messages: defineTable({
     chat_id: v.string(),
     role: v.string(),
     parts: v.array(v.any()),
     update_time: v.number(),
-  }).index("by_chat_id", ["chat_id"]),
+  })
+    .index("by_chat_id", ["chat_id"])
+    .index("by_chat_id_update_time", ["chat_id", "update_time"]),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
messages: defineTable({
chat_id: v.string(),
role: v.string(),
parts: v.array(v.any()),
update_time: v.number(),
}).index("by_chat_id", ["chat_id"]),
});
messages: defineTable({
chat_id: v.string(),
role: v.string(),
parts: v.array(v.any()),
update_time: v.number(),
})
.index("by_chat_id", ["chat_id"])
.index("by_chat_id_update_time", ["chat_id", "update_time"]),
🤖 Prompt for AI Agents
In convex/schema.ts around lines 27-33, the messages table only has a
single-field index on chat_id but needs a composite index to support efficient
chronological queries; add a second index on ["chat_id","update_time"] (e.g.
.index("by_chat_id_update_time", ["chat_id","update_time"])) so queries can
filter by chat_id and order by update_time efficiently (choose ascending or
descending order semantics according to your query patterns).

import { ConvexHttpClient } from "convex/browser";
import { UIMessagePart } from "ai";

const convex = new ConvexHttpClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Fail fast if Convex URL is misconfigured

Avoid constructing the client with an undefined URL; throw a clear error early.

Apply this diff:

-const convex = new ConvexHttpClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
+const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL;
+if (!convexUrl) {
+  throw new ChatSDKError(
+    "bad_request:database",
+    "NEXT_PUBLIC_CONVEX_URL is not set",
+  );
+}
+const convex = new ConvexHttpClient(convexUrl);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const convex = new ConvexHttpClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL;
if (!convexUrl) {
throw new ChatSDKError(
"bad_request:database",
"NEXT_PUBLIC_CONVEX_URL is not set",
);
}
const convex = new ConvexHttpClient(convexUrl);
🤖 Prompt for AI Agents
In lib/db/actions.ts around line 6, the Convex client is constructed directly
from process.env.NEXT_PUBLIC_CONVEX_URL which can be undefined; instead, read
the env var into a local const, validate it and throw a clear Error if it's
missing or empty, then pass the validated URL to new ConvexHttpClient; this
ensures the app fails fast with a descriptive message rather than constructing
the client with an undefined value.

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

♻️ Duplicate comments (1)
app/components/chat.tsx (1)

128-135: Don’t clobber in-flight assistant streaming with DB snapshots

This effect blindly replaces useChat state during streaming, causing flicker and loss of partial assistant output. Gate by status === "streaming" and include it in deps.

Apply:

   useEffect(() => {
     if (!messagesData || messagesData === null) return;
-    const uiMessages = convertToUIMessages(messagesData);
-    setMessages(uiMessages);
-  }, [messagesData, setMessages]);
+    // Don't overwrite in-progress assistant streaming with a DB snapshot
+    if (status === "streaming") return;
+    const uiMessages = convertToUIMessages(messagesData);
+    setMessages(uiMessages);
+  }, [messagesData, status, setMessages]);
🧹 Nitpick comments (3)
app/api/chat/route.ts (2)

68-70: Double truncation risk (frontend + backend)

Comment states token-limit handling is done on the frontend, yet the route also truncates. This can lead to over-truncation and degraded UX.

Apply either:

  • Remove backend truncation (preferred if FE normalization is guaranteed), or
  • Guard with an idempotency check to avoid re-truncating already-normalized input.

34-48: Add request payload validation for robustness

Defensive checks for chatId (non-empty), messages (non-empty array), and mode (in allowed enum) will convert unexpected inputs into clean 4xx ChatSDKErrors instead of falling through to 5xxs.

Apply something like:

   const {
     messages,
     mode,
     todos,
     chatId,
     regenerate,
   }: {
     messages: UIMessage[];
     mode: ChatMode;
     chatId: string;
     todos?: Todo[];
     regenerate?: boolean;
   } = await req.json();
+
+  if (!chatId || typeof chatId !== "string") {
+    throw new ChatSDKError("bad_request:chat", "Missing or invalid chatId");
+  }
+  if (!Array.isArray(messages) || messages.length === 0) {
+    throw new ChatSDKError("bad_request:chat", "Missing messages");
+  }
+  if (mode !== "agent" && mode !== "ask") {
+    throw new ChatSDKError("bad_request:chat", "Invalid mode");
+  }
app/components/chat.tsx (1)

194-205: Regenerate UX: remove last assistant locally before triggering

You delete the last assistant message in DB, but the UI keeps it until Convex pushes an update, causing a flicker. Optimistically update local state before regenerate(...).

Apply:

 const handleRegenerate = async () => {
-  // Remove the last assistant message from the UI and database
+  // Remove the last assistant message from the UI and database
+  setMessages((prev) => {
+    let idx = -1;
+    for (let i = prev.length - 1; i >= 0; i--) {
+      if (prev[i].role === "assistant") {
+        idx = i;
+        break;
+      }
+    }
+    return idx >= 0 ? [...prev.slice(0, idx), ...prev.slice(idx + 1)] : prev;
+  });
   await deleteLastAssistantMessage({ chatId });

   regenerate({
     body: {
       mode,
       todos,
       regenerate: true,
     },
   });
 };
📜 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 30894b0 and 0303a5e.

📒 Files selected for processing (2)
  • app/api/chat/route.ts (6 hunks)
  • app/components/chat.tsx (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
app/components/chat.tsx (10)
app/contexts/GlobalState.tsx (1)
  • useGlobalState (137-143)
convex/messages.ts (2)
  • deleteLastAssistantMessage (157-199)
  • saveMessageFromClient (107-152)
types/chat.ts (2)
  • ChatMessage (58-58)
  • Todo (38-42)
lib/utils.ts (2)
  • convertToUIMessages (34-42)
  • fetchWithErrorHandlers (12-32)
lib/utils/message-processor.ts (1)
  • normalizeMessages (50-130)
lib/errors.ts (1)
  • ChatSDKError (36-74)
app/hooks/useMessageScroll.ts (1)
  • useMessageScroll (4-22)
app/components/Messages.tsx (1)
  • Messages (22-170)
app/components/ChatInput.tsx (1)
  • ChatInput (30-188)
app/components/ScrollToBottomButton.tsx (1)
  • ScrollToBottomButton (11-46)
app/api/chat/route.ts (7)
types/chat.ts (2)
  • ChatMode (3-3)
  • Todo (38-42)
lib/auth/get-user-id.ts (1)
  • getUserID (12-30)
lib/db/actions.ts (3)
  • handleInitialChatAndUserMessage (62-111)
  • updateChat (113-139)
  • saveMessage (40-60)
lib/ai/tools/index.ts (1)
  • createTools (17-73)
lib/actions/index.ts (1)
  • generateTitleFromUserMessageWithWriter (67-90)
convex/chats.ts (1)
  • updateChat (98-176)
convex/messages.ts (1)
  • saveMessage (8-40)
🔇 Additional comments (10)
app/api/chat/route.ts (5)

56-63: Good: centralized initial chat setup + ownership check via DB layer

Defers chat existence/ownership checks and initial user-message persistence to handleInitialChatAndUserMessage. This keeps the route lean and consistent with server-side authorization.


84-92: Tooling setup is solid

Passing getTodoManager alongside tools enables later persistence of todos and sandbox cleanup; interface aligns with createTools(...) contract.


93-101: Title generation only for new chats—nice

Running title generation in parallel only when isNewChat avoids redundant calls and reduces latency for existing chats. Emitting a transient data-title event is a good UX touch.


102-111: Verify hard stop at 25 steps is intended

stopWhen: stepCountIs(25) can prematurely halt longer tasks/tools, especially in "agent" mode.

Would you like me to remove or parameterize this limit behind an env or per-mode default?


121-130: Resilient error cleanup

Catching onError, pausing the sandbox, and awaiting titlePromise prevents leaked resources and dangling tasks even on failure.

app/components/chat.tsx (5)

25-30: Stable chatId generation is correct

Seeding chatId once with useState(() => id || uuidv4()) avoids rerender churn and keeps URL rewriting sane for new chats.


43-46: Good: conditional fetch via Convex "skip"

Defers message loading for new chats until the first submit—saves reads and avoids empty snapshots overwriting local UI.


81-95: Nice: normalize outgoing assistant parts before send

Applying normalizeMessages in prepareSendMessagesRequest keeps the server payload clean and mirrors UI state when transient parts are removed/transformed.


96-100: Solid UX: live title updates via "data-title"

Handling the transient data-title event to update UI title early feels responsive.


209-219: Header/title logic is sensible

Shows "New Chat" for fresh sessions and uses streaming or Convex data to populate thereafter. This avoids empty headers on first render.

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: 0

♻️ Duplicate comments (2)
app/api/chat/route.ts (2)

134-145: Duplicate updateChat calls still possible on abort.

Both streamText.onFinish (lines 134-145) and toUIMessageStream.onFinish (lines 154-168) can execute when the stream is aborted, resulting in duplicate updateChat calls with potentially conflicting data.

Use a shared flag to prevent duplicate updates:

 execute: async ({ writer }) => {
+  let hasUpdatedChat = false;
   const { tools, getSandbox, getTodoManager } = createTools(
     userID,
     writer,
     mode,
     executionMode,
     userLocation,
     todos,
   );

   // ... title generation ...

   const result = streamText({
     // ... config ...
     onFinish: async ({ finishReason }) => {
+      if (hasUpdatedChat) return;
       const generatedTitle = await titlePromise;
       const currentTodos = getTodoManager().getAllTodos();

       if (generatedTitle || finishReason || currentTodos.length > 0) {
+        hasUpdatedChat = true;
         await updateChat({
           chatId,
           title: generatedTitle,
           finishReason,
           todos: currentTodos.length > 0 ? currentTodos : undefined,
         });
       }
     },
     // ... rest ...
   });

   writer.merge(
     result.toUIMessageStream({
       onFinish: async ({ isAborted }) => {
         if (isAborted) {
+          if (!hasUpdatedChat) {
             console.log("Stream was aborted 3");
             const generatedTitle = await titlePromise;
             const currentTodos = getTodoManager().getAllTodos();

             if (generatedTitle || currentTodos.length > 0) {
+              hasUpdatedChat = true;
               await updateChat({
                 chatId,
                 title: generatedTitle,
                 finishReason: "abort",
                 todos: currentTodos.length > 0 ? currentTodos : undefined,
               });
             }
+          }
         } else {
           console.log("Stream completed normally");
         }
         // ... sandbox cleanup ...
       },
     }),
   );

Also applies to: 154-168


183-190: User messages persisted twice.

The onFinish handler persists all messages including user messages, but handleInitialChatAndUserMessage already saves user messages (unless regenerate is true). This creates duplicate user message entries.

Only persist assistant messages here:

 onFinish: async ({ messages }) => {
   for (const message of messages) {
+    if (message.role !== "assistant") continue;
     await saveMessage({
       chatId,
       message,
     });
   }
 },
🧹 Nitpick comments (3)
app/api/chat/route.ts (3)

125-126: Remove or make console.log statements more informative.

The console.log statements for debugging stream aborts should either be removed for production or enhanced with more context (e.g., user ID, chat ID).

Either remove the debug logs or enhance them:

-console.log("Stream was aborted 1", error);
+console.error(`Stream aborted during processing - chatId: ${chatId}, userId: ${userID}`, error);

-console.log("Stream was aborted 2", error);
+console.error(`Stream onAbort triggered - chatId: ${chatId}`, error);

-console.log("Stream was aborted 3");
+console.log(`Handling stream abort cleanup - chatId: ${chatId}`);

-console.log("Stream completed normally");
+console.log(`Stream completed successfully - chatId: ${chatId}`);

Also applies to: 148-149, 156-156, 170-170


9-9: Unused import: consumeStream.

The consumeStream import from the "ai" module is not used anywhere in this file.

Remove the unused import:

 import {
   convertToModelMessages,
   createUIMessageStream,
   createUIMessageStreamResponse,
   stepCountIs,
   streamText,
   UIMessage,
   smoothStream,
-  consumeStream,
 } from "ai";

122-133: Inconsistent error handling between onError and stream aborts.

The onError handler performs cleanup but doesn't update the chat with an error state, while abort handlers do update the chat. Consider also updating the chat with error information for consistency.

Consider updating the chat with error information:

 onError: async (error) => {
   console.error("Error:", error);
   console.log("Stream was aborted 1", error);

+  // Update chat with error state
+  const generatedTitle = await titlePromise;
+  const currentTodos = getTodoManager().getAllTodos();
+  
+  if (generatedTitle || currentTodos.length > 0) {
+    await updateChat({
+      chatId,
+      title: generatedTitle,
+      finishReason: "error",
+      todos: currentTodos.length > 0 ? currentTodos : undefined,
+    });
+  }

   // Perform same cleanup as onFinish to prevent resource leaks
   const sandbox = getSandbox();
   if (sandbox) {
     await pauseSandbox(sandbox);
   }
-  await titlePromise;
 },
📜 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 0303a5e and ef54de1.

📒 Files selected for processing (1)
  • app/api/chat/route.ts (6 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
app/api/chat/route.ts (5)
lib/auth/get-user-id.ts (1)
  • getUserID (12-30)
lib/db/actions.ts (3)
  • handleInitialChatAndUserMessage (62-111)
  • updateChat (113-139)
  • saveMessage (40-60)
lib/ai/tools/index.ts (1)
  • createTools (17-73)
lib/actions/index.ts (1)
  • generateTitleFromUserMessageWithWriter (67-90)
lib/ai/tools/utils/sandbox.ts (1)
  • pauseSandbox (152-169)

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: 2

Caution

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

⚠️ Outside diff range comments (1)
lib/ai/tools/utils/sandbox.ts (1)

54-67: Implement a Runtime Fallback from betaPause() to pause()

To future-proof against SDK API drift in the @e2b/code-interpreter→e2b Sandbox API, retain the existing betaPause() call but fall back at runtime to pause() if betaPause is ever renamed or removed. Although e2b v2.0.1 today only exposes betaPause(), this zero-cost guard ensures we won’t break on future minor/patch releases.

Please apply this refactor in both code paths (around lines 54–67 and 160–166 in lib/ai/tools/utils/sandbox.ts):

  • lib/ai/tools/utils/sandbox.ts:54–67
-              await runningSandbox.betaPause()
+              // Prefer betaPause, fall back to pause for SDK compatibility
+              const pauseFn =
+                (runningSandbox as any).betaPause ??
+                (runningSandbox as any).pause;
+              if (!pauseFn) {
+                throw new Error("Sandbox pause method not available in this SDK");
+              }
+              await pauseFn.call(runningSandbox);
  • lib/ai/tools/utils/sandbox.ts:160–166
-  safeWaitUntil(
-    sandbox.betaPause().catch((error) => {
+  safeWaitUntil(
+    (async () => {
+      const pauseFn = (sandbox as any).betaPause ?? (sandbox as any).pause;
+      if (!pauseFn) throw new Error("Sandbox pause method not available");
+      await pauseFn.call(sandbox);
+    })().catch((error) => {

Also update the secondary occurrence at lines 160–166 and similarly at lines 54–67. This ensures that if e2b ever standardizes on pause(), our code will continue to work without interruption.

♻️ Duplicate comments (4)
.env.local.example (1)

12-14: Past issue resolved: inline comment moved off the value line.

The previous invalid dotenv syntax for WORKOS_COOKIE_PASSWORD is fixed by moving the comment to its own line and providing a placeholder value. LGTM.

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

131-144: Avoid duplicate updateChat patches on abort vs. finish with a shared flag

Both streamText.onFinish and toUIMessageStream.onFinish can execute on abort and double-patch finishReason. Gate with a shared flag in the execute closure.

-    const stream = createUIMessageStream({
-      execute: async ({ writer }) => {
+    const stream = createUIMessageStream({
+      execute: async ({ writer }) => {
+        let wasAborted = false;
         const { tools, getSandbox, getTodoManager } = createTools(
           userID,
           writer,
           mode,
           executionMode,
           userLocation,
           todos,
         );
@@
-          onFinish: async ({ finishReason }) => {
+          onFinish: async ({ finishReason }) => {
+            if (wasAborted) return; // abort path handled in UI stream onFinish
             const generatedTitle = await titlePromise;
             const currentTodos = getTodoManager().getAllTodos();
 
             if (generatedTitle || finishReason || currentTodos.length > 0) {
               await updateChat({
                 chatId,
                 title: generatedTitle,
                 finishReason,
                 todos: currentTodos.length > 0 ? currentTodos : undefined,
               });
             }
           },
@@
-            onFinish: async ({ isAborted, messages }) => {
+            onFinish: async ({ isAborted, messages }) => {
               if (isAborted) {
-                console.log("Stream was aborted 3");
+                console.log("Stream was aborted 3");
+                wasAborted = true;
                 // Handle abort-specific cleanup
                 const generatedTitle = await titlePromise;
                 const currentTodos = getTodoManager().getAllTodos();
 
                 if (generatedTitle || currentTodos.length > 0) {
                   await updateChat({
                     chatId,
                     title: generatedTitle,
                     finishReason: "abort",
                     todos: currentTodos.length > 0 ? currentTodos : undefined,
                   });
                 }
               } else {
                 console.log("Stream completed normally");

Also applies to: 149-166


169-175: Prevent duplicate user-message persistence; only persist assistant messages here

User messages are already saved by handleInitialChatAndUserMessage (unless regenerate). Persisting all messages here duplicates the last user entry.

-                // Handle normal completion
-                for (const message of messages) {
-                  await saveMessage({
-                    chatId,
-                    message,
-                  });
-                }
+                // Handle normal completion: persist assistant messages only
+                for (const message of messages) {
+                  if (message.role !== "assistant") continue;
+                  await saveMessage({ chatId, message });
+                }
lib/db/actions.ts (1)

6-6: Fail fast on missing NEXT_PUBLIC_CONVEX_URL

Constructing the client with an undefined URL will produce opaque errors later.

-const convex = new ConvexHttpClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
+const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL;
+if (!convexUrl) {
+  throw new ChatSDKError("bad_request:database", "NEXT_PUBLIC_CONVEX_URL is not set");
+}
+const convex = new ConvexHttpClient(convexUrl);
🧹 Nitpick comments (13)
.env.local.example (6)

13-13: QuoteCharacter warning is acceptable for secrets.

dotenv-linter warns about quotes, but keeping the password quoted is safer for users who paste secrets containing spaces or special characters. I recommend keeping quotes and ignoring this warning for this line.

If you prefer a clean linter run, you can also change to an unquoted placeholder that contains only safe characters:

-WORKOS_COOKIE_PASSWORD="replace-with-a-32+char-random-secret"
+WORKOS_COOKIE_PASSWORD=replace-with-a-32+char-random-secret

But be sure to document that real values may require quotes.


5-8: Provide concrete local defaults for Convex to reduce setup friction.

Empty placeholders slow down onboarding. Consider adding sensible local dev defaults and a short hint.

Apply:

-# Convex deployment configuration
-CONVEX_DEPLOYMENT=
-NEXT_PUBLIC_CONVEX_URL=
+# Convex deployment configuration
+# Example for local dev (adjust if your Convex dev server uses a different port)
+CONVEX_DEPLOYMENT=dev
+NEXT_PUBLIC_CONVEX_URL=http://localhost:3210

17-22: Clarify whether OpenRouter is truly “Required.”

This section sits under “API CONFIGURATION (Required).” If the app won’t function without an LLM provider, keep it; otherwise, relabel as optional and note supported providers. Add a one-liner pointing users to where it’s read (e.g., server/client file).

Example tweak:

-# API CONFIGURATION (Required)
+# API CONFIGURATION (Required for AI responses)

27-33: Optional keys look good; one small DX improvement.

Consider adding a brief note that TERMINAL_EXECUTION_MODE=sandbox requires the E2B key, otherwise keep it commented.


34-36: Public model config: add guidance on defaults.

Call out that these are public (safe to commit) and optional, and mention any defaults the app uses when unset.

Example:

-# AI Model Configuration
-# NEXT_PUBLIC_AGENT_MODEL=
-# NEXT_PUBLIC_TITLE_MODEL=
+# AI Model Configuration (public; optional — app uses sensible defaults if unset)
+# NEXT_PUBLIC_AGENT_MODEL=
+# NEXT_PUBLIC_TITLE_MODEL=

49-49: Add a trailing newline.

Satisfy the linter’s EndingBlankLine rule.

Apply:

-# CONVEX_SERVICE_ROLE_KEY=
+# CONVEX_SERVICE_ROLE_KEY=
+
lib/ai/tools/utils/sandbox.ts (2)

63-66: Retry strategy: switch from fixed sleep to exponential backoff with jitter

Fixed 5s waits can amplify thundering herd under partial outages. Use exponential backoff with jitter for more resilient pause retries.

-                await new Promise((resolve) => setTimeout(resolve, 5000));
+                const base = 1000; // 1s
+                const delay = Math.min(5000, base * 2 ** attempt);
+                const jitter = Math.random() * 250;
+                await new Promise((r) => setTimeout(r, delay + jitter));

15-20: Minor: docstring and log label drift from actual function name

The flow comments still mention “resume”, and the error log says createOrConnectTerminal while the function is createOrConnectPersistentTerminal.

- * 2a. If found with "running" state: pause first (3 retries), then resume
- * 2b. If found with "paused" state: resume directly (no retries needed)
+ * 2a. If found with "running" state: pause first (3 retries), then connect
+ * 2b. If found with "paused" state: connect directly (no retries needed)
-    console.error(`[${userID}] Error in createOrConnectTerminal:`, error);
+    console.error(`[${userID}] Error in createOrConnectPersistentTerminal:`, error);

Also applies to: 134-136

package.json (1)

51-53: Add engines to guard runtime compatibility (Next 15 + React 19 + Convex + UUID 11)

Lock Node and npm versions to reduce CI/prod drift, especially with Next 15 and UUID 11.

   "devDependencies": {
     ...
     "typescript": "^5"
   },
+  "engines": {
+    "node": ">=18.18 <23",
+    "npm": ">=9"
+  }

Also applies to: 60-60

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

119-130: Consider updating finishReason on error path as well

Currently, onError only pauses the sandbox and waits for title generation. Persisting finishReason: "error" improves the chat’s final state fidelity.

           onError: async (error) => {
             console.error("Error:", error);
 
             console.log("Stream was aborted 1", error);
 
             // Perform same cleanup as onFinish to prevent resource leaks
             const sandbox = getSandbox();
             if (sandbox) {
               await pauseSandbox(sandbox);
             }
             await titlePromise;
+            try {
+              await updateChat({ chatId, finishReason: "error" });
+            } catch (e) {
+              console.warn("Failed to mark chat as errored:", e);
+            }
           },

32-50: ’ss current run appear to have no obvious type-safety issues with geolocation(req)—the helper explicitly accepts a standard Request (see Astro guide) and returns geo fields (astro.webredirect.org)—and since this file lives under the App Router, it runs as an Edge Function by default (no export const runtime override found), so at runtime req will indeed be a NextRequest instance. Unless you have explicitly opted into the Node.js runtime via export const runtime = 'nodejs', your cast to NextRequest is safe but redundant; you can improve clarity by changing the handler signature directly.

• Change:

export async function POST(req: Request) {

to:

import { NextRequest } from 'next/server';
export async function POST(req: NextRequest) {

This removes the as NextRequest cast on line 48, strengthens the signature, and aligns with Vercel Edge conventions.

Tagging as an optional refactor—your code works as-is, but this tweak improves type safety and readability.

lib/db/actions.ts (2)

90-97: Ownership check: clarify error code for consistency

The thrown error uses "forbidden:chat". Confirm your error taxonomy expects "forbidden" to map to 403 and surfaces on the UI. Otherwise, align with your other auth errors (e.g., "unauthorized:chat").

If consistent, no change needed. If not, adjust the code or the error mapping in lib/errors.ts.


51-56: Use server-side service role keys via Convex’s Server SDK or HTTP API

Convex supports keeping your service role key entirely on the server—either by using the server-only SDK (which picks up CONVEX_SERVICE_ROLE_KEY from your environment) or by sending it in an Authorization: Bearer <key> header when calling the HTTP API—so you never need to pass it as a mutation argument.

Refactor suggestions:

  • Configure a dedicated server-side Convex client (e.g. in lib/db/client.ts):
    import { Convex } from "convex/server";
    
    // This client automatically uses process.env.CONVEX_SERVICE_ROLE_KEY
    export const serverConvex = new Convex();
  • In lib/db/actions.ts, update your mutation calls (lines 51–56):
    Before
    return await convex.mutation(api.messages.saveMessage, {
      serviceKey: process.env.CONVEX_SERVICE_ROLE_KEY!,
      chatId,
      role: message.role,
      parts: message.parts,
    });
    After
    return await serverConvex.mutation(api.messages.saveMessage, {
      chatId,
      role: message.role,
      parts: message.parts,
    });
  • Remove the serviceKey field from the saveMessage mutation signature (and any other mutations/queries on lines 129–135) so your API surface only accepts application data, not credentials.

References:
• Convex Authentication & Service Role Keys (Authorization header or server SDK)
• Convex Server SDK / HTTP API examples

📜 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 ef54de1 and d6ba43d.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (5)
  • .env.local.example (1 hunks)
  • app/api/chat/route.ts (4 hunks)
  • lib/ai/tools/utils/sandbox.ts (4 hunks)
  • lib/db/actions.ts (1 hunks)
  • package.json (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
lib/db/actions.ts (3)
convex/chats.ts (3)
  • getChatById (7-57)
  • saveChat (62-93)
  • updateChat (98-176)
lib/errors.ts (1)
  • ChatSDKError (36-74)
convex/messages.ts (1)
  • saveMessage (8-40)
app/api/chat/route.ts (8)
types/chat.ts (2)
  • ChatMode (3-3)
  • Todo (38-42)
lib/auth/get-user-id.ts (1)
  • getUserID (12-30)
lib/db/actions.ts (3)
  • handleInitialChatAndUserMessage (62-111)
  • updateChat (113-139)
  • saveMessage (40-60)
lib/ai/tools/index.ts (1)
  • createTools (17-73)
lib/actions/index.ts (1)
  • generateTitleFromUserMessageWithWriter (67-90)
lib/ai/tools/utils/sandbox.ts (1)
  • pauseSandbox (152-169)
convex/chats.ts (1)
  • updateChat (98-176)
convex/messages.ts (1)
  • saveMessage (8-40)
🪛 dotenv-linter (3.3.0)
.env.local.example

[warning] 13-13: [QuoteCharacter] The value has quote characters (', ")

(QuoteCharacter)


[warning] 14-14: [UnorderedKey] The NEXT_PUBLIC_WORKOS_REDIRECT_URI key should go before the WORKOS_API_KEY key

(UnorderedKey)


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

(EndingBlankLine)

🔇 Additional comments (5)
.env.local.example (3)

43-46: Analytics block is clear and correct.

Quoting the PostHog host is good; avoids parsing surprises. Nothing to change.


9-15: Reorder WorkOS keys for linting and clarify optional status

  • dotenv-linter’s UnorderedKey rule enforces alphabetical order, so NEXT_PUBLIC_WORKOS_REDIRECT_URI must come before the WORKOS_* entries (webinstall.dev).
  • Since WorkOS gating was relaxed in this PR, update the section heading to indicate these variables are optional and only required when enabling WorkOS login.

Apply this minimal diff:

-# WorkOS Authentication (Required for user management and conversation persistence)
-WORKOS_API_KEY=sk_example_123456789
-WORKOS_CLIENT_ID=client_123456789
-# generate a secure password (min 32 characters)
-WORKOS_COOKIE_PASSWORD="replace-with-a-32+char-random-secret"
-NEXT_PUBLIC_WORKOS_REDIRECT_URI=http://localhost:3000/callback
+# WorkOS Authentication (Optional — only needed when enabling WorkOS login)
+NEXT_PUBLIC_WORKOS_REDIRECT_URI=http://localhost:3000/callback
+WORKOS_API_KEY=sk_example_123456789
+WORKOS_CLIENT_ID=client_123456789
+# generate a secure password (min 32 characters)
+WORKOS_COOKIE_PASSWORD="replace-with-a-32+char-random-secret"

Follow-up:

  • Confirm that the redirect URI (/callback) matches your Next.js route (e.g. pages/callback.tsx vs pages/auth/callback.tsx) to prevent setup errors.

1-49: Env var usage verification complete

I ran a repo-wide scan for all keys listed in .env.local.example and here are the results:

• Referenced in code:

  • NEXT_PUBLIC_CONVEX_URL (lib/db/actions.ts, components/ConvexClientProvider.tsx)
  • WORKOS_CLIENT_ID (convex/auth.config.ts)
  • EXA_API_KEY (lib/ai/tools/index.ts & web-search.ts)
  • TERMINAL_EXECUTION_MODE (app/api/chat/route.ts)
  • NEXT_PUBLIC_AGENT_MODEL & NEXT_PUBLIC_TITLE_MODEL (lib/ai/providers.ts)
  • UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN & RATE_LIMIT_REQUESTS (lib/rate-limit.ts)
  • NEXT_PUBLIC_POSTHOG_KEY & NEXT_PUBLIC_POSTHOG_HOST (app/posthog.js)
  • CONVEX_SERVICE_ROLE_KEY (convex/chats.ts, convex/messages.ts, lib/db/actions.ts)

• Not found anywhere:

  • CONVEX_DEPLOYMENT
  • WORKOS_API_KEY
  • WORKOS_COOKIE_PASSWORD
  • NEXT_PUBLIC_WORKOS_REDIRECT_URI
  • OPENROUTER_API_KEY
  • E2B_API_KEY

Please remove any unused vars from .env.local.example or add the missing references in code if you intend to use them.

lib/ai/tools/utils/sandbox.ts (1)

76-80: Confirm SDK’s resume() support before adding fallback

I didn’t find any static resume implementation in lib/ai/tools/utils/sandbox.ts (Sandbox is imported from @e2b/code-interpreter), so it’s unclear whether the external SDK actually exposes a resume() method. Please verify in the SDK’s documentation or source that:

  • Sandbox.connect() may fail on paused sandboxes, and
  • Sandbox.resume() exists with the same signature as shown in the diff.

If both are true, add the suggested try { connect() } catch { resume() } fallback. Otherwise, adjust your logic to match the SDK’s actual API.

package.json (1)

14-18: SDK upgrade sanity check complete: no legacy pause()/resume() calls remain

  • Searched all .ts and .tsx files for Sandbox.pause, Sandbox.resume, and other E2B APIs
  • Only occurrences found in lib/ai/tools/utils/sandbox.ts use the new Sandbox.connect, sandbox.betaPause(), and Sandbox.kill methods
  • No remaining pause() or resume() calls detected outside the updated sandbox utilities

All usages align with the @e2b/code-interpreter 2.x API surface. No further changes required.

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: 2

♻️ Duplicate comments (3)
app/api/chat/route.ts (3)

137-149: Duplicate updateChat on abort vs. normal finish — gate with a shared flag.

Both streamText.onFinish and the UI stream’s onFinish can run on abort; each calls updateChat, causing double patches and races.

Introduce a boolean guard at the start of execute and use it in both onFinish handlers:

 execute: async ({ writer }) => {
+  // Ensure updateChat is patched at most once per request
+  let didPatch = false;
   const { tools, getSandbox, getTodoManager } = createTools(
     userID,
     writer,
     mode,
     executionMode,
     userLocation,
     todos,
   );
@@
-          onFinish: async ({ finishReason }) => {
-            const generatedTitle = await titlePromise;
-            const currentTodos = getTodoManager().getAllTodos();
-
-            if (generatedTitle || finishReason || currentTodos.length > 0) {
-              await updateChat({
-                chatId,
-                title: generatedTitle,
-                finishReason,
-                todos: currentTodos.length > 0 ? currentTodos : undefined,
-              });
-            }
-          },
+          onFinish: async ({ finishReason }) => {
+            const generatedTitle = await titlePromise;
+            const currentTodos = getTodoManager().getAllTodos();
+            if (didPatch) return;
+            if (generatedTitle || finishReason || currentTodos.length > 0) {
+              didPatch = true;
+              await updateChat({
+                chatId,
+                title: generatedTitle,
+                finishReason,
+                todos: currentTodos.length > 0 ? currentTodos : undefined,
+              });
+            }
+          },

155-171: Mirror the one-patch guard in the UI stream’s abort path.

Without this, abort flows still double-patch.

Apply:

         writer.merge(
           result.toUIMessageStream({
             onFinish: async ({ isAborted, messages }) => {
               if (isAborted) {
                 console.log("Stream was aborted 3");
                 // Handle abort-specific cleanup
                 const generatedTitle = await titlePromise;
                 const currentTodos = getTodoManager().getAllTodos();

-                if (generatedTitle || currentTodos.length > 0) {
-                  await updateChat({
-                    chatId,
-                    title: generatedTitle,
-                    finishReason: "abort",
-                    todos: currentTodos.length > 0 ? currentTodos : undefined,
-                  });
-                }
+                if (!didPatch && (generatedTitle || currentTodos.length > 0)) {
+                  didPatch = true;
+                  await updateChat({
+                    chatId,
+                    title: generatedTitle,
+                    finishReason: "abort",
+                    todos: currentTodos.length > 0 ? currentTodos : undefined,
+                  });
+                }
               } else {
                 console.log("Stream completed normally");
                 // Handle normal completion

173-181: Message persistence duplicates entire history (and re-saves the user message). Persist only the final assistant message.

Looping all messages will N× duplicate the transcript and re-save user/system messages that are already persisted upstream.

Replace the loop with a targeted save of the last assistant message:

-                // Handle normal completion
-                for (const message of messages) {
-                  await saveMessage({
-                    chatId,
-                    message,
-                  });
-                }
+                // Handle normal completion: persist only the latest assistant message
+                const last = messages[messages.length - 1];
+                if (last?.role === "assistant") {
+                  await saveMessage({ chatId, message: last });
+                }

Please also verify the client no longer saves the assistant on stop to avoid server+client duplication. Run:

#!/bin/bash
# Find all saveMessage call sites and client stop handlers
rg -nP -C3 '\bsaveMessage\s*\('
rg -nP -C3 'onStop|onFinish|stop\(' app | sed -n '1,200p'
🧹 Nitpick comments (2)
app/api/chat/route.ts (2)

125-136: Consider marking error terminations in chat metadata (finishReason: "error").

Currently errors only pause the sandbox. Updating the chat helps the UI reflect state and avoids limbo after failures. Guard with the same didPatch flag to prevent races.

           onError: async (error) => {
             console.error("Error:", error);
 
             console.log("Stream was aborted 1", error);
 
             // Perform same cleanup as onFinish to prevent resource leaks
             const sandbox = getSandbox();
             if (sandbox) {
               await pauseSandbox(sandbox);
             }
-            await titlePromise;
+            const generatedTitle = await titlePromise;
+            const currentTodos = getTodoManager().getAllTodos();
+            if (typeof didPatch !== "undefined" && !didPatch) {
+              didPatch = true;
+              await updateChat({
+                chatId,
+                title: generatedTitle,
+                finishReason: "error",
+                todos: currentTodos.length > 0 ? currentTodos : undefined,
+              });
+            }
           },

72-74: Comment is misleading.

The comment says truncation is “now done on frontend” but we still run truncateMessagesToTokenLimit on the server. Either remove the comment or clarify why we still run a defensive truncation here.

📜 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 d6ba43d and a9cf768.

📒 Files selected for processing (1)
  • app/api/chat/route.ts (4 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
app/api/chat/route.ts (5)
lib/auth/get-user-id.ts (1)
  • getUserID (12-30)
lib/db/actions.ts (3)
  • handleInitialChatAndUserMessage (62-111)
  • updateChat (113-139)
  • saveMessage (40-60)
lib/ai/tools/index.ts (1)
  • createTools (17-73)
lib/actions/index.ts (1)
  • generateTitleFromUserMessageWithWriter (67-90)
lib/ai/tools/utils/sandbox.ts (1)
  • pauseSandbox (152-169)

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: 1

♻️ Duplicate comments (4)
app/api/chat/route.ts (4)

35-39: Propagate request abort to the model to prevent runaway compute

Currently you only log the abort; the model stream keeps running because controller.abort() is never called. Wire req.signalcontroller so the stream cancels when the client disconnects.

   const controller = new AbortController();

   req.signal.addEventListener("abort", () => {
     console.log("Request aborted");
+    if (!controller.signal.aborted) {
+      controller.abort(); // propagate to streamText/generation
+    }
   });

41-54: Add payload validation and return 4xx for missing chatId/messages

A missing chatId or empty messages currently trickles into internal errors (likely 5xx). Validate early and throw a ChatSDKError("bad_request:payload", ...).

     }: {
       messages: UIMessage[];
       mode: ChatMode;
       chatId: string;
       todos?: Todo[];
       regenerate?: boolean;
     } = await req.json();
 
+    if (
+      !chatId ||
+      typeof chatId !== "string" ||
+      !Array.isArray(messages) ||
+      messages.length === 0
+    ) {
+      throw new ChatSDKError(
+        "bad_request:payload",
+        "chatId and at least one message are required",
+      );
+    }

88-90: Gate updateChat on abort vs. normal finish to avoid double patches

Both streamText.onFinish (Lines 139-151) and toUIMessageStream.onFinish (Lines 161-176) can run on abort, racing and causing duplicate updateChat patches. Use a shared flag to ensure only one path updates.

-    const stream = createUIMessageStream({
+    let wasAborted = false;
+    const stream = createUIMessageStream({
       execute: async ({ writer }) => {
         const { tools, getSandbox, getTodoManager } = createTools(
           userID,
           writer,
           mode,
           executionMode,
           userLocation,
           todos,
         );
@@
-          onFinish: async ({ finishReason }) => {
+          onFinish: async ({ finishReason }) => {
+            if (wasAborted) return; // abort path already handled
             const generatedTitle = await titlePromise;
             const currentTodos = getTodoManager().getAllTodos();
 
             if (generatedTitle || finishReason || currentTodos.length > 0) {
               await updateChat({
                 chatId,
                 title: generatedTitle,
                 finishReason,
                 todos: currentTodos.length > 0 ? currentTodos : undefined,
               });
             }
           },
           onAbort: async (error) => {
-            console.log("Stream was aborted 2", error);
+            console.log("Stream was aborted 2", error);
+            wasAborted = true;
           },
         });
@@
         writer.merge(
           result.toUIMessageStream({
-            onFinish: async ({ isAborted, messages }) => {
+            onFinish: async ({ isAborted, messages }) => {
               if (isAborted) {
-                console.log("Stream was aborted 3");
+                console.log("Stream was aborted 3");
+                wasAborted = true;
                 // Handle abort-specific cleanup
                 const generatedTitle = await titlePromise;
                 const currentTodos = getTodoManager().getAllTodos();
 
                 if (generatedTitle || currentTodos.length > 0) {
                   await updateChat({
                     chatId,
                     title: generatedTitle,
                     finishReason: "abort",
                     todos: currentTodos.length > 0 ? currentTodos : undefined,
                   });
                 }
-              }
+              } else {
+                // Normal completion: persistence happens here (see separate comment)
+              }
 
               const sandbox = getSandbox();
               if (sandbox) {
                 await pauseSandbox(sandbox);
               }
             },
           }),
         );

Also applies to: 139-151, 152-154, 159-176


187-196: Persist only the newly generated assistant message; avoid duplicating user/history

handleInitialChatAndUserMessage already persisted the user message (unless regenerate). Here you re-save the entire messages array, duplicating previous entries and user messages. Persist only the last assistant message on normal completion, and do nothing on abort.

-      onFinish: async ({ messages }) => {
-        console.log("Stream completed normally 3");
-        // Handle normal completion
-        for (const message of messages) {
-          await saveMessage({
-            chatId,
-            message,
-          });
-        }
-      },
+      onFinish: async () => {
+        console.log("Stream completed");
+        // No-op here; persistence happens inside toUIMessageStream.onFinish
+        // to avoid duplicates and to know if it was aborted.
+      },

And inside toUIMessageStream({ onFinish }) (Lines 161-176), persist only the latest assistant message when not aborted:

-              } else {
-                // Normal completion: persistence happens here (see separate comment)
-              }
+              } else {
+                // Normal completion: persist only the latest assistant message
+                const last = messages[messages.length - 1];
+                if (last?.role === "assistant") {
+                  await saveMessage({ chatId, message: last });
+                }
+              }

Also applies to: 159-176

🧹 Nitpick comments (2)
app/api/chat/route.ts (2)

127-131: Clarify error log message

This log prints “Stream was aborted 1” inside onError, which can be misleading for non-abort errors.

-            console.log("Stream was aborted 1", error);
+            console.log("Stream error (not necessarily abort)", error);

99-107: Title generation: confirm abort signal wiring after controller fix

Once abort propagation is added, passing controller.signal here is fine. If you later remove the controller and pass abortSignal: req.signal directly into streamText, also pass req.signal into generateTitleFromUserMessageWithWriter for consistency.

📜 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 a9cf768 and e275b69.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (2)
  • app/api/chat/route.ts (4 hunks)
  • lib/ai/tools/utils/sandbox.ts (4 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • lib/ai/tools/utils/sandbox.ts
🧰 Additional context used
🧬 Code graph analysis (1)
app/api/chat/route.ts (9)
types/chat.ts (2)
  • ChatMode (3-3)
  • Todo (38-42)
lib/auth/get-user-id.ts (1)
  • getUserID (12-30)
lib/rate-limit.ts (1)
  • checkRateLimit (6-63)
lib/db/actions.ts (3)
  • handleInitialChatAndUserMessage (62-111)
  • updateChat (113-139)
  • saveMessage (40-60)
lib/ai/tools/index.ts (1)
  • createTools (17-73)
lib/actions/index.ts (1)
  • generateTitleFromUserMessageWithWriter (67-90)
lib/ai/tools/utils/sandbox.ts (1)
  • pauseSandbox (152-169)
convex/chats.ts (1)
  • updateChat (98-176)
convex/messages.ts (1)
  • saveMessage (8-40)

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: 4

♻️ Duplicate comments (9)
lib/db/actions.ts (1)

1-7: Mark server-only and fail fast on misconfigured Convex URL

  • Prevent accidental client import (and secret exposure) by marking this module as server-only.
  • Avoid constructing Convex client with an undefined URL; fail fast with a clear error.
+import "server-only";
 import { api } from "@/convex/_generated/api";
 import { ChatSDKError } from "../errors";
 import { ConvexHttpClient } from "convex/browser";
 import { UIMessagePart } from "ai";

-const convex = new ConvexHttpClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
+const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL;
+if (!convexUrl) {
+  throw new ChatSDKError(
+    "bad_request:database",
+    "NEXT_PUBLIC_CONVEX_URL is not set",
+  );
+}
+const convex = new ConvexHttpClient(convexUrl);
app/api/chat/route.ts (4)

36-38: Propagate request abort to the model stream

Currently only logs abort; the stream continues running. Abort the controller to cancel model computation and free resources.

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

41-53: Validate payload (chatId and messages) to return 4xx instead of 5xx

Missing/invalid chatId or messages leads to downstream crashes. Fail fast with a clear client error.

     }: {
       messages: UIMessage[];
       mode: ChatMode;
       chatId: string;
       todos?: Todo[];
       regenerate?: boolean;
     } = await req.json();
+
+    if (
+      !chatId ||
+      typeof chatId !== "string" ||
+      !Array.isArray(messages) ||
+      messages.length === 0
+    ) {
+      throw new ChatSDKError(
+        "bad_request:payload",
+        "chatId and at least one message are required",
+      );
+    }

159-171: Avoid duplicating user messages during persistence

handleInitialChatAndUserMessage already saves the user message (unless regenerate). Persist only assistant messages here.

-        writer.merge(
-          result.toUIMessageStream({
-            generateMessageId: uuidv4,
-            onFinish: async ({ messages }) => {
-              for (const message of messages) {
-                await saveMessage({
-                  chatId,
-                  message,
-                });
-              }
-            },
-          }),
-        );
+        writer.merge(
+          result.toUIMessageStream({
+            generateMessageId: uuidv4,
+            onFinish: async ({ messages }) => {
+              for (const message of messages) {
+                if (message.role !== "assistant") continue;
+                await saveMessage({ chatId, message });
+              }
+            },
+          }),
+        );

8-9: Return a proper SSE response (sets required headers)

Use the helper to set Content-Type: text/event-stream and related headers. Improves client compatibility.

-import {
-  JsonToSseTransformStream,
-} from "ai";
+import { createUIMessageStreamResponse } from "ai";
@@
-    return new Response(stream.pipeThrough(new JsonToSseTransformStream()));
+    return createUIMessageStreamResponse(stream);

Also applies to: 175-175

convex/messages.ts (2)

80-87: Bind the by_user_id index correctly to satisfy Convex index requirements

Must constrain the indexed field (user_id) with an equality in withIndex to avoid invalid/expensive scans.

-      const chat = await ctx.db
-        .query("chats")
-        .withIndex("by_user_id")
-        .filter((q) => q.eq(q.field("id"), args.chatId))
-        .first();
+      const chat = await ctx.db
+        .query("chats")
+        .withIndex("by_user_id", (q) => q.eq("user_id", user.subject))
+        .filter((q) => q.eq(q.field("id"), args.chatId))
+        .first();

140-145: Same index binding issue in saveMessageFromClient

Bind by_user_id to the authenticated user before filtering by chat id.

-      const chat = await ctx.db
-        .query("chats")
-        .withIndex("by_user_id")
-        .filter((q) => q.eq(q.field("id"), args.chatId))
-        .first();
+      const chat = await ctx.db
+        .query("chats")
+        .withIndex("by_user_id", (q) => q.eq("user_id", user.subject))
+        .filter((q) => q.eq(q.field("id"), args.chatId))
+        .first();
app/components/chat.tsx (2)

128-134: Don’t clobber in-progress assistant streaming with DB snapshots

Skip overwriting messages while streaming to avoid flicker and partial-content loss.

-  useEffect(() => {
-    if (!messagesData || messagesData === null) return;
-
-    const uiMessages = convertToUIMessages(messagesData);
-    setMessages(uiMessages);
-  }, [messagesData, setMessages]);
+  useEffect(() => {
+    if (!messagesData || messagesData === null) return;
+    if (status === "streaming") return; // preserve in-flight assistant
+    const uiMessages = convertToUIMessages(messagesData);
+    setMessages(uiMessages);
+  }, [messagesData, status, setMessages]);

172-193: Remove client-side save-on-stop to avoid duplicates and races

The server persists assistant messages at stream end. Saving here duplicates rows and races with stop().

-  const handleStop = async () => {
-    // Save the current assistant message before stopping
-    stop();
-
-    const lastMessage = messages[messages.length - 1];
-    if (
-      lastMessage &&
-      lastMessage.role === "assistant" &&
-      status === "streaming"
-    ) {
-      try {
-        await saveMessageFromClient({
-          id: lastMessage.id,
-          chatId,
-          role: lastMessage.role,
-          parts: lastMessage.parts,
-        });
-      } catch (error) {
-        console.error("Failed to save message on stop:", error);
-      }
-    }
-  };
+  const handleStop = async () => {
+    stop();
+  };

If you keep a client fallback, persist before stop(), normalize parts, and coordinate with the server to skip its save on abort.

🧹 Nitpick comments (2)
lib/db/actions.ts (1)

45-49: Tighten message role typing

Use a string literal union to prevent invalid roles from being persisted.

   message: {
     id: string;
-    role: string;
+    role: "user" | "assistant" | "system";
     parts: UIMessagePart<any, any>[];
   };
app/components/chat.tsx (1)

54-59: Nit: remove unused mutation after dropping client-side save

saveMessageFromClient becomes unused if the stop-path persistence is removed.

   const deleteLastAssistantMessage = useMutation(
     api.messages.deleteLastAssistantMessage,
   );
-  const saveMessageFromClient = useMutation(api.messages.saveMessageFromClient);
📜 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 e275b69 and c971db5.

📒 Files selected for processing (5)
  • app/api/chat/route.ts (4 hunks)
  • app/components/chat.tsx (1 hunks)
  • convex/messages.ts (1 hunks)
  • convex/schema.ts (1 hunks)
  • lib/db/actions.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • convex/schema.ts
🧰 Additional context used
🧬 Code graph analysis (4)
convex/messages.ts (1)
lib/db/actions.ts (1)
  • saveMessage (40-62)
lib/db/actions.ts (3)
convex/chats.ts (3)
  • getChatById (7-57)
  • saveChat (62-93)
  • updateChat (98-176)
lib/errors.ts (1)
  • ChatSDKError (36-74)
convex/messages.ts (1)
  • saveMessage (8-55)
app/api/chat/route.ts (7)
lib/auth/get-user-id.ts (1)
  • getUserID (12-30)
lib/rate-limit.ts (1)
  • checkRateLimit (6-63)
lib/db/actions.ts (3)
  • handleInitialChatAndUserMessage (64-114)
  • updateChat (116-142)
  • saveMessage (40-62)
lib/ai/tools/index.ts (1)
  • createTools (17-73)
lib/actions/index.ts (2)
  • generateTitleFromUserMessageWithWriter (67-90)
  • getAIHeaders (5-12)
convex/chats.ts (1)
  • updateChat (98-176)
convex/messages.ts (1)
  • saveMessage (8-55)
app/components/chat.tsx (9)
app/contexts/GlobalState.tsx (1)
  • useGlobalState (137-143)
convex/messages.ts (2)
  • deleteLastAssistantMessage (175-217)
  • saveMessageFromClient (123-170)
types/chat.ts (2)
  • ChatMessage (58-58)
  • Todo (38-42)
lib/utils.ts (2)
  • convertToUIMessages (34-42)
  • fetchWithErrorHandlers (12-32)
lib/utils/message-processor.ts (1)
  • normalizeMessages (50-130)
app/hooks/useMessageScroll.ts (1)
  • useMessageScroll (4-22)
app/components/Messages.tsx (1)
  • Messages (22-170)
app/components/ChatInput.tsx (1)
  • ChatInput (30-188)
app/components/ScrollToBottomButton.tsx (1)
  • ScrollToBottomButton (11-46)

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: 10

Caution

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

⚠️ Outside diff range comments (1)
app/components/ScrollToBottomButton.tsx (1)

26-31: Fix undefined Tailwind spacing classes and update positioning

I searched the repo and didn’t find any tailwind.config.* files, which means there are no custom spacing entries for 34 or 42. As written, bottom-42 and bottom-34 won’t generate any CSS, so the button will fall back to its container’s default positioning. Additionally, using absolute may cause the button to scroll out of view if its nearest positioned ancestor isn’t the viewport.

• File: app/components/ScrollToBottomButton.tsx (lines 26–31)
• No tailwind.config.* in repo → custom spacing tokens 34/42 are missing
• Suggest using default spacing scale or arbitrary values, and switching to fixed positioning

Proposed diff:

-  const bottomPosition = hasActiveTodos ? "bottom-42" : "bottom-34";
+  // Use default Tailwind spacing scale (e.g. 40 → 10rem, 32 → 8rem)
+  const bottomPosition = hasActiveTodos ? "bottom-40" : "bottom-32";

-    <div
-      className={`absolute ${bottomPosition} left-1/2 -translate-x-1/2 z-40 transition-all duration-300`}
-    >
+    <div
+      className={`fixed ${bottomPosition} left-1/2 -translate-x-1/2 z-40 transition-all duration-300`}
+    >

If you need the exact pixel values (e.g. 168px/136px), use Tailwind’s arbitrary-value syntax instead:

const bottomPosition = hasActiveTodos
  ? "bottom-[168px]"
  : "bottom-[136px]";

This will ensure your button is always visible at the intended offset.

♻️ Duplicate comments (4)
app/components/chat.tsx (2)

134-141: Don’t clobber in‑progress assistant streaming with DB snapshots

This effect overwrites partial assistant content mid-stream, causing flicker and loss of context. Gate on status !== "streaming".

-  useEffect(() => {
-    if (!messagesData || messagesData === null) return;
-
-    const uiMessages = convertToUIMessages(messagesData);
-    setMessages(uiMessages);
-  }, [messagesData, setMessages]);
+  useEffect(() => {
+    if (!messagesData || messagesData === null) return;
+    // Don't overwrite in-progress assistant streaming with a DB snapshot
+    if (status === "streaming") return;
+    const uiMessages = convertToUIMessages(messagesData);
+    setMessages(uiMessages);
+  }, [messagesData, status, setMessages]);

178-199: Client-side save-on-stop duplicates server persistence and races with stop()

The route already persists at stream end; saving again here creates duplicates. Also, calling stop() before the check can flip status away from "streaming", skipping the save intermittently.

Recommended: remove client-side persistence and rely on server:

-  const handleStop = async () => {
-    // Save the current assistant message before stopping
-    stop();
-
-    const lastMessage = messages[messages.length - 1];
-    if (
-      lastMessage &&
-      lastMessage.role === "assistant" &&
-      status === "streaming"
-    ) {
-      try {
-        await saveMessageFromClient({
-          id: lastMessage.id,
-          chatId,
-          role: lastMessage.role,
-          parts: lastMessage.parts,
-        });
-      } catch (error) {
-        console.error("Failed to save message on stop:", error);
-      }
-    }
-  };
+  const handleStop = async () => {
+    stop();
+  };

If you must keep a client fallback, persist BEFORE stop() and normalize parts:

-  const handleStop = async () => {
-    // Save the current assistant message before stopping
-    stop();
-
-    const lastMessage = messages[messages.length - 1];
+  const handleStop = async () => {
+    const lastMessage = messages[messages.length - 1];
     if (
       lastMessage &&
       lastMessage.role === "assistant" &&
       status === "streaming"
     ) {
       try {
-        await saveMessageFromClient({
-          id: lastMessage.id,
-          chatId,
-          role: lastMessage.role,
-          parts: lastMessage.parts,
-        });
+        const { messages: normalized } = normalizeMessages([lastMessage as any]);
+        const partsToSave = normalized?.[0]?.parts ?? lastMessage.parts;
+        await saveMessageFromClient({
+          id: lastMessage.id,
+          chatId,
+          role: lastMessage.role,
+          parts: partsToSave,
+        });
       } catch (error) {
         console.error("Failed to save message on stop:", error);
       }
     }
-  };
+    stop();
+  };

To confirm server-side persistence exists:

#!/bin/bash
# Search the chat API route for save/persist calls at stream end.
rg -nC3 'save(Message|Chat)|persist|updateChat|saveMessage' app/api/chat/route.ts
convex/chats.ts (2)

45-49: Wrong index for lookup by chat id

This query filters by id but uses the "by_user_id" index. Use a dedicated "by_id" index and use it directly in withIndex.

-      const chat = await ctx.db
-        .query("chats")
-        .withIndex("by_user_id")
-        .filter((q) => q.eq(q.field("id"), args.id))
-        .first();
+      const chat = await ctx.db
+        .query("chats")
+        .withIndex("by_id", (q) => q.eq("id", args.id))
+        .first();

Also add this index in convex/schema.ts (outside this diff):

// chats table
defineTable({
  // ...fields...
})
  .index("by_user_id", ["user_id"])
  .index("by_id", ["id"]);

242-274: updateChatTodos: same access control and index issues as updateChat

  • Uses "by_user_id" while filtering by id.
  • Lacks identity ownership checks when no serviceKey is provided.
   handler: async (ctx, args) => {
     // Verify service role key
     if (
       args.serviceKey &&
       args.serviceKey !== process.env.CONVEX_SERVICE_ROLE_KEY
     ) {
       throw new Error("Unauthorized: Invalid service key");
     }

     try {
       // Find the chat by chatId
       const chat = await ctx.db
         .query("chats")
-        .withIndex("by_user_id")
-        .filter((q) => q.eq(q.field("id"), args.chatId))
+        .withIndex("by_id", (q) => q.eq("id", args.chatId))
         .first();

       if (!chat) {
         throw new Error("Chat not found");
       }
+      if (!args.serviceKey) {
+        const identity = await ctx.auth.getUserIdentity();
+        if (!identity) {
+          throw new Error("Unauthorized");
+        }
+        if (chat.user_id !== identity.subject) {
+          throw new Error("Forbidden");
+        }
+      }

       // Update the chat with new todos
       await ctx.db.patch(chat._id, {
         todos: args.todos,
         update_time: Date.now(),
       });

       return null;
🧹 Nitpick comments (21)
app/components/ComputerSidebar.tsx (2)

128-128: Spacing change: verify safe-area handling for the fixed container on mobile.

Removing vertical padding is fine visually on desktop, but this root wrapper is fixed-positioned on smaller viewports. Without top/bottom padding you risk content being flush with device notches/home indicators on iOS. Consider adding safe-area insets (desktop unaffected).

Apply this diff to preserve safe-area on mobile only:

-    <div className="h-full w-full top-0 left-0 desktop:top-auto desktop:left-auto desktop:right-auto z-50 fixed desktop:relative desktop:h-full desktop:mr-4 flex-shrink-0">
+    <div className="h-full w-full top-0 left-0 desktop:top-auto desktop:left-auto desktop:right-auto z-50 fixed desktop:relative desktop:h-full desktop:mr-4 flex-shrink-0 pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)] desktop:pt-0 desktop:pb-0">

143-146: Unnecessary tabIndex on a native button.

tabIndex={0} on a button is redundant; native buttons are already focusable in tab order. Removing it avoids potential tab order overrides.

-                    className="w-7 h-7 relative rounded-md inline-flex items-center justify-center gap-2.5 cursor-pointer hover:bg-muted/50 transition-colors"
-                    aria-label="Minimize sidebar"
-                    tabIndex={0}
+                    className="w-7 h-7 relative rounded-md inline-flex items-center justify-center gap-2.5 cursor-pointer hover:bg-muted/50 transition-colors"
+                    aria-label="Minimize sidebar"
app/contexts/GlobalState.tsx (1)

115-141: Stabilize context value to reduce app-wide re-renders.

value is recreated each render, and several action functions are redefined. In large trees, this can cause avoidable re-renders of all consumers. Memoize callbacks and the context value.

-  const value: GlobalStateType = {
+  const value: GlobalStateType = React.useMemo(() => ({
     input,
     setInput,
     mode,
     setMode,
     chatTitle,
     setChatTitle,
     sidebarOpen,
     setSidebarOpen,
     sidebarContent,
     setSidebarContent,
     chatSidebarOpen,
     setChatSidebarOpen,
     todos,
     setTodos,
     mergeTodos,
 
     isTodoPanelExpanded,
     setIsTodoPanelExpanded,
 
-    clearInput,
-    resetChat,
-    openSidebar,
-    updateSidebarContent,
-    closeSidebar,
-    toggleChatSidebar,
-  };
+    clearInput,
+    resetChat,
+    openSidebar: React.useCallback(openSidebar, []),
+    updateSidebarContent: React.useCallback(updateSidebarContent, []),
+    closeSidebar: React.useCallback(closeSidebar, []),
+    toggleChatSidebar: React.useCallback(toggleChatSidebar, []),
+  }), [
+    input, mode, chatTitle, sidebarOpen, sidebarContent, chatSidebarOpen, todos,
+    isTodoPanelExpanded, mergeTodos
+  ]);

Note: If you wrap the action creators with useCallback where they’re defined, you won’t need the inner useCallback wrappers above.

components/ui/sheet.tsx (1)

61-71: Sizing and border classes: minor polish.

When side="top"/"bottom", consider adding explicit width w-full for consistency. Today it’s implied via inset-x-0, but w-full makes intent clear and helps with future refactors.

-          side === "top" &&
-            "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
-          side === "bottom" &&
-            "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
+          side === "top" &&
+            "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto w-full border-b",
+          side === "bottom" &&
+            "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto w-full border-t",
hooks/use-mobile.ts (1)

10-18: Prefer mql.matches and add Safari fallback for MediaQueryList events.

Using window.innerWidth works but mql.matches is the source of truth and avoids edge cases. Also, older Safari versions require addListener/removeListener.

   React.useEffect(() => {
-    const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
-    const onChange = () => {
-      setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
-    };
-    mql.addEventListener("change", onChange);
-    setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
-    return () => mql.removeEventListener("change", onChange);
+    const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
+    const onChange = () => setIsMobile(mql.matches);
+    // Init
+    onChange();
+    // Subscribe (modern + Safari fallback)
+    if ("addEventListener" in mql) {
+      mql.addEventListener("change", onChange);
+      return () => mql.removeEventListener("change", onChange);
+    } else {
+      // @ts-expect-error: Safari <14
+      mql.addListener(onChange);
+      // @ts-expect-error: Safari <14
+      return () => mql.removeListener(onChange);
+    }
   }, []);
components/ui/skeleton.tsx (1)

1-11: Forward ref and accessibility default for decorative skeletons

Forwarding a ref improves composability (measurement/animations), and defaulting to aria-hidden keeps skeletons out of the accessibility tree unless explicitly overridden.

Apply:

-import { cn } from "@/lib/utils";
+import * as React from "react";
+import { cn } from "@/lib/utils";

-function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
-  return (
-    <div
-      data-slot="skeleton"
-      className={cn("bg-accent animate-pulse rounded-md", className)}
-      {...props}
-    />
-  );
-}
+const Skeleton = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
+  ({ className, ...props }, ref) => {
+    return (
+      <div
+        ref={ref}
+        data-slot="skeleton"
+        aria-hidden="true"
+        className={cn("bg-accent animate-pulse rounded-md", className)}
+        {...props}
+      />
+    );
+  },
+);
components/ui/input.tsx (1)

5-19: Forward ref and sensible default type for Input

Forwarding the ref is important for form libs and focus management; defaulting type to "text" avoids accidental "submit" behavior when this is swapped with a button inside forms.

-function Input({ className, type, ...props }: React.ComponentProps<"input">) {
-  return (
-    <input
-      type={type}
+const Input = React.forwardRef<
+  HTMLInputElement,
+  React.ComponentProps<"input">
+>(({ className, type = "text", ...props }, ref) => {
+  return (
+    <input
+      ref={ref}
+      type={type}
       data-slot="input"
       className={cn(
         "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
         "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
         "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
         className,
       )}
       {...props}
     />
-  );
-}
+  );
+});
components/ui/separator.tsx (1)

8-26: Forward ref to Radix primitive for consistency with shadcn/Radix patterns

This keeps ref semantics available to consumers and aligns with typical shadcn UI wrappers.

-function Separator({
-  className,
-  orientation = "horizontal",
-  decorative = true,
-  ...props
-}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
-  return (
-    <SeparatorPrimitive.Root
-      data-slot="separator"
-      decorative={decorative}
-      orientation={orientation}
-      className={cn(
-        "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
-        className,
-      )}
-      {...props}
-    />
-  );
-}
+const Separator = React.forwardRef<
+  React.ElementRef<typeof SeparatorPrimitive.Root>,
+  React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
+>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => {
+  return (
+    <SeparatorPrimitive.Root
+      ref={ref}
+      data-slot="separator"
+      decorative={decorative}
+      orientation={orientation}
+      className={cn(
+        "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
+        className,
+      )}
+      {...props}
+    />
+  );
+});
app/components/ChatItem.tsx (2)

24-32: Only close the sidebar on mobile to preserve desktop UX

Currently, the sidebar closes on all viewports. Many apps keep the chat list open on desktop. Gate the close behavior by isMobile.

-  const handleClick = () => {
-    closeSidebar();
-
-    if (isMobile) {
-      setChatSidebarOpen(false);
-    }
-
-    router.push(`/c/${id}`);
-  };
+  const handleClick = () => {
+    if (isMobile) {
+      // On mobile, collapse the sidebar to reveal content
+      setChatSidebarOpen(false);
+      closeSidebar();
+    }
+    router.push(`/c/${id}`);
+  };

35-48: Improve semantics and a11y for navigation items

  • Indicate the active item with aria-current for assistive tech.
  • If your Button supports asChild (shadcn pattern), consider rendering an anchor via next/link for link semantics. If not, at least ensure type="button" to avoid accidental form submits.

Minimal a11y improvement:

-    <Button
+    <Button
       variant="ghost"
       onClick={handleClick}
+      type="button"
+      aria-current={isActive ? "page" : undefined}
       className={`flex items-center gap-2 p-3 h-auto w-full text-left justify-start ${
         isActive 
           ? "bg-sidebar-accent text-sidebar-accent-foreground" 
           : "hover:bg-sidebar-accent/50"
       }`}
       title={title}
     >

Optional link semantics (if Button supports asChild):

import Link from "next/link";

<Button asChild variant="ghost" /* ...rest */>
  <Link href={`/c/${id}`} aria-current={isActive ? "page" : undefined"} title={title}>
    <span className="text-sm font-medium truncate flex-1 min-w-0">{title}</span>
  </Link>
</Button>

If Button does not support asChild, keep the minimal change above and skip the Link refactor.

app/components/UserDropdownMenu.tsx (2)

46-51: Prefer email before generic “User” in display name fallback

If names are missing but email exists, show the email for a better UX.

-  const getDisplayName = () => {
-    if (user.firstName && user.lastName) {
-      return `${user.firstName} ${user.lastName}`;
-    }
-    return user.firstName || user.lastName || 'User';
-  };
+  const getDisplayName = () => {
+    if (user.firstName && user.lastName) {
+      return `${user.firstName} ${user.lastName}`;
+    }
+    return user.firstName || user.lastName || user.email || "User";
+  };

55-75: Make the trigger keyboard accessible

The trigger child is a div. Add role and tabIndex (or switch to a button) for keyboard/AT accessibility.

-        <div className="flex items-center gap-3 p-3 cursor-pointer hover:bg-sidebar-accent/50 rounded-md transition-colors">
+        <div
+          className="flex items-center gap-3 p-3 cursor-pointer hover:bg-sidebar-accent/50 rounded-md transition-colors"
+          role="button"
+          tabIndex={0}
+        >
app/components/ChatHeader.tsx (2)

3-9: Prefer Next.js router over window.location for navigation

Using next/navigation improves SPA UX and preserves state where appropriate.

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

Also applies to: 33-39


53-60: Make the sidebar toggle an actual button for a11y

The clickable icon lives inside passive containers. Wrap PanelLeft in a Button (variant="ghost") or add role/button semantics for keyboard users and AT.

Example minimal change:

-                <div className="flex h-7 w-7 items-center justify-center cursor-pointer rounded-md hover:bg-muted/50 mr-2">
-                  <PanelLeft
-                    className="size-5 text-muted-foreground cursor-pointer"
-                    onClick={toggleChatSidebar}
-                  />
-                </div>
+                <button
+                  type="button"
+                  aria-label="Open chat sidebar"
+                  onClick={toggleChatSidebar}
+                  className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-muted/50 mr-2"
+                >
+                  <PanelLeft className="size-5 text-muted-foreground" />
+                </button>

Also applies to: 107-114, 166-173

app/components/ChatSidebar.tsx (3)

24-29: Remove unused isMobile prop from ChatSidebarHeader

The prop is passed but never used, adding noise and risk of confusion.

-const ChatSidebarHeader: React.FC<{
-  handleNewChat: () => void;
-  handleCloseSidebar: () => void;
-  isMobile: boolean;
-}> = ({ handleNewChat, handleCloseSidebar, isMobile }) => (
+const ChatSidebarHeader: React.FC<{
+  handleNewChat: () => void;
+  handleCloseSidebar: () => void;
+}> = ({ handleNewChat, handleCloseSidebar }) => (

Drop prop at call sites:

-          <ChatSidebarHeader
-            handleNewChat={handleNewChat}
-            handleCloseSidebar={handleCloseSidebar}
-            isMobile={isMobile}
-          />
+          <ChatSidebarHeader
+            handleNewChat={handleNewChat}
+            handleCloseSidebar={handleCloseSidebar}
+          />

…and similarly in the desktop section.

Also applies to: 155-158, 181-185


95-106: Key vs. id inconsistency

You’re using key={chat._id} but passing id={chat.id}. If both exist, that’s fine; otherwise, prefer a single consistent identifier (likely chat.id) for both key and routing.

-          key={chat._id}
+          key={chat.id ?? chat._id}

55-59: Add lightweight types for chats and user props

There’s a lot of any here. Introducing a minimal ChatListItem and AuthUser interface will improve maintainability and catch regressions.

I can add types for chats (id, title) and the auth user shape, plus derive them from Convex types if available.

Also applies to: 98-104, 116-123

app/components/chat.tsx (1)

67-71: Stable message identity across UI reloads

convertToUIMessages uses message._id for UI id, while client-generated messages use uuid id. Consider using message.id ?? message._id to keep IDs stable across DB round-trips and reduce reconciliation edge cases.

I can open a follow-up PR to adjust convertToUIMessages in lib/utils.ts:

-    id: message._id,
+    id: (message as any).id ?? message._id,
convex/chats.ts (1)

98-118: Consider consolidating todos updates into updateChat to avoid duplicate logic

updateChatTodos overlaps significantly with updateChat. Unify into a single mutation to reduce surface area and potential drift.

Happy to propose a consolidated mutation signature if desired.

components/ui/sidebar.tsx (2)

72-90: Hydrate initial open state from cookie

State is persisted to a cookie but never read. On first mount, initialize from the cookie when the component is uncontrolled.

   const [_open, _setOpen] = React.useState(defaultOpen);
   const open = openProp ?? _open;
   const setOpen = React.useCallback(
     (value: boolean | ((value: boolean) => boolean)) => {
       const openState = typeof value === "function" ? value(open) : value;
       if (setOpenProp) {
         setOpenProp(openState);
       } else {
         _setOpen(openState);
       }

       // This sets the cookie to keep the sidebar state.
-      document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
+      const secure = typeof window !== "undefined" && window.location.protocol === "https:" ? "; Secure" : "";
+      document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; Path=/; Max-Age=${SIDEBAR_COOKIE_MAX_AGE}; SameSite=Lax${secure}`;
     },
     [setOpenProp, open],
   );
+
+  // Initialize from cookie when uncontrolled.
+  React.useEffect(() => {
+    if (openProp !== undefined) return;
+    const match = document.cookie.match(new RegExp(`(?:^|; )${SIDEBAR_COOKIE_NAME}=([^;]+)`));
+    if (match) {
+      _setOpen(match[1] === "true");
+    }
+  }, [openProp]);

294-299: Nonstandard "in-data-*" variant; replace with supported arbitrary variant selector

in-data-[side=left] / in-data-[side=right] isn’t a default Tailwind variant. Prefer the bracket arbitrary variant you’re already using elsewhere.

- "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
+ "[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",

If you do have a custom "in-*" variant configured, ignore this suggestion.

📜 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 c971db5 and dbf53e4.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (18)
  • app/components/ChatHeader.tsx (1 hunks)
  • app/components/ChatItem.tsx (1 hunks)
  • app/components/ChatSidebar.tsx (1 hunks)
  • app/components/ComputerSidebar.tsx (1 hunks)
  • app/components/ScrollToBottomButton.tsx (2 hunks)
  • app/components/UserDropdownMenu.tsx (1 hunks)
  • app/components/chat.tsx (1 hunks)
  • app/contexts/GlobalState.tsx (6 hunks)
  • components/ui/avatar.tsx (1 hunks)
  • components/ui/input.tsx (1 hunks)
  • components/ui/separator.tsx (1 hunks)
  • components/ui/sheet.tsx (1 hunks)
  • components/ui/sidebar.tsx (1 hunks)
  • components/ui/skeleton.tsx (1 hunks)
  • convex/chats.ts (1 hunks)
  • convex/messages.ts (1 hunks)
  • hooks/use-mobile.ts (1 hunks)
  • package.json (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • convex/messages.ts
🧰 Additional context used
🧬 Code graph analysis (14)
app/components/ChatItem.tsx (3)
app/contexts/GlobalState.tsx (1)
  • useGlobalState (150-156)
hooks/use-mobile.ts (1)
  • useIsMobile (5-21)
components/ui/button.tsx (1)
  • Button (59-59)
components/ui/skeleton.tsx (1)
lib/utils.ts (1)
  • cn (8-10)
components/ui/sheet.tsx (1)
lib/utils.ts (1)
  • cn (8-10)
components/ui/input.tsx (1)
lib/utils.ts (1)
  • cn (8-10)
app/components/ScrollToBottomButton.tsx (1)
app/contexts/GlobalState.tsx (1)
  • useGlobalState (150-156)
components/ui/separator.tsx (1)
lib/utils.ts (1)
  • cn (8-10)
app/components/ChatSidebar.tsx (2)
hooks/use-mobile.ts (1)
  • useIsMobile (5-21)
app/contexts/GlobalState.tsx (1)
  • useGlobalState (150-156)
app/components/ChatHeader.tsx (2)
app/contexts/GlobalState.tsx (1)
  • useGlobalState (150-156)
components/icons/hackerai-svg.tsx (1)
  • HackerAISVG (8-67)
app/contexts/GlobalState.tsx (1)
types/chat.ts (1)
  • SidebarContent (24-24)
app/components/chat.tsx (11)
hooks/use-mobile.ts (1)
  • useIsMobile (5-21)
app/contexts/GlobalState.tsx (1)
  • useGlobalState (150-156)
convex/messages.ts (2)
  • deleteLastAssistantMessage (175-217)
  • saveMessageFromClient (123-170)
lib/utils.ts (2)
  • convertToUIMessages (34-42)
  • fetchWithErrorHandlers (12-32)
lib/utils/message-processor.ts (1)
  • normalizeMessages (50-130)
lib/utils/todo-utils.ts (1)
  • mergeTodos (11-37)
lib/errors.ts (1)
  • ChatSDKError (36-74)
app/hooks/useMessageScroll.ts (1)
  • useMessageScroll (4-22)
app/components/Messages.tsx (1)
  • Messages (22-170)
app/components/ChatInput.tsx (1)
  • ChatInput (30-188)
app/components/ScrollToBottomButton.tsx (1)
  • ScrollToBottomButton (11-42)
components/ui/avatar.tsx (1)
lib/utils.ts (1)
  • cn (8-10)
convex/chats.ts (1)
lib/db/actions.ts (3)
  • getChatById (8-18)
  • saveChat (20-39)
  • updateChat (116-142)
app/components/UserDropdownMenu.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)
components/ui/sidebar.tsx (2)
hooks/use-mobile.ts (1)
  • useIsMobile (5-21)
lib/utils.ts (1)
  • cn (8-10)
🔇 Additional comments (18)
package.json (2)

14-16: Version alignment across AI SDK packages.

You updated @ai-sdk/openai, @ai-sdk/react, and ai. Ensure their peer dependency ranges are compatible to avoid subtle runtime mismatch.

If you see peer dep warnings locally, align versions per their release notes; otherwise, you’re good.

Also applies to: 30-31, 33-36, 37-37


17-17: Verify @e2b/code-interpreter v2.0.0 API Usage

I scanned the codebase for legacy symbols (CodeInterpreter, createWorkspace, E2B) and found none. Instead, the following named imports from @e2b/code-interpreter remain:

  • Sandbox in
    lib/ai/tools/index.ts:1
    lib/ai/tools/utils/sandbox.ts:1
    lib/ai/tools/utils/sandbox-utils.ts:1
    lib/ai/tools/utils/sandbox-manager.ts:1
    types/agent.ts:1
  • CommandExitError in lib/ai/tools/run-terminal-cmd.ts:3
  • CommandResult in app/components/tools/TerminalToolHandler.tsx:3

Please confirm that in v2.0.0:

  • Sandbox, CommandExitError, and CommandResult are still exported as named symbols.
  • Their constructors, method signatures, and behaviors haven’t changed in ways that could break existing usage.
app/contexts/GlobalState.tsx (2)

111-114: toggleChatSidebar implementation looks good.

Simple state flip with functional update avoids stale closures. No concerns.


85-91: Consider whether resetting chat should also close sidebars.

resetChat() clears input/title/todos/UI panel state but leaves both sidebars as-is. If “Reset Chat” is intended to return to a clean slate, you may also want to close the right Computer sidebar and the left Chat sidebar.

Would you like a follow-up PR to include closeSidebar() and setChatSidebarOpen(false) inside resetChat()?

components/ui/sheet.tsx (3)

75-78: Verify “rounded-xs” exists in your Tailwind theme.

rounded-xs isn’t a default radius in Tailwind. If not extended, this will be a no-op. Consider rounded-sm or extend the theme.

-        <SheetPrimitive.Close className="... rounded-xs ...">
+        <SheetPrimitive.Close className="... rounded-sm ...">

If you intentionally extended rounded-xs, ignore this.


31-45: Overlay: good defaults and animations.

Backdrop has correct z-index and state-driven transitions. Looks consistent with the rest of the UI primitives.


47-82: Export intent: do you want to re-use Overlay/Portal externally?

SheetOverlay and SheetPortal are defined but not exported. If you plan to compose custom sheet variants elsewhere, exporting them now avoids one-off redefinitions.

 export {
   Sheet,
   SheetTrigger,
   SheetClose,
   SheetContent,
   SheetHeader,
   SheetFooter,
   SheetTitle,
   SheetDescription,
+  SheetOverlay,
+  SheetPortal,
 };
hooks/use-mobile.ts (1)

20-21: Hook behavior is sensible.

Returning !!isMobile avoids undefined consumers pre-hydration. Keep as-is.

app/components/UserDropdownMenu.tsx (1)

96-99: DropdownMenuItem’s variant prop is supported—no changes needed

I’ve verified in components/ui/dropdown-menu.tsx that DropdownMenuItem’s props include

variant?: "default" | "destructive";

and that it applies data-[variant=destructive] styles accordingly. The existing usage with variant="destructive" will type-check and render correctly.

components/ui/avatar.tsx (1)

8-21: LGTM — clean wrappers and sensible defaults

Nice, minimal Radix wrappers with clear data-slot hooks and class merging.

Also applies to: 24-35, 37-51

convex/chats.ts (3)

141-166: Nice: minimal delta updates and consistent timestamping

The prepare-and-patch approach updates only provided fields and stamps update_time once. Clear and correct.


181-219: Good use of identity gate for user-scoped listing

getUserChats correctly requires identity and returns a scoped projection.


1-34: Return typing is precise and future-proofed

Explicitly describing the returned chat shape (including optional finish_reason and todos) improves client DX and guards against accidental field omissions.

components/ui/sidebar.tsx (5)

154-181: Solid desktop/mobile branching and collapsible variants

The split between mobile Sheet and desktop fixed container is clean, and the collapsible="none|offcanvas|icon" behavior reads well.


256-279: Accessible trigger with screen-reader label and small target size

Good use of sr-only label and a compact icon button. The onClick delegates and toggleSidebar abstraction are tidy.


602-638: Nice skeleton UX details

Randomized skeleton width and optional icon make loading states feel less repetitive.


498-546: Tooltip behavior is considerate on collapsed-only and desktop-only

Showing tooltips only when collapsed and hiding them on mobile avoids noisy overlays.


221-239: Verify Tailwind configuration for custom arbitrary-value syntax

I ran searches but didn’t locate a tailwind.config.* file or any plugin enabling the w-(--…) shorthand. Please confirm whether your Tailwind setup supports this syntax. If not, update the class names to use standard arbitrary values with var(), for example:

- className={cn(
-   "relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
+ className={cn(
+   "relative w-[var(--sidebar-width)] bg-transparent transition-[width] duration-200 ease-linear",

   "group-data-[collapsible=offcanvas]:w-0",
   "group-data-[side=right]:rotate-180",
   variant === "floating" || variant === "inset"
     ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
     : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",

And similarly for "w-(--sidebar-width-icon)" elsewhere.
• If you do have a plugin or custom parser that supports w-(--…), could you point to its configuration?
• Otherwise, please switch these to w-[var(--...)] to ensure Tailwind compiles as expected.

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: 1

Caution

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

⚠️ Outside diff range comments (1)
app/components/ChatInput.tsx (1)

82-87: Fix Enter-to-send type mismatch and unify submit paths via requestSubmit()

onKeyDown passes a KeyboardEvent into a handler typed for FormEvent, which is unsafe and can break TS in strict mode. Use a form ref and HTMLFormElement.requestSubmit() to trigger the same onSubmit flow for both Enter and the send button.

Apply this diff:

@@
-import { Button } from "@/components/ui/button";
+import { Button } from "@/components/ui/button";
+import { useRef } from "react";
@@
 }: ChatInputProps) => {
   const { input, setInput, mode, setMode } = useGlobalState();
   const isGenerating = status === "submitted" || status === "streaming";
+  const formRef = useRef<HTMLFormElement>(null);
@@
-              onKeyDown={(e) => {
+              onKeyDown={(e) => {
                 if (e.key === "Enter" && !e.shiftKey) {
                   e.preventDefault();
-                  handleSubmit(e);
+                  // Trigger the form's submit so onSubmit receives a proper FormEvent
+                  if (status === "ready" && input.trim()) {
+                    formRef.current?.requestSubmit();
+                  }
                 }
               }}
@@
-                <form onSubmit={handleSubmit}>
+                <form ref={formRef} onSubmit={handleSubmit}>

Also applies to: 163-181, 1-21, 36-38

♻️ Duplicate comments (1)
app/components/chat.tsx (1)

171-191: Don’t clobber streaming assistant messages with DB snapshots (repeat of earlier feedback)

While streaming, real-time DB updates can overwrite the in-progress assistant content, causing flicker. Gate the sync when status === "streaming" and include status in deps.

Apply this diff:

   useEffect(() => {
     if (!messagesData || messagesData === null) return;
+    // Avoid replacing in-progress assistant streaming with a DB snapshot
+    if (status === "streaming") return;

     const uiMessages = convertToUIMessages(messagesData);
@@
-  }, [messagesData, setMessages, messages]);
+  }, [messagesData, status, setMessages, messages]);
🧹 Nitpick comments (5)
app/contexts/GlobalState.tsx (2)

90-95: Consider whether resetChat should also reset currentChatId (optional)

Some flows may expect a “fresh” chat after reset. If that’s desired, either clear currentChatId here or add an option to resetChat to preserve/clear the id explicitly.

Example:

-const resetChat = () => {
+const resetChat = (opts: { preserveChatId?: boolean } = {}) => {
   setInput("");
   setChatTitle(null);
   setTodos([]);
   setIsTodoPanelExpanded(false);
+  if (!opts.preserveChatId) setCurrentChatId(null);
 };

120-148: Memoize provider value to reduce unnecessary re-renders (optional)

The context value is recreated every render, which can re-render all consumers. Wrap it in useMemo keyed by its fields.

-const value: GlobalStateType = {
+const value: GlobalStateType = useMemo(() => ({
   input,
   setInput,
   mode,
   setMode,
   chatTitle,
   setChatTitle,
   currentChatId,
   setCurrentChatId,
   sidebarOpen,
   setSidebarOpen,
   sidebarContent,
   setSidebarContent,
   chatSidebarOpen,
   setChatSidebarOpen,
   todos,
   setTodos,
   mergeTodos,
   isTodoPanelExpanded,
   setIsTodoPanelExpanded,
   clearInput,
   resetChat,
   openSidebar,
   updateSidebarContent,
   closeSidebar,
   toggleChatSidebar,
-};
+}), [
+  input, mode, chatTitle, currentChatId, sidebarOpen, sidebarContent,
+  chatSidebarOpen, todos, isTodoPanelExpanded,
+  // stable fns can be kept out if wrapped in useCallback
+]);
app/components/ChatItem.tsx (1)

9-16: Use isActive as an override (or remove it) and improve a11y

The isActive prop is accepted but not used; active state is derived only from currentChatId. Either drop the prop or allow it to override. Also, expose aria-current on the active item for screen readers.

Apply this diff:

-interface ChatItemProps {
+interface ChatItemProps {
   id: string;
   title: string;
-  isActive?: boolean;
+  isActive?: boolean; // optional override
 }
@@
-const ChatItem: React.FC<ChatItemProps> = ({ id, title, isActive = false }) => {
+const ChatItem: React.FC<ChatItemProps> = ({ id, title, isActive }) => {
@@
-  const isCurrentlyActive = currentChatId === id;
+  const isCurrentlyActive = isActive ?? (currentChatId === id);
@@
     <Button
       variant="ghost"
       onClick={handleClick}
+      aria-current={isCurrentlyActive ? "page" : undefined}
       className={`flex items-center gap-2 p-3 h-auto w-full text-left justify-start ${
         isCurrentlyActive
           ? "bg-sidebar-accent text-sidebar-accent-foreground"
           : "hover:bg-sidebar-accent/50"
       }`}
       title={title}
     >

Also applies to: 26-29, 41-56

app/hooks/useChatHandlers.ts (1)

91-102: Add error handling around deleteLastAssistantMessage to avoid blocking regenerate

If the delete fails, regenerate should still proceed; surface a non-blocking error.

-  const handleRegenerate = async () => {
-    // Remove the last assistant message from the UI and database
-    await deleteLastAssistantMessage({ chatId });
-
-    regenerate({
+  const handleRegenerate = async () => {
+    try {
+      // Remove the last assistant message from the UI and database
+      await deleteLastAssistantMessage({ chatId });
+    } catch (err) {
+      console.error("Failed to delete last assistant message:", err);
+      // non-blocking: proceed anyway
+    }
+    regenerate({
       body: {
         mode,
         todos,
         regenerate: true,
       },
     });
   };
app/components/chat.tsx (1)

226-233: Bind SidebarProvider open state to chatSidebarOpen (optional)

SidebarProvider is forced open with a no-op onOpenChange, while the container already animates based on chatSidebarOpen. Keep them consistent.

-              <SidebarProvider
-                open={true}
-                onOpenChange={() => {}}
-                defaultOpen={true}
-              >
+              <SidebarProvider
+                open={chatSidebarOpen}
+                onOpenChange={setChatSidebarOpen}
+                defaultOpen={chatSidebarOpen}
+              >
📜 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 dbf53e4 and fda5daf.

📒 Files selected for processing (12)
  • app/c/[id]/page.tsx (1 hunks)
  • app/components/ChatInput.tsx (1 hunks)
  • app/components/ChatItem.tsx (1 hunks)
  • app/components/ChatSidebar.tsx (1 hunks)
  • app/components/UserDropdownMenu.tsx (1 hunks)
  • app/components/chat.tsx (1 hunks)
  • app/contexts/GlobalState.tsx (5 hunks)
  • app/hooks/useChatHandlers.ts (1 hunks)
  • convex/chats.ts (1 hunks)
  • convex/messages.ts (1 hunks)
  • convex/schema.ts (1 hunks)
  • lib/db/actions.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (7)
  • app/components/ChatSidebar.tsx
  • app/c/[id]/page.tsx
  • app/components/UserDropdownMenu.tsx
  • convex/schema.ts
  • lib/db/actions.ts
  • convex/messages.ts
  • convex/chats.ts
🧰 Additional context used
🧬 Code graph analysis (4)
app/hooks/useChatHandlers.ts (3)
types/chat.ts (1)
  • ChatMessage (58-58)
app/contexts/GlobalState.tsx (1)
  • useGlobalState (157-163)
convex/messages.ts (2)
  • deleteLastAssistantMessage (165-208)
  • saveMessageFromClient (117-160)
app/components/chat.tsx (12)
hooks/use-mobile.ts (1)
  • useIsMobile (5-21)
app/contexts/GlobalState.tsx (1)
  • useGlobalState (157-163)
lib/ai/tools/utils/todo-manager.ts (1)
  • setTodos (32-72)
lib/utils.ts (2)
  • convertToUIMessages (34-42)
  • fetchWithErrorHandlers (12-32)
lib/utils/message-processor.ts (1)
  • normalizeMessages (50-130)
lib/utils/todo-utils.ts (1)
  • mergeTodos (11-37)
lib/errors.ts (1)
  • ChatSDKError (36-74)
app/hooks/useMessageScroll.ts (1)
  • useMessageScroll (4-22)
app/hooks/useChatHandlers.ts (1)
  • useChatHandlers (19-109)
app/components/Messages.tsx (1)
  • Messages (22-170)
app/components/ChatInput.tsx (1)
  • ChatInput (30-188)
app/components/ScrollToBottomButton.tsx (1)
  • ScrollToBottomButton (11-42)
app/contexts/GlobalState.tsx (2)
types/chat.ts (1)
  • SidebarContent (24-24)
components/ui/sidebar.tsx (1)
  • SidebarContent (703-703)
app/components/ChatItem.tsx (2)
app/contexts/GlobalState.tsx (1)
  • useGlobalState (157-163)
hooks/use-mobile.ts (1)
  • useIsMobile (5-21)
🔇 Additional comments (3)
app/components/ChatInput.tsx (1)

64-64: Spacing tweak looks good

Dropping the extra margin (mb-4) here should reduce double-spacing with the reworked layout. No functional concerns.

app/contexts/GlobalState.tsx (1)

27-30: State additions for chat sidebar and currentChatId look solid

The new fields (currentChatId, chatSidebarOpen, toggleChatSidebar) are coherently initialized and exposed. Naming and responsibilities are clear.

Also applies to: 37-40, 56-56, 73-73, 78-78, 116-119, 127-135

app/hooks/useChatHandlers.ts (1)

72-89: Verify server persistence for aborted messages

I reviewed the Next.js API route at app/api/chat/route.ts and confirmed that assistant messages are persisted on the server in the onFinish handler of the UI message stream:

writer.merge(
  result.toUIMessageStream({
    generateMessageId: uuidv4,
    onFinish: async ({ messages }) => {
      for (const message of messages) {
        await saveMessage({ chatId, message });
      }
    },
  }),
);

However, the onAbort handler only logs the abort and does not call saveMessage or updateChat. There is no saveMessageFromClient on the server side. Therefore, before removing or adjusting the client fallback in useChatHandlers.ts, please:

  • Confirm whether toUIMessageStream’s onFinish is invoked when the stream is aborted (i.e. does the server persist partial/aborted streams).
  • If aborted streams are persisted server-side, you can safely remove the client-side save (Option A) to avoid duplication and race conditions.
  • If not, keep the client fallback but apply Option B: persist before calling stop(), and normalize the message parts via normalizeMessages.

Please verify the stream-abort persistence behavior to decide between Options A and B.

@rossmanko rossmanko changed the title Feat/chatbot message persistence Feat/message persistence using convex Aug 26, 2025
@rossmanko rossmanko merged commit f9ffdb8 into main Aug 26, 2025
2 checks passed
@coderabbitai coderabbitai bot mentioned this pull request Sep 10, 2025
This was referenced Nov 6, 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