Merge branch 'main' into fix/llm-client-event-loop-closure

This commit is contained in:
mohammed ahmed 2026-04-03 15:31:36 +02:00 committed by GitHub
commit b2debb96b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
81 changed files with 3655 additions and 4402 deletions

View file

@ -13,13 +13,13 @@ from aiservice.common.llm_output_utils import truncate_pathological_output
# Matches both ```python and ```python:filepath blocks, captures content only
MARKDOWN_CODE_BLOCK_PATTERN = re.compile(r"```python(?::[^\n]*)?\n(.*?)```", re.DOTALL)
# Matches first ```python block (no filepath), captures content.
# Matches first ```python block (with optional :filepath), captures content.
# Uses greedy (.*) to handle LLM outputs with nested code fences (e.g. ```python:filepath
# blocks inside the main block). Requires closing ``` to be alone on its line.
FIRST_CODE_BLOCK_PATTERN = re.compile(r"^```python\s*\n(.*)\n```[ \t]*$", re.MULTILINE | re.DOTALL)
FIRST_CODE_BLOCK_PATTERN = re.compile(r"^```python(?::[^\n]*)?\s*\n(.*)\n```[ \t]*$", re.MULTILINE | re.DOTALL)
# Fallback for incomplete code blocks (missing closing ```)
FIRST_CODE_BLOCK_FALLBACK_PATTERN = re.compile(r"^```python\s*\n(.*)", re.MULTILINE | re.DOTALL)
FIRST_CODE_BLOCK_FALLBACK_PATTERN = re.compile(r"^```python(?::[^\n]*)?\s*\n(.*)", re.MULTILINE | re.DOTALL)
def extract_all_code_from_markdown(markdown: str) -> str:

View file

@ -9,22 +9,18 @@ import time
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
import stamina
import sentry_sdk
from anthropic import (
APIConnectionError as AnthropicConnectionError,
APITimeoutError as AnthropicTimeoutError,
AsyncAnthropicBedrock,
InternalServerError as AnthropicServerError,
RateLimitError as AnthropicRateLimitError,
)
from openai import (
APIConnectionError as OpenAIConnectionError,
APITimeoutError as OpenAITimeoutError,
AsyncAzureOpenAI,
InternalServerError as OpenAIServerError,
RateLimitError as OpenAIRateLimitError,
)
import stamina
from anthropic import APIConnectionError as AnthropicConnectionError
from anthropic import APITimeoutError as AnthropicTimeoutError
from anthropic import AsyncAnthropicBedrock
from anthropic import InternalServerError as AnthropicServerError
from anthropic import RateLimitError as AnthropicRateLimitError
from openai import APIConnectionError as OpenAIConnectionError
from openai import APITimeoutError as OpenAITimeoutError
from openai import AsyncAzureOpenAI
from openai import InternalServerError as OpenAIServerError
from openai import RateLimitError as OpenAIRateLimitError
from aiservice.llm_models import has_anthropic, has_openai
@ -36,6 +32,9 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
_ANTHROPIC_MAX_INPUT_TOKENS = 195_000
_CHARS_PER_TOKEN_ESTIMATE = 4
_TRANSIENT_LLM_ERRORS = (
AnthropicConnectionError,
AnthropicTimeoutError,
@ -173,6 +172,11 @@ class LLMClient:
async def call_anthropic(
self, llm: LLM, messages: list[ChatCompletionMessageParam], max_tokens: int
) -> LLMResponse:
estimated_tokens = sum(len(str(m["content"])) for m in messages) // _CHARS_PER_TOKEN_ESTIMATE
if estimated_tokens > _ANTHROPIC_MAX_INPUT_TOKENS:
msg = f"Prompt too large (~{estimated_tokens} tokens estimated, limit {_ANTHROPIC_MAX_INPUT_TOKENS})"
raise ValueError(msg)
system_prompt = next((m["content"] for m in messages if m["role"] == "system"), None)
non_system = [{"role": m["role"], "content": m["content"]} for m in messages if m["role"] != "system"]

View file

@ -141,6 +141,12 @@ class TrackUsageMiddleware:
return JsonResponse({"error": "Failed to initialize user subscription"}, status=500)
if subscription.subscription_status != "active":
logging.warning(
"403 subscription inactive: user_id=%s, status=%s, endpoint=%s",
user_id,
subscription.subscription_status,
endpoint,
)
return JsonResponse(
{"error": "Subscription is not active", "status": subscription.subscription_status}, status=403
)
@ -150,6 +156,14 @@ class TrackUsageMiddleware:
current_used = subscription.optimizations_used or 0
if current_used + cost > subscription.optimizations_limit:
logging.warning(
"403 usage limit exceeded: user_id=%s, used=%s, limit=%s, tier=%s, endpoint=%s",
user_id,
current_used,
subscription.optimizations_limit,
subscription.plan_type,
endpoint,
)
return JsonResponse(
{
"error": "Usage limit exceeded",

View file

@ -5,6 +5,7 @@ from django.db.models.functions import Now
from ninja.errors import HttpError
from ninja.security import HttpBearer
from aiservice.background import fire_and_forget
from authapp.auth_utils import hash_api_key
from authapp.models import CFAPIKeys, Organizations, Subscriptions
@ -58,7 +59,7 @@ class AuthBearer(HttpBearer):
api_key_instance = await CFAPIKeys.objects.filter(key=hashed_token).afirst()
if api_key_instance is None:
raise HttpError(403, "Invalid API key")
await CFAPIKeys.objects.filter(id=api_key_instance.id).aupdate(last_used=Now())
fire_and_forget(CFAPIKeys.objects.filter(id=api_key_instance.id).aupdate(last_used=Now()))
request.user = api_key_instance.user_id
request.tier = api_key_instance.tier
request.api_key_id = api_key_instance.id

View file

@ -1,11 +1,13 @@
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from typing import TYPE_CHECKING
import libcst as cst
import sentry_sdk
import stamina
from ninja import NinjaAPI, Schema
from openai.types.chat import ChatCompletionSystemMessageParam, ChatCompletionUserMessageParam
from pydantic import ValidationError
@ -14,8 +16,6 @@ from aiservice.analytics.posthog import ph
from aiservice.background import fire_and_forget
from aiservice.common_utils import validate_trace_id
from aiservice.env_specific import debug_log_sensitive_data
import stamina
from aiservice.llm import LLMOutputUnparseable, llm_client
from aiservice.llm_models import ADAPTIVE_OPTIMIZE_MODEL
from authapp.auth import AuthenticatedRequest
@ -26,6 +26,8 @@ from core.shared.optimizer_schemas import OptimizeResponseItemSchema
from .adaptive_optimizer_context import AdaptiveOptContext, AdaptiveOptContextData, AdaptiveOptRequestSchema
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from openai.types.chat import ChatCompletionMessageParam
@ -69,6 +71,7 @@ async def perform_adaptive_optimize(
)
llm_cost = output.cost
except Exception as e:
logger.exception("adaptive_optimize LLM call failed: trace_id=%s, user_id=%s", trace_id, user_id)
debug_log_sensitive_data(f"Failed to generate code for source:\n{ctx.data.original_source_code}")
raise LLMOutputUnparseable(str(e)) from e
debug_log_sensitive_data(f"ClaudeClient optimization response:\n{output.content}")
@ -90,6 +93,7 @@ async def perform_adaptive_optimize(
new_opt = await asyncio.to_thread(ctx.parse_and_generate_candidate_schema)
if not new_opt or not ctx.is_valid_code():
extracted_code = ctx.extracted_code_and_expl.code if ctx.extracted_code_and_expl else None
logger.error("adaptive_optimize invalid code: trace_id=%s, user_id=%s", trace_id, user_id)
raise LLMOutputUnparseable("Invalid code generated " + str(extracted_code), cost=llm_cost)
# the parent is the last candidate in the previous optimizations
@ -97,6 +101,7 @@ async def perform_adaptive_optimize(
new_opt.parent_id = last_optimization_id
return new_opt, llm_cost # noqa: TRY300
except (ValueError, ValidationError, cst.ParserSyntaxError) as exc:
logger.exception("adaptive_optimize parsing failed: trace_id=%s, user_id=%s", trace_id, user_id)
sentry_sdk.capture_exception(exc)
debug_log_sensitive_data(f"{type(exc).__name__} for source:\n{ctx.data.original_source_code}")
debug_log_sensitive_data(f"Traceback: {exc}")
@ -123,16 +128,18 @@ async def adaptive_optimize(
trace_id = data.trace_id
if not validate_trace_id(trace_id):
return 400, AdaptiveOptErrorResponseSchema(error="Invalid trace ID. Please provide a valid UUIDv4.")
try:
adaptive_optimization_candidate, llm_cost = await perform_adaptive_optimize(
user_id=request.user, ctx=ctx, trace_id=trace_id
)
if adaptive_optimization_candidate is None:
logger.error("adaptive_optimize endpoint returning 500: trace_id=%s, no candidate generated", trace_id)
return 500, AdaptiveOptErrorResponseSchema(error="Failed to generate optimization candidate")
except LLMOutputUnparseable as e:
return 422, AdaptiveOptErrorResponseSchema(error=str(e))
total_llm_cost = llm_cost
fire_and_forget(update_optimization_cost(trace_id=trace_id, cost=total_llm_cost, user_id=request.user))
fire_and_forget(update_optimization_cost(trace_id=trace_id, cost=llm_cost, user_id=request.user))
if hasattr(request, "should_log_features") and request.should_log_features:
fire_and_forget(
safe_log_features(

View file

@ -7,6 +7,7 @@ from typing import TYPE_CHECKING
import libcst as cst
import sentry_sdk
import stamina
from ninja import NinjaAPI, Schema
from openai.types.chat import ChatCompletionSystemMessageParam, ChatCompletionUserMessageParam
from pydantic import ValidationError
@ -14,8 +15,6 @@ from pydantic import ValidationError
from aiservice.analytics.posthog import ph
from aiservice.common_utils import validate_trace_id
from aiservice.env_specific import debug_log_sensitive_data
import stamina
from aiservice.llm import LLMOutputUnparseable, llm_client
from aiservice.llm_models import CODE_REPAIR_MODEL
from authapp.auth import AuthenticatedRequest
@ -45,8 +44,12 @@ USER_PROMPT = (current_dir / "CODE_REPAIR_USER_PROMPT.md").read_text()
@stamina.retry(on=LLMOutputUnparseable, attempts=2)
async def code_repair( # noqa: D417
user_id: str, optimization_id: str, ctx: CodeRepairContext, optimize_model: LLM = CODE_REPAIR_MODEL
) -> CodeRepairIntermediateResponseItemschema:
user_id: str,
optimization_id: str,
ctx: CodeRepairContext,
trace_id: str = "",
optimize_model: LLM = CODE_REPAIR_MODEL,
) -> CodeRepairIntermediateResponseItemschema | CodeRepairErrorResponseSchema:
"""Repair the given candidate to match the behaviour of the original code.
Parameters
@ -73,7 +76,9 @@ async def code_repair( # noqa: D417
messages: list[ChatCompletionMessageParam] = [system_message, user_message]
debug_log_sensitive_data(f"This was the user prompt\n {user_prompt}\n")
try:
output = await llm_client.call(llm=optimize_model, messages=messages)
output = await llm_client.call(
llm=optimize_model, messages=messages, call_type="code_repair", trace_id=trace_id, user_id=user_id
)
llm_cost = output.cost
except Exception as e:
debug_log_sensitive_data(f"Failed to generate code for source:\n{ctx.data.original_source_code}")
@ -160,10 +165,14 @@ async def repair(
return 200, result
try:
code_repair_data = await code_repair(user_id=request.user, optimization_id=data.optimization_id, ctx=ctx)
code_repair_data = await code_repair(
user_id=request.user, optimization_id=data.optimization_id, ctx=ctx, trace_id=trace_id
)
if isinstance(code_repair_data, CodeRepairErrorResponseSchema):
return 500, code_repair_data
except LLMOutputUnparseable as e:
return 422, CodeRepairErrorResponseSchema(error=str(e))
total_llm_cost = code_repair_data.llm_cost
llm_cost = code_repair_data.llm_cost
try:
ctx.validate_module()
except cst.ParserSyntaxError as e:
@ -180,7 +189,7 @@ async def repair(
return 422, CodeRepairErrorResponseSchema(error=str(exc))
async with asyncio.TaskGroup() as tg:
tg.create_task(update_optimization_cost(trace_id=trace_id, cost=total_llm_cost, user_id=request.user))
tg.create_task(update_optimization_cost(trace_id=trace_id, cost=llm_cost, user_id=request.user))
if hasattr(request, "should_log_features") and request.should_log_features:
tg.create_task(
safe_log_features(

View file

@ -181,6 +181,18 @@ x ="""
assert result == expected
def test_extract_code_block_with_filepath_annotation() -> None:
text = "```python:src/main.py\ndef foo(): pass\n```"
result = extract_code_block(text)
assert result == "def foo(): pass"
def test_extract_code_block_with_filepath_annotation_fallback() -> None:
text = "```python:src/main.py\ndef foo(): pass"
result = extract_code_block(text)
assert result == "def foo(): pass"
def test_extract_code_block_nested_code_fence_in_triple_quote() -> None:
# LLM embeds function definition in a triple-quoted string containing ```
text = '```python\nimport pytest\n_source = """```python:file.py\ndef foo(): pass\n```"""\ndef test_foo():\n assert True\n```'

View file

@ -1,12 +0,0 @@
node_modules/
dist/
build/
coverage/
*.config.js
.eslintrc.mjs
.eslintrc.json
postcss.config.js
tailwind.config.js
// Comment out the ESLint line temporarily to allow for the build to pass
**/*.ts
**/*.js

View file

@ -1,20 +0,0 @@
module.exports = {
root: true,
extends: ["next/core-web-vitals", "plugin:@typescript-eslint/recommended", "prettier"],
parser: "@typescript-eslint/parser",
parserOptions: {
project: "./tsconfig.json",
tsconfigRootDir: __dirname,
ecmaVersion: "latest",
sourceType: "module",
ecmaFeatures: {
jsx: true,
},
},
plugins: ["@typescript-eslint", "react"],
ignorePatterns: ["dist/**", "node_modules/**", "*.config.js", "*.config.mjs", ".eslintrc.js"],
rules: {
"react/react-in-jsx-scope": "off",
"@typescript-eslint/no-explicit-any": "warn",
},
}

View file

@ -0,0 +1,44 @@
import nextConfig from "eslint-config-next"
import prettier from "eslint-config-prettier"
// Find the config object that includes the @typescript-eslint plugin
// and add our custom rule there
const eslintConfig = [
...nextConfig.map(config => {
if (config.plugins?.["@typescript-eslint"]) {
return {
...config,
rules: {
...config.rules,
"@typescript-eslint/no-explicit-any": "warn",
},
}
}
return config
}),
prettier,
{
rules: {
"react/react-in-jsx-scope": "off",
// Downgrade React Compiler rules to warnings: pre-existing patterns, fix incrementally
"react-hooks/set-state-in-effect": "warn",
"react-hooks/error-boundaries": "warn",
"react-hooks/immutability": "warn",
"react-hooks/preserve-manual-memoization": "warn",
"react-hooks/purity": "warn",
"react-hooks/refs": "warn",
"react-hooks/static-components": "warn",
},
},
{
ignores: [
"dist/**",
"node_modules/**",
"*.config.js",
"*.config.mjs",
".next/**",
],
},
]
export default eslintConfig

View file

@ -1,3 +1,8 @@
import { dirname } from "path"
import { fileURLToPath } from "url"
const __dirname = dirname(fileURLToPath(import.meta.url))
/** @type {import("next").NextConfig} */
const nextConfig = {
transpilePackages: ["@codeflash-ai/common"],
@ -25,12 +30,22 @@ const nextConfig = {
return config
},
turbopack: {
root: __dirname,
resolveAlias: {
// Stub Node.js built-ins that web-tree-sitter tries to import in the browser.
// Uses { browser: ... } so aliases only apply to client bundles, not SSR.
'fs': { browser: './src/lib/empty-shim.js' },
'fs/promises': { browser: './src/lib/empty-shim.js' },
'path': { browser: './src/lib/empty-shim.js' },
'module': { browser: './src/lib/empty-shim.js' },
},
},
experimental: {
serverActions: {
allowedOrigins: ["app.codeflash.ai", "localhost:3000"],
bodySizeLimit: '5mb', // Increased from default 1mb to handle large PR creation payloads
},
instrumentationHook: true,
},
typescript: {
ignoreBuildErrors: false,

File diff suppressed because it is too large Load diff

View file

@ -7,8 +7,8 @@
"build": " npm install --loglevel verbose && npx prisma generate && npx next build",
"deploy": "az webapp up -n codeflash-webapp-2 --sku P1V2 --runtime NODE:20-lts",
"start": "node_modules/next/dist/bin/next start",
"lint": "next lint --fix",
"lint:check": "next lint",
"lint": "eslint --fix .",
"lint:check": "eslint .",
"test": "vitest",
"type-check": "tsc --noEmit",
"prisma:generate": "npx prisma generate",
@ -20,7 +20,7 @@
},
"dependencies": {
"@anthropic-ai/sdk": "^0.74.0",
"@auth0/nextjs-auth0": "^3.3.0",
"@auth0/nextjs-auth0": "^4",
"@azure/msal-node": "^3.7.3",
"@codeflash-ai/common": "^1.0.30",
"@hookform/resolvers": "^3.3.2",
@ -37,11 +37,11 @@
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.1.4",
"@sentry/nextjs": "^9.34.0",
"@sentry/nextjs": "^10.38.0",
"@types/node": "^24.3.0",
"@types/pg": "^8.10.9",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react": "19.2.13",
"@types/react-dom": "19.2.3",
"@types/react-syntax-highlighter": "^15.5.13",
"chart.js": "^4.4.9",
"class-variance-authority": "^0.7.0",
@ -51,10 +51,10 @@
"framer-motion": "^12.12.1",
"github-markdown-css": "^5.4.0",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.381.0",
"lucide-react": "^0.563.0",
"marked": "^16.1.1",
"next": "^14.2.32",
"next-themes": "^0.3.0",
"next": "16.1.6",
"next-themes": "^0.4.6",
"node-ts-cache": "^4.4.0",
"node-ts-cache-storage-memory": "^4.4.0",
"pg": "^8.11.3",
@ -62,9 +62,9 @@
"posthog-js": "1.127.0",
"posthog-node": "^4.0.1",
"prism-react-renderer": "^2.4.1",
"react": "^18",
"react": "19.2.4",
"react-chartjs-2": "^5.3.0",
"react-dom": "^18",
"react-dom": "19.2.4",
"react-hook-form": "^7.48.2",
"react-markdown": "^9.0.1",
"react-papaparse": "^4.4.0",
@ -82,18 +82,12 @@
"devDependencies": {
"@testing-library/react": "^16.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@typescript-eslint/eslint-plugin": "^8.50.1",
"@typescript-eslint/parser": "^8.50.1",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.0.1",
"baseline-browser-mapping": "^2.9.11",
"eslint": "^8.57.0",
"eslint-config-next": "15.5.2",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.29.0",
"eslint-plugin-n": "^16.6.2",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-react": "^7.33.2",
"jsdom": "^24.1.0",
"lint-staged": "^15.4.3",
"prettier": "3.2.5",
@ -117,5 +111,9 @@
"**/*.{json,md}": [
"prettier --write"
]
},
"overrides": {
"@types/react": "19.2.13",
"@types/react-dom": "19.2.3"
}
}

View file

@ -57,8 +57,8 @@ export async function fetchUserInfo(): Promise<{
error?: string
}> {
try {
const { getSession } = await import("@auth0/nextjs-auth0")
const session = await getSession()
const { auth0 } = await import("@/lib/auth0")
const session = await auth0.getSession()
if (!session?.user) {
return { error: "Unauthorized" }

View file

@ -1,5 +1,5 @@
import { redirect } from "next/navigation"
import { getSession } from "@auth0/nextjs-auth0"
import { auth0 } from "@/lib/auth0"
import Link from "next/link"
import { type JSX } from "react"
import { APP_ROUTES } from "@/lib/types"
@ -12,12 +12,13 @@ function isValidReturnUrl(url: string): boolean {
return false
}
export default async function AuthenticationPage({
searchParams,
}: {
searchParams: { returnTo?: string; error?: string }
}): Promise<JSX.Element> {
const session = await getSession()
export default async function AuthenticationPage(
props: {
searchParams: Promise<{ returnTo?: string; error?: string }>
}
): Promise<JSX.Element> {
const searchParams = await props.searchParams;
const session = await auth0.getSession()
if (session) {
// User is already logged in
@ -35,7 +36,7 @@ export default async function AuthenticationPage({
<h2 className="text-2xl font-bold">Login Error</h2>
<p className="mt-2">There was an error during login. Please try again.</p>
<Link
href="/api/auth/login"
href="/auth/login"
className="mt-4 inline-block rounded bg-blue-500 px-4 py-2 text-white"
>
Try Again
@ -52,6 +53,6 @@ export default async function AuthenticationPage({
: APP_ROUTES.BASE
console.log(`[Login Page] Redirecting to Auth0 with returnTo: ${returnTo}`)
const loginUrl = `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}`
const loginUrl = `/auth/login?returnTo=${encodeURIComponent(returnTo)}`
redirect(loginUrl)
}

View file

@ -1,6 +1,6 @@
"use server"
import { getSession } from "@auth0/nextjs-auth0"
import { auth0 } from "@/lib/auth0"
import { markUserCompletedOnboarding, submitOnboardingQuestions } from "@codeflash-ai/common"
import PostHogClient from "@/lib/posthog"
import { redirect } from "next/navigation"
@ -11,7 +11,7 @@ export async function SubmitFirstOnboardingPage(
selectedOptions: string[],
customOptionInput: string,
): Promise<void> {
const session = await getSession()
const session = await auth0.getSession()
if (session == null) {
console.log("No session, redirecting to login")
redirect("/login")
@ -43,11 +43,12 @@ export async function SubmitFirstOnboardingPage(
await submitOnboardingQuestions(user_id, email)
// Check for saved redirect URL after onboarding completion
const returnUrl = cookies().get("returnAfterOnboarding")?.value
const cookieStore = await cookies()
const returnUrl = cookieStore.get("returnAfterOnboarding")?.value
console.log("Checking for saved returnUrl:", returnUrl)
if (returnUrl) {
console.log("Found saved returnUrl, redirecting to:", returnUrl)
cookies().delete("returnAfterOnboarding")
cookieStore.delete("returnAfterOnboarding")
redirect(returnUrl)
} else {
console.log("No saved returnUrl, redirecting to /app/gettingstarted")
@ -56,7 +57,7 @@ export async function SubmitFirstOnboardingPage(
}
export async function SubmitSkipOnboardingPage(): Promise<void> {
const session = await getSession()
const session = await auth0.getSession()
if (session == null) {
console.log("No session, redirecting to login")
redirect("/login")
@ -84,11 +85,12 @@ export async function SubmitSkipOnboardingPage(): Promise<void> {
await markUserCompletedOnboarding(user_id)
// Checking for saved redirect URL after onboarding completion
const returnUrl = cookies().get("returnAfterOnboarding")?.value
const cookieStore = await cookies()
const returnUrl = cookieStore.get("returnAfterOnboarding")?.value
console.log(`Checking for saved returnTo URL: ${returnUrl}`)
if (returnUrl) {
console.log("Found saved returnUrl, redirecting to:", returnUrl)
cookies().delete("returnAfterOnboarding")
cookieStore.delete("returnAfterOnboarding")
redirect(returnUrl)
} else {
console.log("No saved returnUrl, redirecting to /app/gettingstarted")

View file

@ -1,6 +1,6 @@
"use server"
import { getSession } from "@auth0/nextjs-auth0"
import { auth0 } from "@/lib/auth0"
import PostHogClient from "@/lib/posthog"
import { redirect } from "next/navigation"
@ -10,7 +10,7 @@ export async function SubmitSecondOnboardingPage(
pythonLibraries: string[] | null,
colleagueInviteEmail: string | null,
): Promise<void> {
const session = await getSession()
const session = await auth0.getSession()
if (session == null) {
console.log("No session, redirecting to login")
redirect("/login")

View file

@ -6,13 +6,13 @@ import {
getUserReferralData,
} from "@codeflash-ai/common"
import PostHogClient from "@/lib/posthog"
import { getSession } from "@auth0/nextjs-auth0"
import { auth0 } from "@/lib/auth0"
export async function upsertReferralSource(
referralSource: string,
additionalComments?: string,
): Promise<any> {
const session = await getSession()
const session = await auth0.getSession()
if (session != null) {
setUserReferralData(session.user.sub, referralSource, additionalComments)
const posthog = PostHogClient()

View file

@ -1,4 +1,5 @@
"use client"
import { type JSX } from "react"
import { Button } from "@/components/ui/button"
import { Trash2 } from "lucide-react"
import { type cf_api_keys } from "@prisma/client"

View file

@ -116,7 +116,6 @@ export function CreateApiKeyDialog(): React.JSX.Element {
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<Form {...form}>
{/* eslint-disable-next-line @typescript-eslint/no-misused-promises */}
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<DialogHeader>
<DialogTitle>Create new API key</DialogTitle>

View file

@ -0,0 +1,20 @@
import { Skeleton } from "@/components/ui/skeleton"
export default function ApiKeysLoading() {
return (
<div className="py-6 sm:py-8 px-4 sm:px-6 max-w-[1400px] mx-auto">
<Skeleton className="h-8 w-32 mb-6" />
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-4 p-4 rounded-lg border">
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-56" />
</div>
<Skeleton className="h-8 w-16 rounded-md" />
</div>
))}
</div>
</div>
)
}

View file

@ -1,5 +1,6 @@
"use server"
import { getSession } from "@auth0/nextjs-auth0"
import { type JSX } from "react"
import { auth0 } from "@/lib/auth0"
import { CreateApiKeyDialog } from "./dialog-create-api-key"
import { Separator } from "@/components/ui/separator"
import { ApiKeyTable } from "./api-key-table"
@ -23,7 +24,7 @@ interface ApiKeyWithOrg extends cf_api_keys {
}
export default async function APIKeyGenerator(): Promise<JSX.Element> {
const session = await getSession()
const session = await auth0.getSession()
// Auth handled by middleware + layout
if (!session?.user) {
throw new Error("Authentication required")

View file

@ -1,5 +1,5 @@
"use server"
import { getSession } from "@auth0/nextjs-auth0"
import { auth0 } from "@/lib/auth0"
import { redirect } from "next/navigation"
import {
deleteAPIKeyById,
@ -16,7 +16,7 @@ export async function generateToken(
keyName: string,
organizationId?: string,
): Promise<{ success: boolean; token: string | undefined; err: string | undefined }> {
const user = await getSession()
const user = await auth0.getSession()
if (user == null) {
redirect("/login")
}
@ -63,7 +63,7 @@ export async function generateTokenForVsCode(
}
}
export async function deleteAPIKey(id: number): Promise<void> {
const user = await getSession()
const user = await auth0.getSession()
if (user == null) {
redirect("/login")
return

View file

@ -0,0 +1,20 @@
import { Skeleton } from "@/components/ui/skeleton"
export default function BillingLoading() {
return (
<div className="py-6 sm:py-8 px-4 sm:px-6 max-w-[1400px] mx-auto">
<Skeleton className="h-8 w-32 mb-6" />
<div className="grid gap-6">
<div className="rounded-xl border p-6 space-y-4">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-8 w-24" />
<Skeleton className="h-4 w-64" />
</div>
<div className="rounded-xl border p-6 space-y-4">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-32 w-full rounded-md" />
</div>
</div>
</div>
)
}

View file

@ -1,11 +1,11 @@
"use server"
import { getSession } from "@auth0/nextjs-auth0"
import { auth0 } from "@/lib/auth0"
import { BillingView } from "./billing-view"
import PostHogClient from "@/lib/posthog"
import { SUBSCRIPTION_PLANS, checkAndResetSubscriptionPeriod } from "@codeflash-ai/common"
export default async function BillingPage() {
const session = await getSession()
const session = await auth0.getSession()
if (!session?.user) return null
const userId = session.user.sub
try {

View file

@ -1,9 +1,9 @@
import { getSession } from "@auth0/nextjs-auth0"
import { auth0 } from "@/lib/auth0"
import PostHogClient from "@/lib/posthog"
import GettingStartedClient from "./getting-started-client"
export default async function GettingStarted() {
const session = await getSession()
const session = await auth0.getSession()
if (!session) return null
const userId = session.user.sub

View file

@ -1,10 +1,10 @@
import { getSession } from "@auth0/nextjs-auth0"
import { auth0 } from "@/lib/auth0"
import { redirect } from "next/navigation"
import { ReactNode } from "react"
import { hasCompletedOnboarding } from "@codeflash-ai/common"
export default async function DashboardLayout({ children }: { children: ReactNode }) {
const session = await getSession()
const session = await auth0.getSession()
if (!session) return null
const completedOnboarding = await hasCompletedOnboarding(session.user.sub)

View file

@ -0,0 +1,21 @@
import { Skeleton } from "@/components/ui/skeleton"
export default function MembersLoading() {
return (
<div className="py-6 sm:py-8 px-4 sm:px-6 max-w-[1400px] mx-auto">
<Skeleton className="h-8 w-40 mb-6" />
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-4 p-4 rounded-lg border">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-48" />
</div>
<Skeleton className="h-6 w-20 rounded-full" />
</div>
))}
</div>
</div>
)
}

View file

@ -0,0 +1,438 @@
"use client"
import { useState, useMemo } from "react"
import {
Clock,
GitPullRequest,
Search,
ChevronDown,
X,
RefreshCw,
Filter,
ArrowUpDown,
BookOpen,
} from "lucide-react"
import Image from "next/image"
import { Card } from "@/components/ui/card"
import Link from "next/link"
import { useRouter } from "next/navigation"
import type { RepositoryWithUsage } from "@/app/dashboard/action"
/** Serialized version for server→client boundary (Dates become ISO strings) */
type SerializedRepository = Omit<RepositoryWithUsage, "created_at" | "last_optimized"> & {
created_at: string
last_optimized: string | null
}
import { useOutsideClick } from "@/components/hooks/useOutsideClick"
function SearchBar({
searchQuery,
setSearchQuery,
}: {
searchQuery: string
setSearchQuery: (value: string) => void
}) {
return (
<div className="relative flex-1 group">
<div className="relative">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<Search
size={18}
className="text-muted-foreground/70 group-focus-within:text-primary transition-colors"
/>
</div>
<input
type="text"
placeholder="Search repositories..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="block w-full rounded-xl border border-border bg-background/60 p-3 pl-10 text-foreground focus:border-primary focus:ring-1 focus:ring-primary transition-all duration-200"
/>
{searchQuery && (
<button
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground"
onClick={() => setSearchQuery("")}
>
<X size={18} className="opacity-70 hover:opacity-100" />
</button>
)}
</div>
</div>
)
}
function RepositoryCard({ repo }: { repo: SerializedRepository }) {
return (
<Link href={`/repositories/${repo.id}`}>
<Card
key={repo.id}
className="bg-card bg-muted/5 rounded-xl border border-border hover:border-primary/30 hover:shadow-md hover:shadow-primary/5 transition-all duration-300 overflow-hidden group"
>
<div className="p-5">
<div className="flex items-start">
<div className="mr-3 flex-shrink-0">
{repo.avatarUrl ? (
<div className="w-9 h-9 sm:w-11 sm:h-11 rounded-full overflow-hidden border-2 border-border/50 group-hover:border-primary/20 transition-colors">
<Image
src={repo.avatarUrl}
alt={`${repo.organization} avatar`}
width={44}
height={44}
className="object-cover w-full h-full"
/>
</div>
) : (
<div className="w-9 h-9 sm:w-11 sm:h-11 rounded-full bg-gradient-to-br from-primary/10 to-primary/30 flex items-center justify-center border-2 border-border group-hover:from-primary/20 group-hover:to-primary/40 transition-colors">
<span className="text-primary font-semibold">
{repo.name?.substring(0, 1).toUpperCase() || "?"}
</span>
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center flex-wrap gap-1">
<h3 className="text-sm sm:text-base font-semibold text-primary hover:underline truncate">
{repo.name || "Unknown Repository"}
</h3>
<span
className={`ml-1 px-1.5 sm:px-2 py-0.5 text-xs font-medium rounded-full ${repo.is_private ? "bg-amber-100 text-amber-700" : "bg-emerald-100 text-emerald-700"}`}
>
{repo.is_private ? "Private" : "Public"}
</span>
</div>
<p className="text-xs sm:text-sm text-muted-foreground mb-2">
{repo.full_name || repo.name}
</p>
<div className="flex items-center flex-wrap gap-1.5 sm:gap-2">
<span
className={`inline-flex items-center px-1.5 sm:px-2 py-0.5 sm:py-1 rounded-full text-xs ${repo.is_active ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-600"}`}
>
<span
className={`inline-block w-1.5 sm:w-2 h-1.5 sm:h-2 rounded-full ${repo.is_active ? "bg-green-500" : "bg-gray-400"} mr-1 sm:mr-1.5`}
></span>
<span>{repo.is_active ? "Active" : "Inactive"}</span>
</span>
{repo.has_github_action && (
<span className="inline-flex items-center px-1.5 sm:px-2 py-0.5 sm:py-1 rounded-full bg-blue-50 text-xs text-blue-700">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
width="10"
height="10"
className="mr-1 fill-current sm:w-3 sm:h-3"
>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path>
</svg>
Action
</span>
)}
{repo.membersCount !== undefined && repo.membersCount > 0 && (
<span className="inline-flex items-center px-1.5 sm:px-2 py-0.5 sm:py-1 rounded-full bg-indigo-50 text-xs text-indigo-700">
<svg
xmlns="http://www.w3.org/2000/svg"
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mr-1 sm:w-3 sm:h-3"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>
{repo.membersCount}
</span>
)}
</div>
</div>
</div>
{repo.last_optimized && (
<div className="mt-3 sm:mt-4 text-xs text-muted-foreground flex items-center">
<Clock size={10} className="mr-1 sm:w-3 sm:h-3" />
Last optimized: {new Date(repo.last_optimized).toLocaleDateString()}
</div>
)}
</div>
</Card>
</Link>
)
}
export function RepositoryList({ repositories }: { repositories: SerializedRepository[] }) {
const router = useRouter()
const [searchQuery, setSearchQuery] = useState("")
const [filter, setFilter] = useState<"all" | "active" | "public" | "private">("all")
const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false)
const [sortBy, setSortBy] = useState<"name">("name")
const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false)
const [isRefreshing, setIsRefreshing] = useState(false)
const filterDropdownRef = useOutsideClick(() => setIsFilterDropdownOpen(false))
const sortDropdownRef = useOutsideClick(() => setIsSortDropdownOpen(false))
const getSortLabel = (sortType: string) => {
switch (sortType) {
case "name":
return "Name"
default:
return "Last Optimized"
}
}
const handleRefresh = () => {
if (isRefreshing) return
setIsRefreshing(true)
router.refresh()
// Reset after a short delay since router.refresh() doesn't provide a completion callback
setTimeout(() => setIsRefreshing(false), 2000)
}
const filteredRepositories = useMemo(() => {
if (!repositories || !Array.isArray(repositories)) {
return []
}
let repos = repositories.filter(repo => {
if (!repo) return false
const matchesSearch =
searchQuery === "" ||
(repo.name && repo.name.toLowerCase().includes(searchQuery.toLowerCase())) ||
(repo.full_name && repo.full_name.toLowerCase().includes(searchQuery.toLowerCase())) ||
(repo.organization && repo.organization.toLowerCase().includes(searchQuery.toLowerCase()))
if (!matchesSearch) return false
switch (filter) {
case "active":
return repo.is_active
case "public":
return !repo.is_private
case "private":
return repo.is_private
default:
return true
}
})
switch (sortBy) {
case "name":
repos = repos.sort((a, b) => {
const nameA = a?.name || ""
const nameB = b?.name || ""
return nameA.localeCompare(nameB)
})
break
}
return repos
}, [repositories, searchQuery, filter, sortBy])
return (
<>
<div className="flex justify-between items-center mb-4 sm:mb-6">
<h2 className="text-base sm:text-lg font-semibold flex items-center">
<BookOpen size={18} className="mr-2 text-primary" />
Repository List
</h2>
<button
onClick={handleRefresh}
disabled={isRefreshing}
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-1.5 sm:py-2 rounded-lg text-xs sm:text-sm border border-border bg-background hover:bg-muted/50 transition-colors ${
isRefreshing ? "opacity-50 cursor-not-allowed" : ""
}`}
>
<RefreshCw
size={12}
className={`text-muted-foreground sm:w-4 sm:h-4 ${isRefreshing ? "animate-spin" : ""}`}
/>
{isRefreshing ? "Refreshing..." : "Refresh"}
</button>
</div>
<div className="flex flex-col md:flex-row gap-3 sm:gap-4 mb-5 sm:mb-6">
<SearchBar searchQuery={searchQuery} setSearchQuery={setSearchQuery} />
<div className="flex gap-2">
<div className="relative" ref={filterDropdownRef}>
<button
onClick={() => setIsFilterDropdownOpen(!isFilterDropdownOpen)}
className="flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-2.5 sm:py-3 text-xs sm:text-sm bg-background border border-border rounded-xl hover:border-primary/50 transition-colors focus:outline-none focus:ring-1 focus:ring-primary"
>
<Filter size={14} className="text-muted-foreground sm:w-4 sm:h-4" />
<span>
{filter === "all"
? "All"
: filter.charAt(0).toUpperCase() + filter.slice(1).replace(/-/g, " ")}
</span>
<ChevronDown
size={14}
className={`transition-transform text-muted-foreground sm:w-4 sm:h-4 ${isFilterDropdownOpen ? "rotate-180" : ""}`}
/>
</button>
{isFilterDropdownOpen && (
<div className="absolute z-10 mt-2 w-48 sm:w-52 bg-card rounded-xl shadow-lg overflow-hidden border border-border animate-in fade-in-50 slide-in-from-top-5">
<div className="py-1">
<button
onClick={() => {
setFilter("all")
setIsFilterDropdownOpen(false)
}}
className={`w-full px-3 sm:px-4 py-2 sm:py-2.5 text-left hover:bg-muted flex items-center ${filter === "all" ? "bg-primary/10 text-primary font-medium" : ""}`}
>
<span className="w-4 sm:w-5 h-4 sm:h-5 mr-1.5 sm:mr-2 flex items-center justify-center">
{filter === "all" && (
<span className="w-1.5 h-1.5 rounded-full bg-primary"></span>
)}
</span>
All repositories
</button>
</div>
<div className="border-t border-border py-1">
<button
onClick={() => {
setFilter("active")
setIsFilterDropdownOpen(false)
}}
className={`w-full px-3 sm:px-4 py-2 sm:py-2.5 text-left hover:bg-muted flex items-center ${filter === "active" ? "bg-primary/10 text-primary font-medium" : ""}`}
>
<span className="w-4 sm:w-5 h-4 sm:h-5 mr-1.5 sm:mr-2 flex items-center justify-center">
{filter === "active" && (
<span className="w-1.5 h-1.5 rounded-full bg-primary"></span>
)}
</span>
Active
</button>
</div>
<div className="border-t border-border py-1">
<button
onClick={() => {
setFilter("public")
setIsFilterDropdownOpen(false)
}}
className={`w-full px-3 sm:px-4 py-2 sm:py-2.5 text-left hover:bg-muted flex items-center ${filter === "public" ? "bg-primary/10 text-primary font-medium" : ""}`}
>
<span className="w-4 sm:w-5 h-4 sm:h-5 mr-1.5 sm:mr-2 flex items-center justify-center">
{filter === "public" && (
<span className="w-1.5 h-1.5 rounded-full bg-primary"></span>
)}
</span>
Public
</button>
<button
onClick={() => {
setFilter("private")
setIsFilterDropdownOpen(false)
}}
className={`w-full px-3 sm:px-4 py-2 sm:py-2.5 text-left hover:bg-muted flex items-center ${filter === "private" ? "bg-primary/10 text-primary font-medium" : ""}`}
>
<span className="w-4 sm:w-5 h-4 sm:h-5 mr-1.5 sm:mr-2 flex items-center justify-center">
{filter === "private" && (
<span className="w-1.5 h-1.5 rounded-full bg-primary"></span>
)}
</span>
Private
</button>
</div>
</div>
)}
</div>
<div className="relative" ref={sortDropdownRef}>
<button
onClick={() => setIsSortDropdownOpen(!isSortDropdownOpen)}
className="flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-2.5 sm:py-3 text-xs sm:text-sm bg-background border border-border rounded-xl hover:border-primary/50 transition-colors focus:outline-none focus:ring-1 focus:ring-primary"
>
<ArrowUpDown size={14} className="text-muted-foreground sm:w-4 sm:h-4" />
<span>Sort: {getSortLabel(sortBy)}</span>
<ChevronDown
size={14}
className={`transition-transform text-muted-foreground sm:w-4 sm:h-4 ${isSortDropdownOpen ? "rotate-180" : ""}`}
/>
</button>
{isSortDropdownOpen && (
<div className="absolute right-0 z-10 mt-2 w-48 sm:w-52 bg-card rounded-xl shadow-lg overflow-hidden border border-border animate-in fade-in-50 slide-in-from-top-5">
<div className="py-1">
<button
onClick={() => {
setSortBy("name")
setIsSortDropdownOpen(false)
}}
className={`w-full px-3 sm:px-4 py-2 sm:py-2.5 text-left hover:bg-muted flex items-center ${sortBy === "name" ? "bg-primary/10 text-primary font-medium" : ""}`}
>
<span className="w-4 sm:w-5 h-4 sm:h-5 mr-1.5 sm:mr-2 flex items-center justify-center">
{sortBy === "name" && (
<span className="w-1.5 h-1.5 rounded-full bg-primary"></span>
)}
</span>
Name
</button>
</div>
</div>
)}
</div>
</div>
</div>
{searchQuery && (
<div className="flex items-center mb-4 sm:mb-5 ml-1">
<span className="text-xs sm:text-sm text-muted-foreground mr-1 sm:mr-2">
Searching for:
</span>
<div className="bg-primary/10 text-primary px-2 sm:px-3 py-1 sm:py-1.5 rounded-full text-xs sm:text-sm flex items-center gap-1 sm:gap-1.5">
<span>{searchQuery}</span>
<button
onClick={() => setSearchQuery("")}
className="text-primary hover:text-primary/80"
>
<X size={12} className="sm:w-4 sm:h-4" />
</button>
</div>
</div>
)}
{filteredRepositories.length === 0 ? (
<div className="text-center py-16 sm:py-20 bg-card/50 rounded-xl border border-dashed border-border">
<div className="inline-flex items-center justify-center w-16 h-16 sm:w-20 sm:h-20 rounded-full bg-muted/40 mb-3 sm:mb-4">
<Search size={24} className="text-muted-foreground sm:w-7 sm:h-7" />
</div>
<h3 className="text-base sm:text-lg font-medium mb-1 sm:mb-2">No repositories found</h3>
<p className="text-xs sm:text-sm text-muted-foreground max-w-md mx-auto">
{
"We couldn't find any repositories matching your search criteria. Try adjusting your filters or search term."
}
</p>
<button
onClick={() => {
setSearchQuery("")
setFilter("all")
}}
className="mt-4 sm:mt-5 px-4 sm:px-5 py-2 sm:py-2.5 bg-primary text-primary-foreground rounded-lg sm:rounded-xl text-xs sm:text-sm hover:bg-primary/90 transition-colors"
>
Clear filters
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-5">
{filteredRepositories.map(repo => (
<RepositoryCard key={repo.id} repo={repo} />
))}
</div>
)}
</>
)
}

View file

@ -0,0 +1,22 @@
"use client"
import { RefreshCw } from "lucide-react"
export default function RepositoriesError({ reset }: { error: Error; reset: () => void }) {
return (
<div className="flex justify-center items-center h-[70vh]">
<div className="bg-red-50 text-red-800 p-6 sm:p-8 rounded-xl max-w-md border border-red-200">
<h3 className="text-base sm:text-lg font-medium mb-2 sm:mb-3">Something went wrong</h3>
<p className="mb-3 sm:mb-4 text-sm sm:text-base">
There was an error loading the repositories page.
</p>
<button
onClick={reset}
className="flex items-center gap-1 sm:gap-2 w-full justify-center px-3 sm:px-4 py-2 sm:py-2.5 bg-red-100 hover:bg-red-200 text-red-800 rounded-lg text-xs sm:text-sm font-medium transition-colors"
>
<RefreshCw size={14} className="sm:w-4 sm:h-4" /> Try Again
</button>
</div>
</div>
)
}

View file

@ -0,0 +1,5 @@
import { RepositoriesSkeleton } from "@/components/repositories/RepositoriesSkeleton"
export default function RepositoriesLoading() {
return <RepositoriesSkeleton />
}

View file

@ -1,692 +1,33 @@
"use client"
import { GitPullRequest } from "lucide-react"
import { getAccountContext } from "@/lib/server/get-account-context"
import { getAllRepositories } from "@/app/dashboard/action"
import { RepositoryList } from "./_components/RepositoryList"
import React, { useState, useMemo, useEffect, useCallback, useRef } from "react"
import {
Clock,
GitPullRequest,
Search,
ChevronDown,
X,
RefreshCw,
Filter,
ArrowUpDown,
BookOpen,
} from "lucide-react"
import Image from "next/image"
import { Card } from "@/components/ui/card"
import { getUserIdAndUsername } from "@/app/utils/auth"
import Link from "next/link"
import { getAllRepositories, RepositoryWithUsage } from "@/app/dashboard/action"
import { useViewMode } from "@/app/app/ViewModeContext"
// Error Boundary Component
class RepositoryErrorBoundary extends React.Component<
{ children: React.ReactNode },
{ hasError: boolean; error?: Error }
> {
constructor(props: { children: React.ReactNode }) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error("Repository page error:", error, errorInfo)
}
render() {
if (this.state.hasError) {
return (
<div className="flex justify-center items-center h-[70vh]">
<div className="bg-red-50 text-red-800 p-6 sm:p-8 rounded-xl max-w-md border border-red-200">
<h3 className="text-base sm:text-lg font-medium mb-2 sm:mb-3">Something went wrong</h3>
<p className="mb-3 sm:mb-4 text-sm sm:text-base">
There was an error loading the repositories page.
</p>
<button
onClick={() => window.location.reload()}
className="flex items-center gap-1 sm:gap-2 w-full justify-center px-3 sm:px-4 py-2 sm:py-2.5 bg-red-100 hover:bg-red-200 text-red-800 rounded-lg text-xs sm:text-sm font-medium transition-colors"
>
<RefreshCw size={14} className="sm:w-4 sm:h-4" /> Reload Page
</button>
</div>
</div>
)
}
return this.props.children
}
}
// Custom hook for debouncing
const useDebounce = (callback: () => void, delay: number) => {
const timeoutRef = useRef<NodeJS.Timeout>()
return useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
timeoutRef.current = setTimeout(callback, delay)
}, [callback, delay])
}
// Custom hook for detecting clicks outside of an element
const useOutsideClick = (callback: () => void) => {
const ref = React.useRef<HTMLDivElement>(null)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
callback()
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => {
document.removeEventListener("mousedown", handleClickOutside)
}
}, [callback])
return ref
}
// Enhanced search component
const SearchBar = ({
searchQuery,
setSearchQuery,
}: {
searchQuery: string
setSearchQuery: (value: string) => void
}) => {
return (
<div className="relative flex-1 group">
<div className="relative">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<Search
size={18}
className="text-muted-foreground/70 group-focus-within:text-primary transition-colors"
/>
</div>
<input
type="text"
placeholder="Search repositories..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="block w-full rounded-xl border border-border bg-background/60 p-3 pl-10 text-foreground focus:border-primary focus:ring-1 focus:ring-primary transition-all duration-200"
/>
{searchQuery && (
<button
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground"
onClick={() => setSearchQuery("")}
>
<X size={18} className="opacity-70 hover:opacity-100" />
</button>
)}
</div>
</div>
)
}
// GitHub-style Repository Card Component
const RepositoryCard = ({ repo }: { repo: RepositoryWithUsage }) => (
<Link href={`/repositories/${repo.id}`}>
<Card
key={repo.id}
className="bg-card bg-muted/5 rounded-xl border border-border hover:border-primary/30 hover:shadow-md hover:shadow-primary/5 transition-all duration-300 overflow-hidden group"
>
<div className="p-5">
<div className="flex items-start">
{/* Circular avatar for organization */}
<div className="mr-3 flex-shrink-0">
{repo.avatarUrl ? (
<div className="w-9 h-9 sm:w-11 sm:h-11 rounded-full overflow-hidden border-2 border-border/50 group-hover:border-primary/20 transition-colors">
<Image
src={repo.avatarUrl}
alt={`${repo.organization} avatar`}
width={44}
height={44}
className="object-cover w-full h-full"
/>
</div>
) : (
<div className="w-9 h-9 sm:w-11 sm:h-11 rounded-full bg-gradient-to-br from-primary/10 to-primary/30 flex items-center justify-center border-2 border-border group-hover:from-primary/20 group-hover:to-primary/40 transition-colors">
<span className="text-primary font-semibold">
{repo.name?.substring(0, 1).toUpperCase() || "?"}
</span>
</div>
)}
</div>
<div className="flex-1 min-w-0">
{/* Repository name with visibility badge */}
<div className="flex items-center flex-wrap gap-1">
<h3 className="text-sm sm:text-base font-semibold text-primary hover:underline truncate">
{repo.name || "Unknown Repository"}
</h3>
<span
className={`ml-1 px-1.5 sm:px-2 py-0.5 text-xs font-medium rounded-full ${repo.is_private ? "bg-amber-100 text-amber-700" : "bg-emerald-100 text-emerald-700"}`}
>
{repo.is_private ? "Private" : "Public"}
</span>
</div>
{/* Organization/full name */}
<p className="text-xs sm:text-sm text-muted-foreground mb-2">
{repo.full_name || repo.name}
</p>
{/* Repository stats - matching schema data */}
<div className="flex items-center flex-wrap gap-1.5 sm:gap-2">
{/* Active status */}
<span
className={`inline-flex items-center px-1.5 sm:px-2 py-0.5 sm:py-1 rounded-full text-xs ${repo.is_active ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-600"}`}
>
<span
className={`inline-block w-1.5 sm:w-2 h-1.5 sm:h-2 rounded-full ${repo.is_active ? "bg-green-500" : "bg-gray-400"} mr-1 sm:mr-1.5`}
></span>
<span>{repo.is_active ? "Active" : "Inactive"}</span>
</span>
{/* GitHub Action */}
{repo.has_github_action && (
<span className="inline-flex items-center px-1.5 sm:px-2 py-0.5 sm:py-1 rounded-full bg-blue-50 text-xs text-blue-700">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
width="10"
height="10"
className="mr-1 fill-current sm:w-3 sm:h-3"
>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path>
</svg>
Action
</span>
)}
{/* Members count if available */}
{repo.membersCount !== undefined && repo.membersCount > 0 && (
<span className="inline-flex items-center px-1.5 sm:px-2 py-0.5 sm:py-1 rounded-full bg-indigo-50 text-xs text-indigo-700">
<svg
xmlns="http://www.w3.org/2000/svg"
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mr-1 sm:w-3 sm:h-3"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>
{repo.membersCount}
</span>
)}
</div>
</div>
</div>
{/* Last optimized date */}
{repo.last_optimized && (
<div className="mt-3 sm:mt-4 text-xs text-muted-foreground flex items-center">
<Clock size={10} className="mr-1 sm:w-3 sm:h-3" />
Last optimized: {new Date(repo.last_optimized).toLocaleDateString()}
</div>
)}
</div>
</Card>
</Link>
)
// Import skeleton loaders
import {
RepositoriesSkeleton,
RepositoriesRefreshingSkeleton,
} from "@/components/repositories/RepositoriesSkeleton"
// Loading State Component (now using skeleton loaders)
const RepositoriesLoading = ({ isRefreshing = false }: { isRefreshing?: boolean }) =>
isRefreshing ? (
<RepositoriesRefreshingSkeleton />
) : (
<RepositoriesSkeleton message="Loading repositories..." />
)
// Page Header Component
const PageHeader = ({ totalCount }: { totalCount: number }) => (
<div className="mb-6 sm:mb-8">
<div className="flex items-center gap-3 mb-2">
<h1 className="text-xl sm:text-2xl font-bold">Repositories</h1>
<div className="px-2 py-0.5 sm:px-2.5 sm:py-1 bg-primary/10 text-primary rounded-full text-xs sm:text-sm font-medium">
{totalCount} total
</div>
</div>
</div>
)
// Main component for repository list with filters
const RepositoryList = ({ repositories }: { repositories: RepositoryWithUsage[] }) => {
const [searchQuery, setSearchQuery] = useState("")
const [filter, setFilter] = useState<"all" | "active" | "public" | "private">("all")
const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false)
const [sortBy, setSortBy] = useState<"name">("name")
const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false)
const filterDropdownRef = useOutsideClick(() => setIsFilterDropdownOpen(false))
const sortDropdownRef = useOutsideClick(() => setIsSortDropdownOpen(false))
const getSortLabel = (sortType: string) => {
switch (sortType) {
case "name":
return "Name"
default:
return "Last Optimized"
}
}
const filteredRepositories = useMemo(() => {
// Add safety check for repositories array
if (!repositories || !Array.isArray(repositories)) {
return []
}
let repos = repositories.filter(repo => {
// Add safety checks for repo properties
if (!repo) return false
// Search in name and full_name with safety checks
const matchesSearch =
searchQuery === "" ||
(repo.name && repo.name.toLowerCase().includes(searchQuery.toLowerCase())) ||
(repo.full_name && repo.full_name.toLowerCase().includes(searchQuery.toLowerCase())) ||
(repo.organization && repo.organization.toLowerCase().includes(searchQuery.toLowerCase()))
if (!matchesSearch) return false
switch (filter) {
case "active":
return repo.is_active
case "public":
return !repo.is_private
case "private":
return repo.is_private
default:
return true
}
})
// Sort repositories with safety check
switch (sortBy) {
case "name":
repos = repos.sort((a, b) => {
const nameA = a?.name || ""
const nameB = b?.name || ""
return nameA.localeCompare(nameB)
})
break
}
return repos
}, [repositories, searchQuery, filter, sortBy])
return (
<>
<div className="flex flex-col md:flex-row gap-3 sm:gap-4 mb-5 sm:mb-6">
<SearchBar searchQuery={searchQuery} setSearchQuery={setSearchQuery} />
<div className="flex gap-2">
<div className="relative" ref={filterDropdownRef}>
<button
onClick={() => setIsFilterDropdownOpen(!isFilterDropdownOpen)}
className="flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-2.5 sm:py-3 text-xs sm:text-sm bg-background border border-border rounded-xl hover:border-primary/50 transition-colors focus:outline-none focus:ring-1 focus:ring-primary"
>
<Filter size={14} className="text-muted-foreground sm:w-4 sm:h-4" />
<span>
{filter === "all"
? "All"
: filter.charAt(0).toUpperCase() + filter.slice(1).replace(/-/g, " ")}
</span>
<ChevronDown
size={14}
className={`transition-transform text-muted-foreground sm:w-4 sm:h-4 ${isFilterDropdownOpen ? "rotate-180" : ""}`}
/>
</button>
{isFilterDropdownOpen && (
<div className="absolute z-10 mt-2 w-48 sm:w-52 bg-card rounded-xl shadow-lg overflow-hidden border border-border animate-in fade-in-50 slide-in-from-top-5">
<div className="py-1">
<button
onClick={() => {
setFilter("all")
setIsFilterDropdownOpen(false)
}}
className={`w-full px-3 sm:px-4 py-2 sm:py-2.5 text-left hover:bg-muted flex items-center ${filter === "all" ? "bg-primary/10 text-primary font-medium" : ""}`}
>
<span className="w-4 sm:w-5 h-4 sm:h-5 mr-1.5 sm:mr-2 flex items-center justify-center">
{filter === "all" && (
<span className="w-1.5 h-1.5 rounded-full bg-primary"></span>
)}
</span>
All repositories
</button>
</div>
<div className="border-t border-border py-1">
<button
onClick={() => {
setFilter("active")
setIsFilterDropdownOpen(false)
}}
className={`w-full px-3 sm:px-4 py-2 sm:py-2.5 text-left hover:bg-muted flex items-center ${filter === "active" ? "bg-primary/10 text-primary font-medium" : ""}`}
>
<span className="w-4 sm:w-5 h-4 sm:h-5 mr-1.5 sm:mr-2 flex items-center justify-center">
{filter === "active" && (
<span className="w-1.5 h-1.5 rounded-full bg-primary"></span>
)}
</span>
Active
</button>
</div>
<div className="border-t border-border py-1">
<button
onClick={() => {
setFilter("public")
setIsFilterDropdownOpen(false)
}}
className={`w-full px-3 sm:px-4 py-2 sm:py-2.5 text-left hover:bg-muted flex items-center ${filter === "public" ? "bg-primary/10 text-primary font-medium" : ""}`}
>
<span className="w-4 sm:w-5 h-4 sm:h-5 mr-1.5 sm:mr-2 flex items-center justify-center">
{filter === "public" && (
<span className="w-1.5 h-1.5 rounded-full bg-primary"></span>
)}
</span>
Public
</button>
<button
onClick={() => {
setFilter("private")
setIsFilterDropdownOpen(false)
}}
className={`w-full px-3 sm:px-4 py-2 sm:py-2.5 text-left hover:bg-muted flex items-center ${filter === "private" ? "bg-primary/10 text-primary font-medium" : ""}`}
>
<span className="w-4 sm:w-5 h-4 sm:h-5 mr-1.5 sm:mr-2 flex items-center justify-center">
{filter === "private" && (
<span className="w-1.5 h-1.5 rounded-full bg-primary"></span>
)}
</span>
Private
</button>
</div>
</div>
)}
</div>
<div className="relative" ref={sortDropdownRef}>
<button
onClick={() => setIsSortDropdownOpen(!isSortDropdownOpen)}
className="flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-2.5 sm:py-3 text-xs sm:text-sm bg-background border border-border rounded-xl hover:border-primary/50 transition-colors focus:outline-none focus:ring-1 focus:ring-primary"
>
<ArrowUpDown size={14} className="text-muted-foreground sm:w-4 sm:h-4" />
<span>Sort: {getSortLabel(sortBy)}</span>
<ChevronDown
size={14}
className={`transition-transform text-muted-foreground sm:w-4 sm:h-4 ${isSortDropdownOpen ? "rotate-180" : ""}`}
/>
</button>
{isSortDropdownOpen && (
<div className="absolute right-0 z-10 mt-2 w-48 sm:w-52 bg-card rounded-xl shadow-lg overflow-hidden border border-border animate-in fade-in-50 slide-in-from-top-5">
<div className="py-1">
<button
onClick={() => {
setSortBy("name")
setIsSortDropdownOpen(false)
}}
className={`w-full px-3 sm:px-4 py-2 sm:py-2.5 text-left hover:bg-muted flex items-center ${sortBy === "name" ? "bg-primary/10 text-primary font-medium" : ""}`}
>
<span className="w-4 sm:w-5 h-4 sm:h-5 mr-1.5 sm:mr-2 flex items-center justify-center">
{sortBy === "name" && (
<span className="w-1.5 h-1.5 rounded-full bg-primary"></span>
)}
</span>
Name
</button>
</div>
</div>
)}
</div>
</div>
</div>
{/* Search indicator */}
{searchQuery && (
<div className="flex items-center mb-4 sm:mb-5 ml-1">
<span className="text-xs sm:text-sm text-muted-foreground mr-1 sm:mr-2">
Searching for:
</span>
<div className="bg-primary/10 text-primary px-2 sm:px-3 py-1 sm:py-1.5 rounded-full text-xs sm:text-sm flex items-center gap-1 sm:gap-1.5">
<span>{searchQuery}</span>
<button
onClick={() => setSearchQuery("")}
className="text-primary hover:text-primary/80"
>
<X size={12} className="sm:w-4 sm:h-4" />
</button>
</div>
</div>
)}
{filteredRepositories.length === 0 ? (
<div className="text-center py-16 sm:py-20 bg-card/50 rounded-xl border border-dashed border-border">
<div className="inline-flex items-center justify-center w-16 h-16 sm:w-20 sm:h-20 rounded-full bg-muted/40 mb-3 sm:mb-4">
<Search size={24} className="text-muted-foreground sm:w-7 sm:h-7" />
</div>
<h3 className="text-base sm:text-lg font-medium mb-1 sm:mb-2">No repositories found</h3>
<p className="text-xs sm:text-sm text-muted-foreground max-w-md mx-auto">
{
"We couldn't find any repositories matching your search criteria. Try adjusting your filters or search term."
}
</p>
<button
onClick={() => {
setSearchQuery("")
setFilter("all")
}}
className="mt-4 sm:mt-5 px-4 sm:px-5 py-2 sm:py-2.5 bg-primary text-primary-foreground rounded-lg sm:rounded-xl text-xs sm:text-sm hover:bg-primary/90 transition-colors"
>
Clear filters
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-5">
{filteredRepositories.map(repo => (
<RepositoryCard key={repo.id} repo={repo} />
))}
</div>
)}
</>
)
}
// Main page component
const maxRetries = 3
function RepositoriesPage() {
const [repositories, setRepositories] = useState<RepositoryWithUsage[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [isRefreshing, setIsRefreshing] = useState(false)
const [retryCount, setRetryCount] = useState(0)
const { currentOrg } = useViewMode()
const fetchRepositories = useCallback(
async (attempt = 0) => {
try {
setLoading(attempt === 0)
setError(null)
// Add a small delay for rapid refreshes and retries
if (attempt > 0) {
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000))
}
const data = await getUserIdAndUsername()
if (!data || !data.userId || !data.username) {
throw new Error("User authentication data not found")
}
const repos = await getAllRepositories(
currentOrg ? { orgId: currentOrg.id } : { userId: data.userId, username: data.username },
)
if (Array.isArray(repos)) {
setRepositories(repos)
setRetryCount(0) // Reset retry count on success
} else {
console.warn("Received non-array repositories data:", repos)
setRepositories([])
}
} catch (err) {
console.error(`Failed to fetch repositories (attempt ${attempt + 1}):`, err)
// If it's an auth error and we haven't exceeded retries, try again
if (
attempt < maxRetries &&
err instanceof Error &&
(err.message.includes("authentication") ||
err.message.includes("User authentication data not found") ||
err.message.includes("Unauthorized") ||
err.message.includes("No valid session found"))
) {
setRetryCount(attempt + 1)
return fetchRepositories(attempt + 1)
}
setError("Failed to load repositories. Please try again later.")
setRepositories([])
} finally {
setLoading(false)
setIsRefreshing(false)
}
},
[currentOrg],
)
// Debounced refresh to prevent rapid successive calls
const debouncedRefresh = useDebounce(() => {
setIsRefreshing(true)
fetchRepositories()
}, 300)
const handleRefresh = () => {
if (!isRefreshing && !loading) {
debouncedRefresh()
}
}
// Handle browser refresh with beforeunload
useEffect(() => {
const handleBeforeUnload = () => {
// Clear any pending timeouts
return null
}
window.addEventListener("beforeunload", handleBeforeUnload)
return () => window.removeEventListener("beforeunload", handleBeforeUnload)
}, [])
useEffect(() => {
// Check if user was recently authenticated
const lastAuthCheck = localStorage.getItem("lastAuthCheck")
const now = Date.now()
// If last auth check was less than 2 seconds ago, wait a bit
if (lastAuthCheck && now - parseInt(lastAuthCheck) < 2000) {
const delay = 2000 - (now - parseInt(lastAuthCheck))
setTimeout(() => {
fetchRepositories()
}, delay)
} else {
// Add a small delay to prevent race conditions on rapid refreshes
const timeoutId = setTimeout(() => {
fetchRepositories()
}, 100)
const cleanup = () => clearTimeout(timeoutId)
return cleanup
}
// Update last auth check time
localStorage.setItem("lastAuthCheck", now.toString())
}, [fetchRepositories])
// Refresh Button Component
const RefreshButton = () => (
<button
onClick={handleRefresh}
disabled={isRefreshing || loading}
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-1.5 sm:py-2 rounded-lg text-xs sm:text-sm border border-border bg-background hover:bg-muted/50 transition-colors ${
isRefreshing || loading ? "opacity-50 cursor-not-allowed" : ""
}`}
>
<RefreshCw
size={12}
className={`text-muted-foreground sm:w-4 sm:h-4 ${isRefreshing || loading ? "animate-spin" : ""}`}
/>
{isRefreshing ? "Refreshing..." : "Refresh"}
</button>
)
if (loading) {
return <RepositoriesLoading isRefreshing={isRefreshing} />
}
if (error) {
return (
<div className="flex justify-center items-center h-[70vh]">
<div className="bg-red-50 text-red-800 p-6 sm:p-8 rounded-xl max-w-md border border-red-200">
<h3 className="text-base sm:text-lg font-medium mb-2 sm:mb-3">
Unable to Load Repositories
</h3>
<p className="mb-3 sm:mb-4 text-sm sm:text-base">{error}</p>
{retryCount > 0 && (
<p className="mb-3 text-xs text-red-600">
Retry attempt: {retryCount}/{maxRetries}
</p>
)}
<button
onClick={() => fetchRepositories()}
className="flex items-center gap-1 sm:gap-2 w-full justify-center px-3 sm:px-4 py-2 sm:py-2.5 bg-red-100 hover:bg-red-200 text-red-800 rounded-lg text-xs sm:text-sm font-medium transition-colors"
>
<RefreshCw size={14} className="sm:w-4 sm:h-4" /> Try Again
</button>
</div>
</div>
)
}
export default async function RepositoriesPage() {
const accountPayload = await getAccountContext()
const repos = await getAllRepositories(accountPayload)
// Serialize Date objects for client component boundary
const repositories = (Array.isArray(repos) ? repos : []).map(repo => ({
...repo,
created_at: repo.created_at instanceof Date ? repo.created_at.toISOString() : repo.created_at,
last_optimized:
repo.last_optimized instanceof Date ? repo.last_optimized.toISOString() : repo.last_optimized,
}))
return (
<div className="flex-1 bg-background">
<div className="h-screen py-6 sm:py-8 px-4 sm:px-6 max-w-[1400px] mx-auto">
<PageHeader totalCount={repositories?.length || 0} />
<div className="min-h-screen py-6 sm:py-8 px-4 sm:px-6 max-w-[1400px] mx-auto">
<div className="mb-6 sm:mb-8">
<div className="flex items-center gap-3 mb-2">
<h1 className="text-xl sm:text-2xl font-bold">Repositories</h1>
<div className="px-2 py-0.5 sm:px-2.5 sm:py-1 bg-primary/10 text-primary rounded-full text-xs sm:text-sm font-medium">
{repositories.length} total
</div>
</div>
</div>
<div className="bg-card p-4 sm:p-6 rounded-xl border border-border">
<div className="flex justify-between items-center mb-4 sm:mb-6">
<h2 className="text-base sm:text-lg font-semibold flex items-center">
<BookOpen size={18} className="mr-2 text-primary" />
Repository List
</h2>
<RefreshButton />
</div>
{!repositories || repositories.length === 0 ? (
{repositories.length === 0 ? (
<div className="flex justify-center items-center min-h-[300px] sm:min-h-[400px] w-full">
<div className="text-center py-12 sm:py-16 bg-muted/10 rounded-xl border border-dashed border-border max-w-lg w-full px-5 sm:px-8">
<div className="inline-flex items-center justify-center w-12 h-12 sm:w-16 sm:h-16 rounded-full bg-muted/20 mb-3 sm:mb-4">
@ -727,12 +68,3 @@ function RepositoriesPage() {
</div>
)
}
// Main export with error boundary
export default function RepositoriesPageWrapper() {
return (
<RepositoryErrorBoundary>
<RepositoriesPage />
</RepositoryErrorBoundary>
)
}

View file

@ -3,7 +3,7 @@
import { CF_API } from "@/app/api/const"
import { ActionResponse, createErrorResponse, createSuccessResponse } from "@/lib/action-response"
import { getRepositoriesForAccountCached } from "@/lib/services/repository-utils"
import { getAccessToken } from "@auth0/nextjs-auth0"
import { auth0 } from "@/lib/auth0"
import { AccountPayload, buildOptimizationOrCondition, prisma } from "@codeflash-ai/common"
import * as Sentry from "@sentry/nextjs"
@ -32,9 +32,9 @@ export interface GetStagingCodeParams {
export async function getStagingCodeFromApi(params: GetStagingCodeParams): Promise<ActionResponse<StagingCodeResponse>> {
const cfapiUrl = process.env.CODEFLASH_CFAPI_URL
const session = await getAccessToken({ refresh: true })
const session = await auth0.getAccessToken()
if (!cfapiUrl || !session?.accessToken) {
if (!cfapiUrl || !session?.token) {
return createErrorResponse("Please sign in to continue")
}
@ -43,7 +43,7 @@ export async function getStagingCodeFromApi(params: GetStagingCodeParams): Promi
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.accessToken}`,
Authorization: `Bearer ${session.token}`,
"X-CodeFlash-Source": "webapp",
},
body: JSON.stringify(params),
@ -92,9 +92,9 @@ export async function commitStagingCode(
commitMessage?: string,
): Promise<ActionResponse<CommitStagingCodeResponse>> {
const cfapiUrl = process.env.CODEFLASH_CFAPI_URL
const session = await getAccessToken({ refresh: true })
const session = await auth0.getAccessToken()
if (!cfapiUrl || !session?.accessToken) {
if (!cfapiUrl || !session?.token) {
return createErrorResponse("Please sign in to continue")
}
@ -103,7 +103,7 @@ export async function commitStagingCode(
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.accessToken}`,
Authorization: `Bearer ${session.token}`,
"X-CodeFlash-Source": "webapp",
},
body: JSON.stringify({
@ -275,9 +275,9 @@ export async function createPullRequest({
optimizedLineProfiler?: string
}): Promise<ActionResponse> {
const cfapiUrl = process.env.CODEFLASH_CFAPI_URL
const session = await getAccessToken({ refresh: true })
const session = await auth0.getAccessToken()
if (!cfapiUrl || !session?.accessToken) {
if (!cfapiUrl || !session?.token) {
return createErrorResponse("Please sign in to continue")
}
@ -291,7 +291,7 @@ export async function createPullRequest({
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.accessToken}`,
Authorization: `Bearer ${session.token}`,
"X-CodeFlash-Source": "webapp",
},
body: JSON.stringify({

View file

@ -3,7 +3,15 @@
import { useEffect, useState, useCallback, useRef } from "react"
import { useParams, useRouter } from "next/navigation"
import Image from "next/image"
import { Zap, CheckCircle, XCircle, MessageSquare, Loader2, GitCommit, BarChart3 } from "lucide-react"
import {
Zap,
CheckCircle,
XCircle,
MessageSquare,
Loader2,
GitCommit,
BarChart3,
} from "lucide-react"
import {
createPullRequest,
getOptimizationEventById,
@ -15,7 +23,12 @@ import {
commitStagingCode,
} from "./action"
import { getUserIdAndUsername } from "@/app/utils/auth"
import MonacoDiffEditorGithub from "@/components/Editor/monaco-diff-editor-github"
import dynamic from "next/dynamic"
const MonacoDiffEditorGithub = dynamic(
() => import("@/components/Editor/monaco-diff-editor-github"),
{ ssr: false },
)
import { toast } from "sonner"
import { MarkdownEditor } from "@/components/markdwon/markdown-editor"
import { MarkdownViewer } from "@/components/markdwon/markdown-viewer"
@ -653,8 +666,7 @@ export default function OptimizationReviewPage() {
// Check if we have empty diffContents for git_branch storage type (merged PR in privacy mode)
const isPrivacyModeWithNoDiff =
event.staging_storage_type === "git_branch" &&
Object.keys(diffContents).length === 0
event.staging_storage_type === "git_branch" && Object.keys(diffContents).length === 0
return (
<div className="min-h-screen bg-background">

View file

@ -0,0 +1,958 @@
"use client"
import { useState, useCallback, useRef, useEffect } from "react"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Search,
FileCode2,
Zap,
Clock,
ChevronLeft,
ChevronRight,
Filter,
ArrowUpDown,
ArrowUp,
ArrowDown,
} from "lucide-react"
import { formatDistanceToNow } from "date-fns"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { getAllOptimizationEvents } from "../action"
import Image from "next/image"
import { ReviewQualityBadge } from "@/components/ui/quality_badge"
import type { AccountPayload } from "@codeflash-ai/common"
interface Repository {
id: string
full_name?: string
}
interface DiffContent {
oldContent: string
newContent: string
}
interface EventMetadata {
diffContents?: Record<string, DiffContent>
[key: string]: unknown
}
interface OptimizationEvent {
id: string
function_name?: string
file_path?: string
repository?: Repository | null | undefined
speedup_x?: number
speedup_pct?: number
metadata?: EventMetadata | null | undefined
created_at: string
status?: string
event_type?: string
trace_id: string
review_quality: string
}
interface FilterState {
search: string
repositoryId: string | null
status: string
eventType: string
reviewQuality: string
sortBy: string
page: number
}
interface OptimizationsTableProps {
initialEvents: OptimizationEvent[]
initialTotalCount: number
availableRepositories: Array<{ id: string; full_name: string }>
accountPayload: AccountPayload
}
function TableSkeleton() {
return (
<>
{Array.from({ length: 5 }).map((_, index) => (
<TableRow key={index}>
<TableCell>
<div className="flex items-start gap-3">
<div className="h-4 w-4 bg-muted animate-pulse rounded mt-1 flex-shrink-0" />
<div className="flex-1 min-w-0 space-y-2">
<div className="h-4 bg-muted animate-pulse rounded w-3/4" />
<div className="h-3 bg-muted animate-pulse rounded w-1/2" />
</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<div className="h-8 w-8 bg-muted animate-pulse rounded-full flex-shrink-0" />
<div className="h-4 bg-muted animate-pulse rounded w-32" />
</div>
</TableCell>
<TableCell className="text-center">
<div className="h-6 bg-muted animate-pulse rounded-full w-20 mx-auto" />
</TableCell>
<TableCell className="text-center">
<div className="h-6 bg-muted animate-pulse rounded-full w-24 mx-auto" />
</TableCell>
<TableCell className="text-center">
<div className="h-6 bg-muted animate-pulse rounded-full w-16 mx-auto" />
</TableCell>
<TableCell className="text-center">
<div className="h-6 bg-muted animate-pulse rounded-full w-24 mx-auto" />
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-2">
<div className="h-6 bg-muted animate-pulse rounded-full w-12" />
<div className="h-6 bg-muted animate-pulse rounded-full w-12" />
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<div className="h-3 w-3 bg-muted animate-pulse rounded flex-shrink-0" />
<div className="h-4 bg-muted animate-pulse rounded w-24" />
</div>
</TableCell>
</TableRow>
))}
</>
)
}
function calculateDiffStats(
diffContents: Record<string, { oldContent: string; newContent: string }>,
) {
let totalAdditions = 0
let totalDeletions = 0
Object.entries(diffContents).forEach(([, { oldContent, newContent }]) => {
const oldLines = oldContent.split("\n").filter(line => line.trim() !== "")
const newLines = newContent.split("\n").filter(line => line.trim() !== "")
const oldLineMap = new Map<string, number>()
const newLineMap = new Map<string, number>()
oldLines.forEach(line => {
const trimmed = line.trim()
oldLineMap.set(trimmed, (oldLineMap.get(trimmed) || 0) + 1)
})
newLines.forEach(line => {
const trimmed = line.trim()
newLineMap.set(trimmed, (newLineMap.get(trimmed) || 0) + 1)
})
for (const [line, oldCount] of Array.from(oldLineMap)) {
const newCount = newLineMap.get(line) || 0
if (oldCount > newCount) {
totalDeletions += oldCount - newCount
}
}
for (const [line, newCount] of Array.from(newLineMap)) {
const oldCount = oldLineMap.get(line) || 0
if (newCount > oldCount) {
totalAdditions += newCount - oldCount
}
}
})
return { totalAdditions, totalDeletions }
}
function ClickableTableRow({
event,
children,
onRowClick,
}: {
event: OptimizationEvent
children: React.ReactNode
onRowClick: (eventId: string) => void
}) {
const handleRowClick = useCallback(
(e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest('a[href^="http"]')) {
return
}
onRowClick(event.trace_id)
},
[event.trace_id, onRowClick],
)
return (
<TableRow
key={event.id}
className="group cursor-pointer hover:bg-muted"
onClick={handleRowClick}
>
{children}
</TableRow>
)
}
export function OptimizationsTable({
initialEvents,
initialTotalCount,
availableRepositories,
accountPayload,
}: OptimizationsTableProps) {
const router = useRouter()
const [events, setEvents] = useState<OptimizationEvent[]>(initialEvents)
const [totalCount, setTotalCount] = useState(initialTotalCount)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [filters, setFilters] = useState<FilterState>({
search: "",
repositoryId: null,
status: "all",
eventType: "all",
reviewQuality: "all",
sortBy: "created_at_desc",
page: 1,
})
const pageSize = 10
const isInitialMount = useRef(true)
const debounceTimer = useRef<NodeJS.Timeout>(undefined)
const loadEvents = useCallback(async () => {
setIsLoading(true)
setError(null)
try {
const filter: Record<string, string | null | { not: null }> = {}
if (filters.repositoryId === "none") {
filter.repository_id = null
} else if (filters.repositoryId) {
filter.repository_id = filters.repositoryId
}
if (filters.status !== "all") {
filter.status = filters.status
}
if (filters.eventType !== "all") {
filter.event_type = filters.eventType
}
if (filters.reviewQuality !== "all") {
filter.review_quality = filters.reviewQuality
}
const [sortField, sortDirection] = filters.sortBy.split("_").reduce(
(acc, part, index, arr) => {
if (index === arr.length - 1 && (part === "asc" || part === "desc")) {
return [acc[0], part]
}
return [acc[0] ? `${acc[0]}_${part}` : part, acc[1]]
},
["", "desc"] as [string, string],
)
const sort: Record<string, "asc" | "desc"> = {
[sortField]: sortDirection as "asc" | "desc",
}
const data = await getAllOptimizationEvents({
payload: accountPayload,
search: filters.search,
filter,
sort,
page: filters.page,
pageSize,
})
type RawEvent = OptimizationEvent & {
repository?: { id: string; full_name?: string; name?: string } | null
}
const transformedEvents: OptimizationEvent[] = (data?.events || []).map(
(event: RawEvent) => ({
...event,
metadata: event.metadata as EventMetadata | null | undefined,
repository: event.repository
? {
id: event.repository.id,
full_name: event.repository.full_name || event.repository.name,
}
: null,
}),
)
setEvents(transformedEvents)
setTotalCount(data?.totalCount || 0)
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load events")
} finally {
setIsLoading(false)
}
}, [filters, accountPayload, pageSize])
// Load events when filters change (skip initial mount — server provided that data)
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false
return
}
if (debounceTimer.current) {
clearTimeout(debounceTimer.current)
}
const hasSearchChanged = filters.search !== ""
if (hasSearchChanged) {
debounceTimer.current = setTimeout(() => {
loadEvents()
}, 300)
} else {
loadEvents()
}
return () => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current)
}
}
}, [filters, loadEvents])
const handleRowClick = useCallback(
(traceId: string) => {
router.push(`/review-optimizations/${traceId}`)
},
[router],
)
const updateFilter = useCallback((key: keyof FilterState, value: string | number | null) => {
setFilters(prev => ({
...prev,
[key]: value,
...(key !== "page" && { page: 1 }),
}))
}, [])
const clearFilters = useCallback(() => {
setFilters({
search: "",
repositoryId: null,
status: "all",
eventType: "all",
reviewQuality: "all",
sortBy: "created_at_desc",
page: 1,
})
}, [])
const hasActiveFilters =
filters.search ||
filters.repositoryId !== null ||
filters.status !== "all" ||
filters.eventType !== "all" ||
filters.reviewQuality !== "all" ||
filters.sortBy !== "created_at_desc"
const totalPages = Math.ceil(totalCount / pageSize)
const handlePageChange = useCallback(
(newPage: number) => {
if (newPage >= 1 && newPage <= totalPages) {
updateFilter("page", newPage)
}
},
[totalPages, updateFilter],
)
const getSortIcon = useCallback(
(field: string) => {
if (filters.sortBy.startsWith(field)) {
return filters.sortBy.endsWith("_asc") ? (
<ArrowUp className="h-4 w-4" />
) : (
<ArrowDown className="h-4 w-4" />
)
}
return <ArrowUpDown className="h-4 w-4 opacity-50" />
},
[filters.sortBy],
)
const toggleSort = useCallback(
(field: string) => {
const newSort = filters.sortBy.startsWith(field)
? filters.sortBy === `${field}_desc`
? `${field}_asc`
: `${field}_desc`
: `${field}_desc`
updateFilter("sortBy", newSort)
},
[filters.sortBy, updateFilter],
)
const getSpeedupBadge = useCallback((speedup?: number, speedupPct?: number) => {
if (typeof speedup !== "number" || typeof speedupPct !== "number") return null
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max)
const x = clamp(speedup, 1, 300)
const t = (x - 1) / 299
const hue = 158
const lightness = 95 - t * (95 - 35)
const saturation = 45 + t * (75 - 45)
const textColor = lightness < 60 ? "#fff" : "#047857"
const borderLightness = lightness > 60 ? lightness - 8 : lightness + 8
const borderSaturation = saturation > 70 ? saturation - 10 : saturation + 10
const bgColor = `hsl(${hue}, ${saturation}%, ${lightness}%)`
const borderColor = `hsl(${hue}, ${borderSaturation}%, ${borderLightness}%)`
return (
<Badge
variant="default"
className="font-mono text-[11px] px-2 py-0.5 whitespace-nowrap font-medium"
style={{
backgroundColor: bgColor,
color: textColor,
border: `1px solid ${borderColor}`,
}}
>
{speedup.toFixed(2)}x ({speedupPct.toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, ",")}%)
</Badge>
)
}, [])
const getStatusBadge = useCallback((status?: string) => {
if (!status) return null
const variants: Record<string, { className: string; label: string }> = {
approved: {
className:
"bg-green-100 text-green-800 border-green-300 dark:bg-green-900/30 dark:text-green-100 dark:border-green-700",
label: "Approved",
},
rejected: {
className:
"bg-red-100 text-red-800 border-red-300 dark:bg-red-900/30 dark:text-red-100 dark:border-red-700",
label: "Rejected",
},
}
const variant = variants[status] || {
className: "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-100",
label: status,
}
return (
<Badge variant="secondary" className={variant.className}>
{variant.label}
</Badge>
)
}, [])
const getEventTypeBadge = useCallback((eventType?: string) => {
if (!eventType) return null
const variants: Record<string, { className: string; label: string }> = {
pr_created: {
className:
"bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-900/30 dark:text-blue-100 dark:border-blue-700",
label: "PR Created",
},
pr_merged: {
className:
"bg-purple-100 text-purple-800 border-purple-300 dark:bg-purple-900/30 dark:text-purple-100 dark:border-purple-700",
label: "PR Merged",
},
pr_closed: {
className:
"bg-orange-100 text-orange-800 border-orange-300 dark:bg-orange-900/30 dark:text-orange-100 dark:border-orange-700",
label: "PR Closed",
},
"no-pr": {
className:
"bg-gray-100 text-gray-800 border-gray-300 dark:bg-gray-800 dark:text-gray-100 dark:border-gray-600",
label: "Staged Changes",
},
}
const variant = variants[eventType] || {
className: "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-100",
label: eventType,
}
return (
<Badge variant="secondary" className={variant.className}>
{variant.label}
</Badge>
)
}, [])
return (
<div className="py-8 px-4">
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2">Review Optimizations</h1>
</div>
{/* Search and Filters */}
<div className="mb-6">
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder="Search by function name, file path, or repository name..."
value={filters.search}
onChange={e => updateFilter("search", e.target.value)}
className="pl-10 w-full"
/>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Filter className="h-4 w-4" />
<span className="hidden sm:inline">Filters:</span>
</div>
<Select
value={filters.repositoryId || "all"}
onValueChange={value =>
updateFilter(
"repositoryId",
value === "all" ? null : value === "none" ? "none" : value,
)
}
>
<SelectTrigger
className={`w-[180px] sm:w-[220px] ${
filters.repositoryId === null
? "text-muted-foreground [&>span]:text-muted-foreground"
: ""
}`}
>
<SelectValue placeholder="All Repositories" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Repositories</SelectItem>
<SelectItem value="none">Without Repository</SelectItem>
{availableRepositories.map(repo => (
<SelectItem key={repo.id} value={repo.id}>
{repo.full_name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filters.status} onValueChange={value => updateFilter("status", value)}>
<SelectTrigger
className={`w-[120px] sm:w-[150px] ${
filters.status === "all"
? "text-muted-foreground [&>span]:text-muted-foreground"
: ""
}`}
>
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Reviews</SelectItem>
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
</SelectContent>
</Select>
<Select
value={filters.eventType}
onValueChange={value => updateFilter("eventType", value)}
>
<SelectTrigger
className={`w-[120px] sm:w-[150px] ${
filters.eventType === "all"
? "text-muted-foreground [&>span]:text-muted-foreground"
: ""
}`}
>
<SelectValue placeholder="Event Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Status</SelectItem>
<SelectItem value="pr_created">PR Created</SelectItem>
<SelectItem value="pr_merged">PR Merged</SelectItem>
<SelectItem value="pr_closed">PR Closed</SelectItem>
<SelectItem value="no-pr">Staged Changes</SelectItem>
</SelectContent>
</Select>
<Select
value={filters.reviewQuality}
onValueChange={value => updateFilter("reviewQuality", value)}
>
<SelectTrigger
className={`w-[120px] sm:w-[150px] ${
filters.reviewQuality === "all"
? "text-muted-foreground [&>span]:text-muted-foreground"
: ""
}`}
>
<SelectValue placeholder="Quality" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Quality</SelectItem>
<SelectItem value="high">High</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="low">Low</SelectItem>
</SelectContent>
</Select>
<Select value={filters.sortBy} onValueChange={value => updateFilter("sortBy", value)}>
<SelectTrigger
className={`w-[140px] sm:w-[200px] ${
filters.sortBy === "created_at_desc"
? "text-muted-foreground [&>span]:text-muted-foreground"
: ""
}`}
>
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="created_at_desc">Newest</SelectItem>
<SelectItem value="created_at_asc">Oldest</SelectItem>
<SelectItem value="speedup_x_desc">Speedup (Highest)</SelectItem>
<SelectItem value="speedup_x_asc">Speedup (Lowest)</SelectItem>
<SelectItem value="review_quality_desc">Quality (High to Low)</SelectItem>
<SelectItem value="review_quality_asc">Quality (Low to High)</SelectItem>
</SelectContent>
</Select>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={clearFilters}
className="text-muted-foreground hover:text-foreground"
>
Clear
</Button>
)}
</div>
</div>
{error && (
<div className="mb-6 p-4 rounded-lg bg-destructive/10 border border-destructive/20">
<p className="text-destructive text-sm">Error: {error}</p>
<Button
variant="outline"
size="sm"
onClick={loadEvents}
className="mt-2"
disabled={isLoading}
>
Retry
</Button>
</div>
)}
<div className="rounded-lg border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[25%]">FUNCTION / FILE</TableHead>
<TableHead className="w-[18%]">REPOSITORY</TableHead>
<TableHead className="text-center">REVIEW</TableHead>
<TableHead className="text-center">STATUS</TableHead>
<TableHead
className="text-center cursor-pointer hover:bg-muted/50"
onClick={() => toggleSort("review_quality")}
>
<div className="flex items-center justify-center gap-1">
<span>QUALITY</span>
{getSortIcon("review_quality")}
</div>
</TableHead>
<TableHead
className="text-center cursor-pointer hover:bg-muted/50"
onClick={() => toggleSort("speedup_x")}
>
<div className="flex items-center justify-center gap-1">
<span>SPEEDUP</span>
{getSortIcon("speedup_x")}
</div>
</TableHead>
<TableHead className="text-center">CHANGES</TableHead>
<TableHead
className="text-right cursor-pointer hover:bg-muted/50"
onClick={() => toggleSort("created_at")}
>
<div className="flex items-center justify-end gap-1">
<span>CREATED</span>
{getSortIcon("created_at")}
</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableSkeleton />
) : events.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="py-12">
<div className="mx-auto max-w-lg text-center bg-muted/5 border border-dashed border-border rounded-xl px-6 py-10">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-muted/30 mb-4">
<Zap className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-base sm:text-lg font-semibold mb-2">
No optimizations to review yet
</h3>
<p className="text-xs sm:text-sm text-muted-foreground mb-3">
Run `codeflash --all with --staging-review` in a repository or install the VS
Code extension to trigger your first optimization review.
</p>
{hasActiveFilters ? (
<p className="text-xs sm:text-sm text-muted-foreground">
Filters are currently hiding resultsclear them to see everything.
</p>
) : (
<div className="flex flex-col sm:flex-row items-center justify-center gap-2 sm:gap-3 mt-4">
<a
href="/onboarding"
className="w-full sm:w-auto inline-flex items-center justify-center rounded-lg bg-primary px-4 py-2 text-xs sm:text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
View setup steps
</a>
<a
href="https://docs.codeflash.ai/editor-plugins/vscode"
target="_blank"
rel="noopener noreferrer"
className="w-full sm:w-auto inline-flex items-center justify-center rounded-lg border border-border px-4 py-2 text-xs sm:text-sm font-medium text-foreground hover:bg-muted/40 transition-colors"
>
Install VS Code extension
</a>
</div>
)}
</div>
</TableCell>
</TableRow>
) : (
events.map((event: OptimizationEvent) => {
let diffStats: { totalAdditions: number; totalDeletions: number } = {
totalAdditions: 0,
totalDeletions: 0,
}
if (
event.metadata &&
typeof event.metadata === "object" &&
event.metadata !== null &&
typeof event.metadata.diffContents === "object" &&
event.metadata.diffContents !== null
) {
const diffContentsRaw = event.metadata.diffContents
if (diffContentsRaw && typeof diffContentsRaw === "object") {
let valid = true
for (const value of Object.values(diffContentsRaw as object)) {
if (
!value ||
typeof value !== "object" ||
typeof (value as DiffContent).oldContent !== "string" ||
typeof (value as DiffContent).newContent !== "string"
) {
valid = false
break
}
}
if (valid) {
const diffContents = diffContentsRaw as Record<
string,
{ oldContent: string; newContent: string }
>
diffStats = calculateDiffStats(diffContents)
}
}
}
return (
<ClickableTableRow key={event.id} event={event} onRowClick={handleRowClick}>
<TableCell className="w-auto min-w-0">
<div className="flex items-start gap-3">
<FileCode2 className="h-4 w-4 text-muted-foreground mt-1 flex-shrink-0" />
<div className="flex-1 min-w-0 overflow-hidden">
<div className="font-mono text-sm font-medium truncate">
{event.function_name || "Unknown Function"}
</div>
<div className="text-xs text-muted-foreground truncate">
{event.file_path || "No file path"}
</div>
</div>
</div>
</TableCell>
<TableCell className="w-auto min-w-0">
<div className="flex items-center gap-3">
{event.repository ? (
<>
<div className="relative h-8 w-8 flex-shrink-0">
{event.repository.full_name && (
<Image
src={`https://github.com/${event.repository.full_name.split("/")[0]}.png`}
alt={event.repository.full_name}
fill
className="rounded-full object-cover"
onError={e => {
e.currentTarget.style.display = "none"
}}
/>
)}
</div>
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex items-center gap-1">
<span className="text-sm font-medium truncate">
{event.repository.full_name || "Unknown Repository"}
</span>
</div>
</div>
</>
) : (
<>
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center flex-shrink-0">
<Zap className="h-4 w-4 text-muted-foreground" />
</div>
<span className="text-sm text-muted-foreground">
Untracked repository
</span>
</>
)}
</div>
</TableCell>
<TableCell className="text-center">{getStatusBadge(event.status)}</TableCell>
<TableCell className="text-center">
{getEventTypeBadge(event.event_type)}
</TableCell>
<TableCell className="text-center">
<ReviewQualityBadge quality={event.review_quality} />
</TableCell>
<TableCell className="text-center">
{getSpeedupBadge(event.speedup_x, event.speedup_pct)}
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-2 flex-wrap">
{diffStats.totalAdditions > 0 && (
<Badge
variant="secondary"
className="bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-950/50 dark:text-emerald-400 dark:border-emerald-800 whitespace-nowrap font-medium"
>
+{diffStats.totalAdditions}
</Badge>
)}
{diffStats.totalDeletions > 0 && (
<Badge
variant="secondary"
className="bg-rose-50 text-rose-700 border-rose-200 dark:bg-rose-950/50 dark:text-rose-400 dark:border-rose-800 whitespace-nowrap font-medium"
>
-{diffStats.totalDeletions}
</Badge>
)}
{diffStats.totalAdditions === 0 && diffStats.totalDeletions === 0 && (
<span className="text-muted-foreground text-xs"></span>
)}
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Clock className="h-3 w-3 text-muted-foreground flex-shrink-0" />
<span className="text-sm text-muted-foreground whitespace-nowrap">
{formatDistanceToNow(new Date(event.created_at), {
addSuffix: true,
})}
</span>
</div>
</TableCell>
</ClickableTableRow>
)
})
)}
</TableBody>
</Table>
</div>
{!isLoading && totalPages > 1 && (
<div className="flex items-center justify-between mt-6">
<p className="text-sm text-muted-foreground">
Showing {(filters.page - 1) * pageSize + 1} to{" "}
{Math.min(filters.page * pageSize, totalCount)} of {totalCount} events
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={filters.page === 1}
onClick={() => handlePageChange(filters.page - 1)}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let pageNum: number
if (totalPages <= 5) {
pageNum = i + 1
} else if (filters.page <= 3) {
pageNum = i + 1
} else if (filters.page >= totalPages - 2) {
pageNum = totalPages - 4 + i
} else {
pageNum = filters.page - 2 + i
}
return (
<Button
key={i}
variant={filters.page === pageNum ? "default" : "outline"}
size="sm"
className="w-8 h-8 p-0"
onClick={() => handlePageChange(pageNum)}
>
{pageNum}
</Button>
)
})}
{totalPages > 5 && filters.page < totalPages - 2 && <span className="px-2">...</span>}
{totalPages > 5 && filters.page < totalPages - 2 && (
<Button
variant="outline"
size="sm"
className="w-8 h-8 p-0"
onClick={() => handlePageChange(totalPages)}
>
{totalPages}
</Button>
)}
</div>
<Button
variant="outline"
size="sm"
disabled={filters.page === totalPages}
onClick={() => handlePageChange(filters.page + 1)}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,22 @@
"use client"
import { RefreshCw } from "lucide-react"
export default function ReviewOptimizationsError({ reset }: { error: Error; reset: () => void }) {
return (
<div className="flex justify-center items-center h-[70vh]">
<div className="bg-red-50 text-red-800 p-6 sm:p-8 rounded-xl max-w-md border border-red-200">
<h3 className="text-base sm:text-lg font-medium mb-2 sm:mb-3">Something went wrong</h3>
<p className="mb-3 sm:mb-4 text-sm sm:text-base">
There was an error loading the review optimizations page.
</p>
<button
onClick={reset}
className="flex items-center gap-1 sm:gap-2 w-full justify-center px-3 sm:px-4 py-2 sm:py-2.5 bg-red-100 hover:bg-red-200 text-red-800 rounded-lg text-xs sm:text-sm font-medium transition-colors"
>
<RefreshCw size={14} className="sm:w-4 sm:h-4" /> Try Again
</button>
</div>
</div>
)
}

View file

@ -0,0 +1,59 @@
import { Skeleton } from "@/components/ui/skeleton"
export default function ReviewOptimizationsLoading() {
return (
<div className="py-8 px-4">
<div className="mb-8">
<Skeleton className="h-9 w-64 mb-2" />
</div>
{/* Filter bar skeleton */}
<div className="mb-6">
<div className="flex flex-wrap items-center gap-3">
<Skeleton className="h-10 flex-1 min-w-[200px] max-w-md rounded-md" />
<Skeleton className="h-10 w-[180px] rounded-md" />
<Skeleton className="h-10 w-[120px] rounded-md" />
<Skeleton className="h-10 w-[120px] rounded-md" />
<Skeleton className="h-10 w-[120px] rounded-md" />
<Skeleton className="h-10 w-[140px] rounded-md" />
</div>
</div>
{/* Table skeleton */}
<div className="rounded-lg border bg-card">
<div className="p-4 border-b">
<div className="flex gap-4">
<Skeleton className="h-4 w-[25%]" />
<Skeleton className="h-4 w-[18%]" />
<Skeleton className="h-4 w-[10%]" />
<Skeleton className="h-4 w-[10%]" />
<Skeleton className="h-4 w-[8%]" />
<Skeleton className="h-4 w-[10%]" />
<Skeleton className="h-4 w-[8%]" />
<Skeleton className="h-4 w-[11%]" />
</div>
</div>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="p-4 border-b last:border-0">
<div className="flex items-center gap-4">
<div className="w-[25%] space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
<div className="w-[18%] flex items-center gap-2">
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-4 w-24" />
</div>
<Skeleton className="h-6 w-[10%] rounded-full" />
<Skeleton className="h-6 w-[10%] rounded-full" />
<Skeleton className="h-6 w-[8%] rounded-full" />
<Skeleton className="h-6 w-[10%] rounded-full" />
<Skeleton className="h-6 w-[8%] rounded-full" />
<Skeleton className="h-4 w-[11%]" />
</div>
</div>
))}
</div>
</div>
)
}

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
"use client"
import { usePathname, useSearchParams } from "next/navigation"
import { useEffect } from "react"
import { type JSX, useEffect } from "react"
import { usePostHog } from "posthog-js/react"
export default function PostHogPageView(): JSX.Element | null {

View file

@ -1,175 +0,0 @@
import {
type AfterCallbackAppRoute,
type AppRouteHandlerFnContext,
getSession,
handleAuth,
handleCallback,
handleLogin,
handleLogout,
type Session,
} from "@auth0/nextjs-auth0"
import { type NextRequest, NextResponse } from "next/server"
import { createOrUpdateUser, hasCompletedOnboarding } from "@codeflash-ai/common"
import { trackUserLogin } from "@/lib/analytics/tracking"
import { cookies } from "next/headers"
import { APP_ROUTES } from "@/lib/types"
//In case we want to change some future variables to set redirect to marketing campaign
const LOGOUT_REDIRECT_URL =
process.env.CODEFLASH_LOGOUT_REDIRECT_URL ??
process.env.CODEFLASH_MARKETING_URL ??
"https://codeflash.ai"
// THIS IS THE KEY CHANGE - Your afterCallback was empty!
const afterCallback: AfterCallbackAppRoute = async (req: NextRequest, session: Session) => {
if (!session.user) {
return session
}
const user = session.user
console.log(`[Auth] Processing login for user: ${user.sub}`)
if (!user.sub || !user.nickname) {
console.error("[Auth] Missing required user fields")
return session
}
try {
// 1. SAVE TO DATABASE (moved from login page!)
console.log("[Auth] Saving user to database...")
await createOrUpdateUser(user.sub, user.nickname, user.email ?? null, user.name ?? null)
console.log("[Auth] User saved successfully")
// 2. TRACK LOGIN (moved from login page!)
await trackUserLogin({
userId: user.sub,
username: user.nickname,
email: user.email,
name: user.name,
})
// 3. CHECK ONBOARDING (moved from login page!)
const completedOnboarding = await hasCompletedOnboarding(user.sub)
console.log(`[Auth] Onboarding completed: ${completedOnboarding}`)
// 4. Decide where to redirect - Auth0 preserves returnTo from login
let intendedDestination = APP_ROUTES.BASE
// Try to get returnTo from multiple sources
const url = new URL(req.url)
// Method 1: From URL search params (direct from Auth0 redirect)
const returnToParam = url.searchParams.get("returnTo")
if (returnToParam) {
intendedDestination = returnToParam
console.log(`[Auth] Found returnTo in URL params: ${intendedDestination}`)
}
// Method 2: From state parameter (fallback)
const stateParam = url.searchParams.get("state")
if (stateParam && !returnToParam) {
try {
const state = JSON.parse(Buffer.from(stateParam, "base64").toString("utf-8"))
if (state.returnTo) {
intendedDestination = state.returnTo
console.log(`[Auth] Found returnTo in state: ${intendedDestination}`)
}
} catch (e) {
console.warn("[Auth] Failed to parse state:", e)
}
}
// check if the path is codeflash/auth/[token]
const isAuthPath =
intendedDestination.startsWith("/codeflash/auth") ||
intendedDestination.includes("/codeflash/auth")
console.log(`[Auth] isAuthPath: ${isAuthPath}`)
// Handle onboarding redirect
if (!completedOnboarding && !isAuthPath) {
session.returnTo = "/onboarding"
} else {
session.returnTo = intendedDestination
}
} catch (error) {
console.error("[Auth] Error in afterCallback:", error)
// Don't fail login even if our processing fails
session.returnTo = APP_ROUTES.BASE
}
return session
}
// Rest of your file stays mostly the same...
export const GET = handleAuth({
// Fixed login handler to preserve returnTo parameter
login: async (request: any, response: any) => {
console.log("Logging in")
try {
const req = request as NextRequest
const url = new URL(req.url)
const returnTo = url.searchParams.get("returnTo") || APP_ROUTES.BASE
console.log(`[Auth] Login with returnTo: ${returnTo}`)
return await handleLogin(req, response as AppRouteHandlerFnContext, {
returnTo,
authorizationParams: {
scope: "openid profile email offline_access",
},
})
} catch (error) {
console.error("Error logging in:", error)
return NextResponse.json({ error: "Failed to initiate login" }, { status: 500 })
}
},
// Your existing logout handler...
logout: async (request: any, response: any) => {
console.log("Logging out")
try {
return await handleLogout(request as NextRequest, response as AppRouteHandlerFnContext, {
returnTo: LOGOUT_REDIRECT_URL,
})
} catch (error) {
console.error("Error logging out:", error)
return NextResponse.redirect(LOGOUT_REDIRECT_URL)
}
},
// Updated callback handler
callback: async (req: any, res: any) => {
try {
const response = (await handleCallback(req as NextRequest, res as AppRouteHandlerFnContext, {
afterCallback, // NOW THIS DOES SOMETHING!
})) as NextResponse
const session = await getSession(req as NextRequest, response)
if (session != null) {
// Use the returnTo set by afterCallback
const returnTo = session.returnTo || APP_ROUTES.BASE
const isAbsolute = returnTo.includes(process.env.AUTH0_BASE_URL ?? "")
const redirectUrl = isAbsolute ? returnTo : `${process.env.AUTH0_BASE_URL}${returnTo}`
return NextResponse.redirect(redirectUrl, response)
} else {
return NextResponse.redirect(`${process.env.AUTH0_BASE_URL}/waitlist`, response)
}
} catch (error: any) {
console.error("Error in callback:", error)
// Your existing error handling...
if (error.status === 400 && error.message.search("allowlist-fail") !== -1) {
const re = /allowlist-fail\s(.*)\s(.*)\)/
const match = error.message.match(re)
if (match != null) {
const userId = match[1]
const userNickname = match[2]
return NextResponse.redirect(
`${process.env.AUTH0_BASE_URL}/waitlist?username=${userNickname}&userid=${userId}`,
)
}
}
// If error doesn't match any specific case, return error page
return NextResponse.redirect(`${process.env.AUTH0_BASE_URL}/login?error=callback_failed`)
}
},
})

View file

@ -57,7 +57,7 @@ function summarizeToolResult(toolName: string, result: string): string {
}
case "get_errors": {
if (result === "No errors in this trace.") return "No errors"
const count = lines.filter((l) => l.startsWith("[")).length
const count = lines.filter(l => l.startsWith("[")).length
return `Found ${count} errors`
}
case "get_llm_call_detail":
@ -92,7 +92,7 @@ async function processToolCalls(
}
const results = await Promise.all(
toolUseBlocks.map(async (block) => {
toolUseBlocks.map(async block => {
const result = await resolveToolCall(
block.name,
(block.input as Record<string, unknown>) ?? {},
@ -120,10 +120,7 @@ async function processToolCalls(
return results
}
function baseParams(
systemPrompt: string,
conversationMessages: Anthropic.MessageParam[],
) {
function baseParams(systemPrompt: string, conversationMessages: Anthropic.MessageParam[]) {
return {
model: "claude-opus-4-6" as const,
max_tokens: 32_000,
@ -154,10 +151,7 @@ export async function POST(request: NextRequest): Promise<Response> {
const { traceId, messages } = body
if (!traceId || !messages?.length) {
return Response.json(
{ error: "traceId and messages are required" },
{ status: 400 },
)
return Response.json({ error: "traceId and messages are required" }, { status: 400 })
}
const tracePrefix = traceId.substring(0, 33)
@ -168,7 +162,7 @@ export async function POST(request: NextRequest): Promise<Response> {
const indexed = indexTraceData(traceData)
const systemPrompt = buildSummaryPrompt(indexed)
const conversationMessages: Anthropic.MessageParam[] = messages.map((m) => ({
const conversationMessages: Anthropic.MessageParam[] = messages.map(m => ({
role: m.role,
content: m.content,
}))
@ -183,21 +177,25 @@ export async function POST(request: NextRequest): Promise<Response> {
try {
let toolRounds = 0
let emittedText = false
// eslint-disable-next-line no-constant-condition
while (true) {
enqueue(`data: ${JSON.stringify({ type: "status", message: toolRounds === 0 ? "Thinking…" : "Analyzing…" })}\n\n`)
enqueue(
`data: ${JSON.stringify({ type: "status", message: toolRounds === 0 ? "Thinking…" : "Analyzing…" })}\n\n`,
)
// Redact thinking blocks from prior rounds (each can be 10-50KB)
for (const msg of conversationMessages) {
if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue
for (const block of msg.content) {
if ((block as { type: string }).type === "thinking") {
(block as { thinking: string }).thinking = ""
;(block as { thinking: string }).thinking = ""
}
}
}
const messageStream = client.messages.stream(baseParams(systemPrompt, conversationMessages))
const messageStream = client.messages.stream(
baseParams(systemPrompt, conversationMessages),
)
const timeout = setTimeout(() => messageStream.abort(), ROUND_TIMEOUT_MS)
let response: Anthropic.Message
try {
@ -230,7 +228,7 @@ export async function POST(request: NextRequest): Promise<Response> {
if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue
for (const block of msg.content) {
if ((block as { type: string }).type === "thinking") {
(block as { thinking: string }).thinking = ""
;(block as { thinking: string }).thinking = ""
}
}
}
@ -252,9 +250,12 @@ export async function POST(request: NextRequest): Promise<Response> {
enqueue("data: [DONE]\n\n")
} catch (err) {
const message = err instanceof Anthropic.APIError
? `API error: ${err.status} ${err.message}`
: err instanceof Error ? err.message : "Stream error"
const message =
err instanceof Anthropic.APIError
? `API error: ${err.status} ${err.message}`
: err instanceof Error
? err.message
: "Stream error"
enqueue(`data: ${JSON.stringify({ error: message })}\n\n`)
} finally {
clearInterval(keepalive)

View file

@ -3,7 +3,8 @@ import { PrismaClient } from "@prisma/client"
const prisma = new PrismaClient()
export async function POST(request: NextRequest, { params }: { params: { trace_id: string } }) {
export async function POST(request: NextRequest, props: { params: Promise<{ trace_id: string }> }) {
const params = await props.params;
try {
const { trace_id } = params
const body = await request.json()

View file

@ -1,7 +1,9 @@
"use client"
import { getUserOrganizations } from "@/components/dashboard/action"
import { UserProfile } from "@auth0/nextjs-auth0/client"
import { setOrgCookie } from "./org-cookie-action"
import type { User as UserProfile } from "@auth0/nextjs-auth0/types"
import { useRouter } from "next/navigation"
import React, {
createContext,
useContext,
@ -44,6 +46,7 @@ export function ViewModeProvider({
children: React.ReactNode
user?: UserProfile
}) {
const router = useRouter()
const [mode, setMode] = useState<ViewMode>("personal")
const [loading, setIsLoading] = useState<boolean>(true)
const [orgs, setOrgs] = useState<Organization[]>([])
@ -67,6 +70,10 @@ export function ViewModeProvider({
const finalOrgs = fetchedOrgs || orgsRef.current
setLocalStorageMode(newMode, orgId)
// Sync org cookie so server components can read it
const cookieOrgId = newMode === "organization" && orgId ? orgId : null
await setOrgCookie(cookieOrgId)
if (newMode === "organization" && orgId) {
const org = finalOrgs.find(o => o.id === orgId)
if (org) {
@ -80,8 +87,11 @@ export function ViewModeProvider({
setMode("personal")
setCurrentOrg(null)
}
// Trigger server re-render so server components pick up the new cookie
router.refresh()
},
[user?.sub, setLocalStorageMode],
[user?.sub, setLocalStorageMode, router],
)
useEffect(() => {

View file

@ -4,7 +4,8 @@ import { redirect } from "next/navigation"
* Catch-all route for legacy /app/* URLs
* Redirects to the corresponding route without the /app prefix
*/
export default function LegacyAppCatchAll({ params }: { params: { slug: string[] } }) {
const newPath = `/${params.slug.join("/")}`
redirect(newPath)
export default async function LegacyAppCatchAll(props: { params: Promise<{ slug: string[] }> }) {
const params = await props.params;
const newPath = `/${params.slug.join("/")}`
redirect(newPath)
}

View file

@ -0,0 +1,17 @@
"use server"
import { cookies } from "next/headers"
export async function setOrgCookie(orgId: string | null) {
const cookieStore = await cookies()
if (orgId) {
cookieStore.set("currentOrganizationId", orgId, {
path: "/",
httpOnly: true,
sameSite: "lax",
maxAge: 60 * 60 * 24 * 365, // 1 year
})
} else {
cookieStore.delete("currentOrganizationId")
}
}

View file

@ -0,0 +1,77 @@
"use client"
import { useCallback, useMemo, useRef, useState } from "react"
import { CalendarDays, ChevronDown } from "lucide-react"
import { useRouter, useSearchParams } from "next/navigation"
import { useOutsideClick } from "@/components/hooks/useOutsideClick"
export function YearSelector({ selectedYear }: { selectedYear: number }) {
const router = useRouter()
const searchParams = useSearchParams()
const [isOpen, setIsOpen] = useState(false)
const dropdownRef = useOutsideClick(() => setIsOpen(false))
const currentYear = new Date().getFullYear()
const availableYears = useMemo(() => {
const baseYear = 2025
return Array.from(
{ length: Math.max(1, currentYear - baseYear + 1) },
(_, i) => baseYear + i,
).filter(year => year <= currentYear)
}, [currentYear])
const handleYearChange = useCallback(
(year: number) => {
setIsOpen(false)
const params = new URLSearchParams(searchParams.toString())
if (year === currentYear) {
params.delete("year")
} else {
params.set("year", String(year))
}
const query = params.toString()
router.push(query ? `?${query}` : "/dashboard", { scroll: false })
},
[router, searchParams, currentYear],
)
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-1 px-2 py-1 text-xs bg-background border border-border rounded-md hover:border-primary/50 transition-colors"
disabled={availableYears.length <= 1}
>
<CalendarDays size={12} className="text-muted-foreground" />
<span>{selectedYear}</span>
{availableYears.length > 1 && (
<ChevronDown
size={12}
className={`transition-transform text-muted-foreground ${isOpen ? "rotate-180" : ""}`}
/>
)}
</button>
{isOpen && availableYears.length > 1 && (
<div className="absolute right-0 z-10 mt-1 w-32 bg-card rounded-md shadow-lg overflow-hidden border border-border animate-in fade-in-50 slide-in-from-top-5">
<div className="py-1">
{availableYears.map(year => (
<button
key={year}
onClick={() => handleYearChange(year)}
className={`w-full px-3 py-1.5 text-left hover:bg-muted flex items-center ${selectedYear === year ? "bg-primary/10 text-primary font-medium" : ""}`}
>
<span className="w-4 h-4 mr-1.5 flex items-center justify-center">
{selectedYear === year && (
<span className="w-1.5 h-1.5 rounded-full bg-primary"></span>
)}
</span>
{year}
</button>
))}
</div>
</div>
)}
</div>
)
}

View file

@ -1,10 +1,10 @@
import { getSession } from "@auth0/nextjs-auth0"
import { auth0 } from "@/lib/auth0"
import { redirect } from "next/navigation"
import { ReactNode } from "react"
import { hasCompletedOnboarding } from "@codeflash-ai/common"
export default async function DashboardLayout({ children }: { children: ReactNode }) {
const session = await getSession()
const session = await auth0.getSession()
if (!session) return null
const completedOnboarding = await hasCompletedOnboarding(session.user.sub)

View file

@ -0,0 +1,5 @@
import { DashboardSkeleton } from "@/components/dashboard/DashboardSkeleton"
export default function DashboardLoading() {
return <DashboardSkeleton />
}

View file

@ -1,270 +1,72 @@
"use client"
import React, { useState, useMemo, useEffect, useCallback, memo, useRef } from "react"
import {
Lock,
Globe,
RefreshCw,
Zap,
Gauge,
FolderGit2,
BookOpen,
CalendarDays,
ChevronDown,
} from "lucide-react"
import { getDashboardData, RepositoryWithUsage } from "./action"
import { getUserIdAndUsername } from "@/app/utils/auth"
import { format, subDays } from "date-fns"
import { Suspense } from "react"
import { Lock, Globe, Zap, Gauge, FolderGit2, BookOpen } from "lucide-react"
import { getDashboardData } from "./action"
import { getAccountContext } from "@/lib/server/get-account-context"
import { ActiveUsersLeaderboard } from "@/components/dashboard/ActiveUsersLeaderboard"
import { CompactPullRequestActivityCard } from "@/components/dashboard/CompactPullRequestActivityCard"
import { DashboardErrorBoundary } from "@/components/dashboard/DashboardErrorBoundary"
import { MetricCard } from "@/components/dashboard/MetricCard"
import { OptimizationPRsTable } from "@/components/dashboard/OptimizationPRsTable"
import { DashboardSkeleton } from "@/components/dashboard/DashboardSkeleton"
import { useViewMode } from "../app/ViewModeContext"
import { useOutsideClick } from "@/components/hooks/useOutsideClick"
import { AccountPayload } from "@codeflash-ai/common"
import { YearSelector } from "./_components/YearSelector"
import { format, subDays } from "date-fns"
const ErrorDisplay = memo(({ error, onRetry }: { error: string; onRetry: () => void }) => (
<div className="flex justify-center items-center h-[70vh]">
<div className="bg-red-50 text-red-800 p-6 sm:p-8 rounded-xl max-w-md border border-red-200">
<h3 className="text-base sm:text-lg font-medium mb-2 sm:mb-3">Unable to Load Dashboard</h3>
<p className="mb-3 sm:mb-4 text-sm sm:text-base">{error}</p>
<button
onClick={onRetry}
className="flex items-center gap-1 sm:gap-2 w-full justify-center px-3 sm:px-4 py-2 sm:py-2.5 bg-red-100 hover:bg-red-200 text-red-800 rounded-lg text-xs sm:text-sm font-medium transition-colors"
>
<RefreshCw size={14} className="sm:w-4 sm:h-4" /> Try Again
</button>
</div>
</div>
))
ErrorDisplay.displayName = "ErrorDisplay"
function getDateRangeDisplay(): string {
const now = new Date()
const last30DaysStart = subDays(now, 30)
const startMonth = format(last30DaysStart, "MMMM")
const endMonth = format(now, "MMMM")
const startYear = format(last30DaysStart, "yyyy")
const endYear = format(now, "yyyy")
interface OptimizationStats {
totalAttempts: number
successfulAttempts: number
activeReposLast30Days: number
if (startMonth === endMonth && startYear === endYear) {
return `${startMonth} ${format(last30DaysStart, "d")}-${format(now, "d")}, ${startYear}`
}
if (startYear === endYear) {
return `${format(last30DaysStart, "MMMM d")} - ${format(now, "MMMM d")}, ${startYear}`
}
return `${format(last30DaysStart, "MMMM d, yyyy")} - ${format(now, "MMMM d, yyyy")}`
}
interface PrActivityData {
month: string
pr_created: number
pr_merged: number
pr_closed: number
}
interface ActiveUserData {
username: string
eventCount: number
avatarUrl: string
}
function Dashboard() {
const { currentOrg } = useViewMode()
export default async function DashboardPage({
searchParams,
}: {
searchParams: Promise<{ year?: string }>
}) {
const params = await searchParams
const currentYear = new Date().getFullYear()
const parsedYear = params.year ? parseInt(params.year, 10) : currentYear
const selectedYear = Number.isNaN(parsedYear) ? currentYear : parsedYear
const [repositories, setRepositories] = useState<RepositoryWithUsage[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const accountPayload = await getAccountContext()
const { stats, repos } = await getDashboardData(accountPayload, selectedYear)
const [optimizationStats, setOptimizationStats] = useState<OptimizationStats>({
totalAttempts: 0,
successfulAttempts: 0,
activeReposLast30Days: 0,
})
const repositories = Array.isArray(repos) ? repos : []
const privateRepos = repositories.filter(repo => repo?.is_private).length
const publicRepos = repositories.length - privateRepos
const totalRepos = repositories.length
const [prActivityData, setPrActivityData] = useState<PrActivityData[]>([])
const [selectedYear, setSelectedYear] = useState<number>(currentYear)
const [isYearDropdownOpen, setIsYearDropdownOpen] = useState(false)
const yearDropdownRef = useOutsideClick(() => setIsYearDropdownOpen(false))
const dateRangeDisplay = getDateRangeDisplay()
const [activeUsersData, setActiveUsersData] = useState<ActiveUserData[]>([])
const [optimizationsTrend, setOptimizationsTrend] = useState<number[]>([])
const [optimizationsTrendDates, setOptimizationsTrendDates] = useState<string[]>([])
const [successfulOptimizationsTrend, setSuccessfulOptimizationsTrend] = useState<number[]>([])
const [successfulOptimizationsTrendDates, setSuccessfulOptimizationsTrendDates] = useState<
string[]
>([])
const [accountPayload, setAccountPayload] = useState<AccountPayload | null>(null)
const [isMobile, setIsMobile] = useState<boolean>(false)
const dateValues = useMemo(() => {
const now = new Date()
const last30DaysStart = subDays(now, 30)
const startMonth = format(last30DaysStart, "MMMM")
const endMonth = format(now, "MMMM")
const startYear = format(last30DaysStart, "yyyy")
const endYear = format(now, "yyyy")
function getDateRangeDisplay(): string {
if (startMonth === endMonth && startYear === endYear) {
return `${startMonth} ${format(last30DaysStart, "d")}-${format(now, "d")}, ${startYear}`
}
if (startYear === endYear) {
return `${format(last30DaysStart, "MMMM d")} - ${format(now, "MMMM d")}, ${startYear}`
}
return `${format(last30DaysStart, "MMMM d, yyyy")} - ${format(now, "MMMM d, yyyy")}`
}
return { now, last30DaysStart, dateRangeDisplay: getDateRangeDisplay() }
}, [])
const repoCounts = useMemo(() => {
if (!Array.isArray(repositories) || repositories.length === 0) {
return { privateRepos: 0, publicRepos: 0, totalRepos: 0 }
}
const privateRepos = repositories.filter(repo => repo?.is_private).length
const publicRepos = repositories.length - privateRepos
return { privateRepos, publicRepos, totalRepos: repositories.length }
}, [repositories])
const availableYears = useMemo(() => {
const baseYear = 2025
return Array.from(
{ length: Math.max(1, currentYear - baseYear + 1) },
(_, i) => baseYear + i,
).filter(year => year <= currentYear)
}, [currentYear])
useEffect(() => {
let timeoutId: NodeJS.Timeout
const handleResize = () => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => setIsMobile(window.innerWidth < 640), 150)
}
if (typeof window !== "undefined") {
setIsMobile(window.innerWidth < 640)
window.addEventListener("resize", handleResize)
return () => {
clearTimeout(timeoutId)
window.removeEventListener("resize", handleResize)
}
}
}, [])
const currentOrgId = currentOrg?.id
const fetchingRef = useRef(false)
const fetchDashboardData = useCallback(async () => {
if (fetchingRef.current) return
fetchingRef.current = true
try {
setLoading(true)
setError(null)
const currentUser = await getUserIdAndUsername()
if (!currentUser?.userId || !currentUser?.username) {
throw new Error("User authentication data not found")
}
const payload: AccountPayload = currentOrgId
? { orgId: currentOrgId }
: { userId: currentUser.userId, username: currentUser.username }
// Store payload for the PR table component
setAccountPayload(payload)
const { stats, repos } = await getDashboardData(payload, selectedYear)
setRepositories(Array.isArray(repos) ? repos : [])
setOptimizationStats({
totalAttempts: stats.optimizations.total,
successfulAttempts: stats.optimizations.successful,
activeReposLast30Days: stats.activeReposLast30Days.length,
})
const optimizationValues = stats.optimizations.timeSeries.map(item => item.count)
const optimizationDates = stats.optimizations.timeSeries.map(item => item.date)
setOptimizationsTrend(optimizationValues)
setOptimizationsTrendDates(optimizationDates)
const successfulValues = stats.optimizations.successfulTimeSeries.map(item => item.count)
const successfulDates = stats.optimizations.successfulTimeSeries.map(item => item.date)
setSuccessfulOptimizationsTrend(successfulValues)
setSuccessfulOptimizationsTrendDates(successfulDates)
setPrActivityData(stats.pullRequests)
setActiveUsersData(stats.activeUsersLast30Days)
} catch (err) {
console.error("Dashboard data fetch error:", err)
setError("Failed to load dashboard data. Please try again later.")
setRepositories([])
setPrActivityData([])
setActiveUsersData([])
setOptimizationsTrend([])
setOptimizationsTrendDates([])
setSuccessfulOptimizationsTrend([])
setSuccessfulOptimizationsTrendDates([])
} finally {
setLoading(false)
fetchingRef.current = false
}
}, [selectedYear, currentOrgId])
useEffect(() => {
fetchDashboardData()
}, [fetchDashboardData])
const handleYearChange = useCallback((year: number) => {
setSelectedYear(year)
setIsYearDropdownOpen(false)
}, [])
if (loading) return <DashboardSkeleton />
if (error) return <ErrorDisplay error={error} onRetry={fetchDashboardData} />
const optimizationsTrend = stats.optimizations.timeSeries.map(item => item.count)
const optimizationsTrendDates = stats.optimizations.timeSeries.map(item => item.date)
const successfulOptimizationsTrend = stats.optimizations.successfulTimeSeries.map(
item => item.count,
)
const successfulOptimizationsTrendDates = stats.optimizations.successfulTimeSeries.map(
item => item.date,
)
return (
<div className="min-h-screen pb-8 py-6 sm:py-8 px-4 sm:px-6 max-w-[1400px] mx-auto">
<div className="mb-6 sm:mb-8">
<div className="flex items-center justify-between mb-2">
<h1 className="text-xl sm:text-2xl font-bold">Dashboard</h1>
<div className="relative" ref={yearDropdownRef}>
<button
onClick={() => setIsYearDropdownOpen(!isYearDropdownOpen)}
className="flex items-center gap-1 px-2 py-1 text-xs bg-background border border-border rounded-md hover:border-primary/50 transition-colors"
disabled={availableYears.length <= 1}
>
<CalendarDays size={12} className="text-muted-foreground" />
<span>{selectedYear}</span>
{availableYears.length > 1 && (
<ChevronDown
size={12}
className={`transition-transform text-muted-foreground ${isYearDropdownOpen ? "rotate-180" : ""}`}
/>
)}
</button>
{isYearDropdownOpen && availableYears.length > 1 && (
<div className="absolute right-0 z-10 mt-1 w-32 bg-card rounded-md shadow-lg overflow-hidden border border-border animate-in fade-in-50 slide-in-from-top-5">
<div className="py-1">
{availableYears.map(year => (
<button
key={year}
onClick={() => handleYearChange(year)}
className={`w-full px-3 py-1.5 text-left hover:bg-muted flex items-center ${selectedYear === year ? "bg-primary/10 text-primary font-medium" : ""}`}
>
<span className="w-4 h-4 mr-1.5 flex items-center justify-center">
{selectedYear === year && (
<span className="w-1.5 h-1.5 rounded-full bg-primary"></span>
)}
</span>
{year}
</button>
))}
</div>
</div>
)}
</div>
<Suspense>
<YearSelector selectedYear={selectedYear} />
</Suspense>
</div>
</div>
{repoCounts.totalRepos === 0 && (
{totalRepos === 0 && (
<div className="mb-6 sm:mb-8">
<div className="rounded-xl border border-dashed border-border bg-muted/10 px-5 py-4 sm:px-6 sm:py-5 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
@ -298,19 +100,17 @@ function Dashboard() {
</div>
)}
{/* Optimization PRs Table - Positioned at the top */}
{accountPayload && (
<div className="mb-6 sm:mb-8">
<OptimizationPRsTable payload={accountPayload} />
</div>
)}
{/* Optimization PRs Table */}
<div className="mb-6 sm:mb-8">
<OptimizationPRsTable payload={accountPayload} />
</div>
<div className="grid grid-cols-1 gap-3 sm:gap-5 mb-6 sm:mb-8">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-5">
<MetricCard
title="Optimization Attempts"
value={optimizationStats.totalAttempts}
icon={<Zap size={isMobile ? 16 : 20} />}
value={stats.optimizations.total}
icon={<Zap />}
gradientFrom="bg-gradient-to-br from-blue-500/20"
gradientTo="to-blue-600/20"
iconColor="text-blue-500"
@ -324,9 +124,9 @@ function Dashboard() {
/>
<MetricCard
title="Optimizations Found"
value={optimizationStats.successfulAttempts}
value={stats.optimizations.successful}
subtitle=""
icon={<Gauge size={isMobile ? 16 : 20} />}
icon={<Gauge />}
gradientFrom="bg-gradient-to-br from-emerald-500/20"
gradientTo="to-emerald-600/20"
iconColor="text-emerald-500"
@ -344,8 +144,8 @@ function Dashboard() {
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3 sm:gap-5">
<MetricCard
title="Total Repositories"
value={repoCounts.totalRepos}
icon={<BookOpen size={isMobile ? 16 : 20} />}
value={totalRepos}
icon={<BookOpen />}
gradientFrom="bg-gradient-to-br from-blue-500/20"
gradientTo="to-blue-600/20"
iconColor="text-blue-500"
@ -355,20 +155,20 @@ function Dashboard() {
<MetricCard
title="Active Repositories"
value={optimizationStats.activeReposLast30Days}
value={stats.activeReposLast30Days.length}
subtitle="last 30 days"
icon={<FolderGit2 size={isMobile ? 16 : 20} />}
icon={<FolderGit2 />}
gradientFrom="bg-gradient-to-br from-purple-500/20"
gradientTo="to-purple-600/20"
iconColor="text-purple-500"
timeText={dateValues.dateRangeDisplay}
timeText={dateRangeDisplay}
showChart={false}
/>
<MetricCard
title="Private Repositories"
value={repoCounts.privateRepos}
icon={<Lock size={isMobile ? 16 : 20} />}
value={privateRepos}
icon={<Lock />}
gradientFrom="bg-gradient-to-br from-amber-500/20"
gradientTo="to-amber-600/20"
iconColor="text-amber-500"
@ -378,8 +178,8 @@ function Dashboard() {
<MetricCard
title="Public Repositories"
value={repoCounts.publicRepos}
icon={<Globe size={isMobile ? 16 : 20} />}
value={publicRepos}
icon={<Globe />}
gradientFrom="bg-gradient-to-br from-violet-500/20"
gradientTo="to-violet-600/20"
iconColor="text-violet-500"
@ -391,27 +191,15 @@ function Dashboard() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-5 mb-6 sm:mb-8 h-96 md:h-[500px]">
<CompactPullRequestActivityCard
prData={prActivityData}
prData={stats.pullRequests}
selectedYear={selectedYear}
onYearChange={handleYearChange}
className="h-full"
/>
<div className="h-full">
<ActiveUsersLeaderboard leaderboardData={activeUsersData} />
<ActiveUsersLeaderboard leaderboardData={stats.activeUsersLast30Days} />
</div>
</div>
</div>
)
}
const MemoizedDashboard = memo(Dashboard)
MemoizedDashboard.displayName = "Dashboard"
export default function DashboardWrapper() {
return (
<DashboardErrorBoundary>
<MemoizedDashboard />
</DashboardErrorBoundary>
)
}

View file

@ -1,10 +1,10 @@
/* Scoped observability theme - only affects pages wrapped with .obs-v2 */
@import "../styles/obs-theme.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Scoped observability theme - only affects pages wrapped with .obs-v2 */
@import "../styles/obs-theme.css";
@layer base {
:root {
/* Background and foreground */

View file

@ -1,23 +1,20 @@
import { type JSX } from "react"
import type { Metadata } from "next"
import { Inter as FontSans, JetBrains_Mono } from "next/font/google"
import "./globals.css"
import { cn } from "@/lib/utils"
import { ThemeProvider } from "@/components/theme-provider"
import { UserProvider } from "@auth0/nextjs-auth0/client"
import { Auth0Provider } from "@auth0/nextjs-auth0"
import { Toaster } from "@/components/ui/toaster"
import { Toaster as SonnerToaster } from "sonner"
import { getSession } from "@auth0/nextjs-auth0"
import { auth0 } from "@/lib/auth0"
import Script from "next/script"
import { PHProvider } from "./providers"
import dynamic from "next/dynamic"
import PostHogPageView from "./PostHogPageView"
import { ViewModeProvider } from "./app/ViewModeContext"
import { PrivacyModeProvider } from "./app/PrivacyModeContext"
import { ConditionalLayout } from "@/components/conditional-layout"
const PostHogPageView = dynamic(async () => await import("./PostHogPageView"), {
ssr: false,
})
const fontSans = FontSans({
subsets: ["latin"],
variable: "--font-sans",
@ -40,7 +37,7 @@ export default async function RootLayout({
}: {
children: React.ReactNode
}): Promise<JSX.Element> {
const session = await getSession()
const session = await auth0.getSession()
let intercomSnippet: string = `var APP_ID = "ljxo1nzr";
(function(){var w=window;var ic=w.Intercom;if(typeof ic==="function"){ic('reattach_activator');ic('update',w.intercomSettings);}else{var d=document;var i=function(){i.c(arguments);};i.q=[];i.c=function(args){i.q.push(args);};w.Intercom=i;var l=function(){var s=d.createElement('script');s.type='text/javascript';s.async=true;s.src='https://widget.intercom.io/widget/' + APP_ID;var x=d.getElementsByTagName('script')[0];x.parentNode.insertBefore(s, x);};if(document.readyState==='complete'){l();}else if(w.attachEvent){w.attachEvent('onload',l);}else{w.addEventListener('load',l,false);}}})();
`
@ -100,7 +97,7 @@ export default async function RootLayout({
</head>
<body className={cn("min-h-screen bg-background font-sans antialiased", fontSans.variable, jetbrainsMono.variable)}>
<PostHogPageView />
<UserProvider>
<Auth0Provider>
<ThemeProvider
attribute="class"
defaultTheme="system"
@ -115,7 +112,7 @@ export default async function RootLayout({
<Toaster />
<SonnerToaster position="top-right" richColors />
</ThemeProvider>
</UserProvider>
</Auth0Provider>
</body>
</PHProvider>
</html>

View file

@ -1,6 +1,6 @@
"use client"
import { memo } from "react"
import React, { memo } from "react"
import {
Clock,
CheckCircle2,
@ -14,7 +14,7 @@ import { CandidateContent } from "./candidate-content"
import { RankingContent, SummaryContent } from "./ranking-content"
import type { TimelineSection } from "./timeline-types"
function getStatusIcon(status: string): JSX.Element {
function getStatusIcon(status: string): React.JSX.Element {
switch (status) {
case "success":
return <CheckCircle2 className="h-4 w-4 text-green-500" />

View file

@ -16,19 +16,21 @@ import { CopyButton } from "@/components/observability/copy-button"
import { ParsedResponseView } from "@/components/observability/parsed-response-view"
interface LLMCallDetailPageProps {
params: {
params: Promise<{
id: string
}
}>
}
export async function generateMetadata({ params }: LLMCallDetailPageProps): Promise<Metadata> {
export async function generateMetadata(props: LLMCallDetailPageProps): Promise<Metadata> {
const params = await props.params;
return {
title: `LLM Call ${params.id.substring(0, 8)} - Observability`,
description: "View LLM call details for prompt engineering analysis",
}
}
export default async function LLMCallDetailPage({ params }: LLMCallDetailPageProps) {
export default async function LLMCallDetailPage(props: LLMCallDetailPageProps) {
const params = await props.params;
// Fetch LLM call details
const llmCall = await prisma.llm_calls.findUnique({
where: { id: params.id },

View file

@ -69,7 +69,8 @@ const getModels = unstable_cache(
{ revalidate: 300 }, // 5 minutes
)
export default async function LLMCallsPage({ searchParams }: { searchParams: SearchParams }) {
export default async function LLMCallsPage(props: { searchParams: Promise<SearchParams> }) {
const searchParams = await props.searchParams;
try {
const page = parseInt(searchParams.page || "1")
const pageSize = 50

View file

@ -20,12 +20,13 @@ import { CopyButton } from "@/components/observability/copy-button"
export const revalidate = 60
interface TracePageProps {
params: {
params: Promise<{
trace_id: string
}
}>
}
export default async function TracePage({ params }: TracePageProps) {
export default async function TracePage(props: TracePageProps) {
const params = await props.params
const { trace_id } = params
// Use prefix matching (first 33 chars) to group multi-model calls that share the same base trace_id

View file

@ -79,7 +79,8 @@ const getTotalTracesCount = unstable_cache(
{ revalidate: 30 },
)
export default async function TracesPage({ searchParams }: { searchParams: SearchParams }) {
export default async function TracesPage(props: { searchParams: Promise<SearchParams> }) {
const searchParams = await props.searchParams;
try {
const page = parseInt(searchParams.page || "1")
const pageSize = 50

View file

@ -1,10 +1,10 @@
import { getSession } from "@auth0/nextjs-auth0"
import { auth0 } from "@/lib/auth0"
import { redirect } from "next/navigation"
import { hasCompletedOnboarding } from "@codeflash-ai/common"
import { APP_ROUTES } from "@/lib/types"
export default async function RootPage() {
const session = await getSession()
const session = await auth0.getSession()
if (!session) return null
const completedOnboarding = await hasCompletedOnboarding(session.user.sub)

View file

@ -1,4 +1,5 @@
"use client"
import { type JSX } from "react"
import posthog from "posthog-js"
import { PostHogProvider } from "posthog-js/react"

View file

@ -1,6 +1,6 @@
// cf-webapp/src/app/subscribe/pro/page.tsx
"use server"
import { getSession } from "@auth0/nextjs-auth0"
import { auth0 } from "@/lib/auth0"
import { redirect } from "next/navigation"
import { createCheckoutSession } from "@codeflash-ai/common"
import * as Sentry from "@sentry/nextjs"
@ -8,9 +8,10 @@ import * as Sentry from "@sentry/nextjs"
interface SearchParams {
period?: "monthly" | "yearly"
}
export default async function ProSubscribePage({ searchParams }: { searchParams: SearchParams }) {
export default async function ProSubscribePage(props: { searchParams: Promise<SearchParams> }) {
const searchParams = await props.searchParams;
const period: "monthly" | "yearly" = searchParams.period || "monthly"
const session = await getSession()
const session = await auth0.getSession()
if (!session) return null

View file

@ -2,19 +2,20 @@ import { PrismaClient } from "@prisma/client"
import { notFound } from "next/navigation"
import Link from "next/link"
import { ExperimentMetadata } from "@/lib/types" // Your defined types
import MonacoDiffViewer from "@/components/trace/monaco-diff-viewer" // The client component
import MonacoDiffViewer from "@/components/trace/monaco-diff-viewer"
import { Metadata } from "next" // For Next.js metadata API
import { getSession } from "@auth0/nextjs-auth0"
import { auth0 } from "@/lib/auth0"
import { isTeamMember } from "@/app/utils/auth"
interface TraceDetailsPageProps {
params: {
params: Promise<{
trace_id: string
}
}>
}
const prisma = new PrismaClient()
// Function to generate dynamic metadata (e.g., page title)
export async function generateMetadata({ params }: TraceDetailsPageProps): Promise<Metadata> {
export async function generateMetadata(props: TraceDetailsPageProps): Promise<Metadata> {
const params = await props.params
const { trace_id } = params
// Optionally fetch minimal data for title generation to avoid over-fetching
@ -57,7 +58,8 @@ export async function generateMetadata({ params }: TraceDetailsPageProps): Promi
}
// The main page component
export default async function TraceDetailsPage({ params }: TraceDetailsPageProps) {
export default async function TraceDetailsPage(props: TraceDetailsPageProps) {
const params = await props.params
const { trace_id } = params
if (!trace_id) {
@ -65,7 +67,7 @@ export default async function TraceDetailsPage({ params }: TraceDetailsPageProps
notFound()
}
const session = await getSession()
const session = await auth0.getSession()
if (!session?.user) return null
// Check team member access - only team members can view traces

View file

@ -1,11 +1,11 @@
"use server"
import { getSession, Session } from "@auth0/nextjs-auth0"
import { auth0 } from "@/lib/auth0"
import { cache } from "react"
import { isTeamMemberCheck } from "@/lib/team-members"
const getCachedSession = cache(async (): Promise<Session | null | undefined> => {
return getSession()
const getCachedSession = cache(async () => {
return auth0.getSession()
})
export async function getUserId(): Promise<string | null> {
@ -46,7 +46,7 @@ export async function requireTeamMember(): Promise<void> {
}
}
export const getAuthenticatedTeamSession = cache(async (): Promise<Session | null> => {
export const getAuthenticatedTeamSession = cache(async () => {
const session = await getCachedSession()
if (!session?.user) {

View file

@ -1,7 +1,7 @@
// components/Editor/monaco-diff-editor-github.tsx
"use client"
import React, { useState, useEffect, useRef, useCallback } from "react"
import React, { type JSX, useState, useEffect, useRef, useCallback } from "react"
import { Editor, DiffEditor } from "@monaco-editor/react"
import type { editor } from "monaco-editor"
import ReactMarkdown from "react-markdown"

View file

@ -7,6 +7,7 @@ import { Breadcrumb } from "./dashboard/bread-crumb"
import { AnnouncementTicker } from "@/components/announcements/announcement-ticker"
import { TOP_BAR_ANNOUNCEMENT } from "@/config/announcements"
import { cn } from "@/lib/utils"
import type { User } from "@auth0/nextjs-auth0/types"
const HIDDEN_PAGES = ["/onboarding", "/codeflash/auth", "/login", "/codeflash/auth/callback"]
@ -15,7 +16,7 @@ export function ConditionalLayout({
user,
}: {
children: React.ReactNode
user?: { sub?: string } | null
user?: User | null
}) {
const pathname = usePathname()
const [isAnnouncementVisible, setIsAnnouncementVisible] = useState(true)

View file

@ -1,6 +1,3 @@
"use client"
import React from "react"
import Image from "next/image"
import { Users, Crown } from "lucide-react"
@ -9,10 +6,10 @@ interface ActiveUsersLeaderboardProps {
className?: string
}
export const ActiveUsersLeaderboard: React.FC<ActiveUsersLeaderboardProps> = ({
export function ActiveUsersLeaderboard({
leaderboardData,
className,
}) => {
}: ActiveUsersLeaderboardProps) {
const safeLeaderboardData = Array.isArray(leaderboardData) ? leaderboardData : []
const needsScrollbar = safeLeaderboardData.length > 12

View file

@ -9,9 +9,19 @@ import {
GitPullRequestClosed,
GitPullRequest,
} from "lucide-react"
import { PullRequestActivityBarChart } from "./PullRequestActivityBarChart"
import { useRouter, useSearchParams } from "next/navigation"
import dynamic from "next/dynamic"
import { Skeleton } from "@/components/ui/skeleton"
import { useOutsideClick } from "../hooks/useOutsideClick"
const PullRequestActivityBarChart = dynamic(
() => import("./PullRequestActivityBarChart").then(mod => mod.PullRequestActivityBarChart),
{
ssr: false,
loading: () => <Skeleton className="h-full w-full rounded-md" />,
},
)
interface CompactPullRequestActivityCardProps {
prData: Array<{
month: string
@ -20,7 +30,7 @@ interface CompactPullRequestActivityCardProps {
pr_closed: number
}>
selectedYear: number
onYearChange: (year: number) => void
onYearChange?: (year: number) => void
className?: string
}
@ -30,7 +40,19 @@ export const CompactPullRequestActivityCard: React.FC<CompactPullRequestActivity
onYearChange,
className,
}) => {
const router = useRouter()
const searchParams = useSearchParams()
const [isYearDropdownOpen, setIsYearDropdownOpen] = useState(false)
const handleYearChange = (year: number) => {
if (onYearChange) {
onYearChange(year)
} else {
const params = new URLSearchParams(searchParams.toString())
params.set("year", String(year))
router.push(`?${params.toString()}`)
}
}
const yearDropdownRef = useOutsideClick(() => setIsYearDropdownOpen(false))
const safePrData = Array.isArray(prData) ? prData : []
@ -82,7 +104,7 @@ export const CompactPullRequestActivityCard: React.FC<CompactPullRequestActivity
<button
key={year}
onClick={() => {
onYearChange(year)
handleYearChange(year)
setIsYearDropdownOpen(false)
}}
className={`w-full px-3 py-1.5 text-left hover:bg-muted flex items-center ${selectedYear === year ? "bg-primary/10 text-primary font-medium" : ""}`}

View file

@ -1,13 +1,11 @@
"use client"
import React from "react"
import type { FC } from "react"
import { Skeleton } from "@/components/ui/skeleton"
/**
* Skeleton loader for MetricCard component
* Mimics the structure of the actual MetricCard with icon, title, value, and optional chart
*/
const MetricCardSkeleton: React.FC<{ showChart?: boolean }> = ({ showChart = true }) => (
const MetricCardSkeleton: FC<{ showChart?: boolean }> = ({ showChart = true }) => (
<div className="bg-card rounded-xl border border-border p-4 h-full">
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
@ -38,7 +36,7 @@ const MetricCardSkeleton: React.FC<{ showChart?: boolean }> = ({ showChart = tru
* Skeleton loader for Pull Request Activity Card
* Mimics the PR activity chart card structure
*/
const PullRequestActivityCardSkeleton: React.FC = () => (
const PullRequestActivityCardSkeleton: FC = () => (
<div className="bg-card rounded-xl border border-border p-4 h-full flex flex-col">
<div className="flex items-center justify-between mb-3">
<div>
@ -72,7 +70,7 @@ const PullRequestActivityCardSkeleton: React.FC = () => (
* Skeleton loader for Active Users Leaderboard
* Mimics the leaderboard structure with user rows
*/
const ActiveUsersLeaderboardSkeleton: React.FC = () => (
const ActiveUsersLeaderboardSkeleton: FC = () => (
<div className="bg-card rounded-xl border border-border p-4 h-full flex flex-col">
<div className="mb-3">
<Skeleton className="h-5 w-40 mb-1" />
@ -105,7 +103,7 @@ const ActiveUsersLeaderboardSkeleton: React.FC = () => (
* Displays skeleton placeholders matching the full dashboard layout
* Used while dashboard data is being fetched
*/
export const DashboardSkeleton: React.FC = () => {
export const DashboardSkeleton: FC = () => {
return (
<div className="h-screen py-6 sm:py-8 px-4 sm:px-6 max-w-[1400px] mx-auto">
{/* Header skeleton */}

View file

@ -1,7 +1,13 @@
"use client"
import React, { useMemo } from "react"
import { SparkLineChart } from "./SparkLineChart"
import { useMemo } from "react"
import dynamic from "next/dynamic"
import { Skeleton } from "@/components/ui/skeleton"
const SparkLineChart = dynamic(() => import("./SparkLineChart").then(mod => mod.SparkLineChart), {
ssr: false,
loading: () => <Skeleton className="h-[60px] w-full rounded-md" />,
})
interface MetricCardProps {
title: string
@ -70,11 +76,9 @@ export const MetricCard: React.FC<MetricCardProps> = ({
</div>
</div>
<div
className={`w-8 h-8 sm:w-10 sm:h-10 rounded-lg sm:rounded-xl ${gradientFrom} ${gradientTo} flex items-center justify-center ${iconColor}`}
className={`w-8 h-8 sm:w-10 sm:h-10 rounded-lg sm:rounded-xl ${gradientFrom} ${gradientTo} flex items-center justify-center ${iconColor} [&_svg]:w-4 [&_svg]:h-4 sm:[&_svg]:w-5 sm:[&_svg]:h-5`}
>
{React.cloneElement(icon as React.ReactElement, {
size: typeof window !== "undefined" && window.innerWidth < 640 ? 16 : 20,
})}
{icon}
</div>
</div>

View file

@ -27,7 +27,7 @@ import {
Zap,
BarChart3,
} from "lucide-react"
import { UserProfile } from "@auth0/nextjs-auth0/client"
import type { User as UserProfile } from "@auth0/nextjs-auth0/types"
import { SignOut } from "../ui/SignOut"
import { useViewMode } from "@/app/app/ViewModeContext"
import { usePrivacyMode } from "@/app/app/PrivacyModeContext"

View file

@ -3,7 +3,7 @@
import * as React from "react"
import { type JSX, useEffect, useState } from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import { type ThemeProviderProps } from "next-themes/dist/types"
import { type ThemeProviderProps } from "next-themes"
export function ThemeProvider({ children, ...props }: ThemeProviderProps): JSX.Element {
const [isClient, setIsClient] = useState(false)

View file

@ -6,8 +6,8 @@ import { type JSX } from "react"
export function SignOut(): JSX.Element {
return (
/* eslint-disable-next-line @next/next/no-html-link-for-pages */
<a href="/api/auth/logout">
<a href="/auth/logout">
<Button
variant="ghost"
className="w-full justify-start text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20"

View file

@ -26,7 +26,7 @@ export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}

View file

@ -21,14 +21,14 @@ interface FormFieldContextValue<
> {
name: TName
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
@ -37,7 +37,7 @@ const FormField = <
)
}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
@ -45,7 +45,7 @@ const useFormField = () => {
const fieldState = getFieldState(fieldContext.name, formState)
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
@ -65,7 +65,7 @@ const useFormField = () => {
interface FormItemContextValue {
id: string
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue)
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
@ -142,7 +142,7 @@ const FormMessage = React.forwardRef<
const { error, formMessageId } = useFormField()
const body = error != null ? String(error?.message) : children
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
if (!body) {
return null
}

View file

@ -63,7 +63,7 @@ SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayNam
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
// eslint-disable-next-line react/prop-types
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content

View file

@ -8,7 +8,7 @@ import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
// eslint-disable-next-line react/prop-types
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}

View file

@ -135,7 +135,7 @@ function dispatch(action: Action): void {
type Toast = Omit<ToasterToast, "id">
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
function toast({ ...props }: Toast) {
const id = genId()

View file

@ -1,6 +1,10 @@
import * as Sentry from "@sentry/nextjs"
export function register() {
// Sentry initialization is now handled by dedicated config files:
// - sentry.server.config.ts for server-side
// - sentry.client.config.ts for client-side
// This prevents duplicate initialization issues with Sentry v9
}
export const onRequestError = Sentry.captureRequestError

View file

@ -0,0 +1,119 @@
import { Auth0Client } from "@auth0/nextjs-auth0/server"
import { createOrUpdateUser, hasCompletedOnboarding } from "@codeflash-ai/common"
import { trackUserLogin } from "@/lib/analytics/tracking"
import { APP_ROUTES } from "@/lib/types"
import { NextResponse } from "next/server"
const LOGOUT_REDIRECT_URL =
process.env.CODEFLASH_LOGOUT_REDIRECT_URL ??
process.env.CODEFLASH_MARKETING_URL ??
"https://codeflash.ai"
function redirectTo(path: string): NextResponse {
const baseUrl = process.env.APP_BASE_URL || process.env.AUTH0_BASE_URL || "http://localhost:3000"
return NextResponse.redirect(new URL(path, baseUrl))
}
// Auth0 v4 expects AUTH0_DOMAIN; derive from v3's AUTH0_ISSUER_BASE_URL if needed
const auth0Domain =
process.env.AUTH0_DOMAIN ??
process.env.AUTH0_ISSUER_BASE_URL?.replace(/^https?:\/\//, "")
export const auth0 = new Auth0Client({
domain: auth0Domain,
authorizationParameters: {
scope: "openid profile email offline_access",
},
signInReturnToPath: APP_ROUTES.BASE,
async beforeSessionSaved(session, idToken) {
// Decode the ID token to get claims like nickname, name, picture
if (idToken) {
try {
const payload = JSON.parse(Buffer.from(idToken.split(".")[1], "base64url").toString())
return {
...session,
user: {
...session.user,
nickname: payload.nickname ?? session.user.nickname,
name: payload.name ?? session.user.name,
picture: payload.picture ?? session.user.picture,
},
}
} catch {
// If decoding fails, return session as-is
}
}
return session
},
async onCallback(error, context, session) {
if (error) {
console.error("[Auth] Error in callback:", error)
const errorMessage = error.message || ""
if (errorMessage.includes("allowlist-fail")) {
const re = /allowlist-fail\s(.*)\s(.*)\)/
const match = errorMessage.match(re)
if (match != null) {
const userId = match[1]
const userNickname = match[2]
return redirectTo(`/waitlist?username=${userNickname}&userid=${userId}`)
}
}
return redirectTo("/login?error=callback_failed")
}
if (!session) {
return redirectTo("/waitlist")
}
const user = session.user
console.log(`[Auth] Processing login for user: ${user.sub}`)
if (!user.sub || !user.nickname) {
console.error("[Auth] Missing required user fields")
return redirectTo(context.returnTo || APP_ROUTES.BASE)
}
try {
// Save user to database
console.log("[Auth] Saving user to database...")
await createOrUpdateUser(user.sub, user.nickname, user.email ?? null, user.name ?? null)
console.log("[Auth] User saved successfully")
// Track login
await trackUserLogin({
userId: user.sub,
username: user.nickname,
email: user.email,
name: user.name,
})
// Check onboarding
const completedOnboarding = await hasCompletedOnboarding(user.sub)
console.log(`[Auth] Onboarding completed: ${completedOnboarding}`)
const intendedDestination = context.returnTo || APP_ROUTES.BASE
// Check if the path is codeflash/auth/[token]
const isAuthPath =
intendedDestination.startsWith("/codeflash/auth") ||
intendedDestination.includes("/codeflash/auth")
if (!completedOnboarding && !isAuthPath) {
return redirectTo("/onboarding")
}
return redirectTo(intendedDestination)
} catch (err) {
console.error("[Auth] Error in onCallback:", err)
return redirectTo(context.returnTo || APP_ROUTES.BASE)
}
},
routes: {
login: "/auth/login",
logout: "/auth/logout",
callback: "/auth/callback",
},
appBaseUrl: process.env.APP_BASE_URL || process.env.AUTH0_BASE_URL,
})

View file

@ -0,0 +1,3 @@
// Empty shim for Node.js modules that web-tree-sitter tries to import in the browser.
// Turbopack equivalent of webpack's resolve.fallback: { module: false }
module.exports = {}

View file

@ -0,0 +1,32 @@
import { cache } from "react"
import { cookies } from "next/headers"
import { auth0 } from "@/lib/auth0"
import { getUserOrganizations } from "@/components/dashboard/action"
import type { AccountPayload } from "@codeflash-ai/common"
/**
* Server-side utility to determine the current account context (personal or org).
* Reads the auth session + org cookie to build an AccountPayload for data fetching.
* Cached per request via React `cache()`.
*/
export const getAccountContext = cache(async (): Promise<AccountPayload> => {
const session = await auth0.getSession()
if (!session?.user?.sub || !session?.user?.nickname) {
throw new Error("User session not found or incomplete")
}
const cookieStore = await cookies()
const orgId = cookieStore.get("currentOrganizationId")?.value
if (orgId) {
// Validate user is a member of this org
const result = await getUserOrganizations(session.user.sub)
if (result.success && result.organizations?.some(org => org.id === orgId)) {
return { orgId }
}
// Invalid org cookie — fall through to personal mode
}
return { userId: session.user.sub, username: session.user.nickname }
})

View file

@ -1,30 +1,36 @@
import { getSession } from "@auth0/nextjs-auth0/edge"
import { type NextRequest, NextResponse } from "next/server"
import { auth0 } from "@/lib/auth0"
import { isTeamMemberCheck } from "@/lib/team-members"
export default async function middleware(req: NextRequest) {
export async function proxy(req: NextRequest) {
// Let Auth0 handle auth routes (/auth/login, /auth/callback, /auth/logout, etc.)
const authRes = await auth0.middleware(req)
const { pathname, origin, search } = req.nextUrl
// Skip auth check for certain paths
// For auth routes, return the Auth0 response directly
if (pathname.startsWith("/auth")) {
return authRes
}
// Skip auth check for public/static paths
const ignorePaths = [
"/api/auth",
"/_next",
"/favicon.ico",
"/manifest.json",
"/login",
"/onboarding",
"/api/healthcheck",
]
if (ignorePaths.some(p => pathname.startsWith(p))) {
return NextResponse.next()
return authRes
}
const res = NextResponse.next()
const session = await getSession(req, res)
const session = await auth0.getSession(req)
// Handle unauthenticated user
if (!session?.user) {
if (pathname.startsWith("/api/") || pathname === "/api") {
// Return JSON error for actual API routes (must have /api/ or be exactly /api)
return NextResponse.json(
{
error: "not_authenticated",
@ -36,7 +42,7 @@ export default async function middleware(req: NextRequest) {
// Redirect to login for page routes with returnTo
const returnTo = encodeURIComponent(pathname + search)
const loginUrl = new URL(`/login?returnTo=${returnTo}`, origin)
const loginUrl = new URL(`/auth/login?returnTo=${returnTo}`, origin)
return NextResponse.redirect(loginUrl)
}
@ -46,30 +52,9 @@ export default async function middleware(req: NextRequest) {
}
}
return res
return authRes
}
export const config = {
matcher: [
"/",
"/app/:path*",
"/trace/:path*",
"/billing",
"/billing/:path*",
"/apikeys",
"/apikeys/:path*",
"/repositories",
"/repositories/:path*",
"/review-optimizations",
"/review-optimizations/:path*",
"/getting-started",
"/getting-started/:path*",
"/dashboard",
"/dashboard/:path*",
"/observability",
"/observability/:path*",
"/members",
"/members/:path*",
"/subscribe/:path*",
],
matcher: ["/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)"],
}

View file

@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "es6",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@ -11,7 +15,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@ -19,9 +23,20 @@
}
],
"paths": {
"@/*": ["./src/*"]
"@/*": [
"./src/*"
]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}