Compare commits

...

9 Commits

Author SHA1 Message Date
ZhenYi
32bd760b77 chore: update package.json dependencies and index.html
Update project dependencies and enhance index.html with
meta tags and link references.
2026-05-14 21:51:05 +08:00
ZhenYi
df4cf55b07 feat: update App.tsx with project features
Add project-related feature components including issues and
project management integration.
2026-05-14 21:50:47 +08:00
ZhenYi
826fa1226a feat: enhance MePage and add layout improvements
Add activity stats display to MePage and include additional
layout enhancements for consistent page structure.
2026-05-14 21:50:31 +08:00
ZhenYi
9981664731 feat(me): add ActivityTimeline and NotificationList components
Add ActivityTimeline component with user activity display and
NotificationList for user notifications.
2026-05-14 21:50:18 +08:00
ZhenYi
e64dc94d29 feat: add IssuesPage with tabs and kanban board
Implement issues listing page with tab navigation (All/To Do/In Progress/Done)
and kanban board view using TanStack Table. Connect to API endpoints with
pagination and filtering support.
2026-05-14 21:50:04 +08:00
ZhenYi
aaf518a66c feat: add Vite configuration with aliases and proxy
Add path aliases (@/* -> src/*) and development proxy
configuration for API routing.
2026-05-14 21:49:47 +08:00
ZhenYi
f4397256bd refactor: remove unused Redux store hooks
Delete obsolete Redux store configuration files as part of
the Zustand migration. These files are no longer referenced
after the store refactor.
2026-05-14 21:49:37 +08:00
ZhenYi
a1d245a767 Enable RSA support in russh 2026-05-14 21:45:05 +08:00
ZhenYi
a3ecf0c88b Separate SSH key probe from authentication 2026-05-14 21:44:55 +08:00
17 changed files with 561 additions and 198 deletions

5
Cargo.lock generated
View File

@ -4676,6 +4676,7 @@ dependencies = [
"ed25519-dalek 3.0.0-pre.6", "ed25519-dalek 3.0.0-pre.6",
"hex", "hex",
"hmac 0.13.0", "hmac 0.13.0",
"num-bigint-dig",
"p256 0.14.0-rc.7", "p256 0.14.0-rc.7",
"p384 0.14.0-rc.7", "p384 0.14.0-rc.7",
"p521", "p521",
@ -5989,6 +5990,7 @@ dependencies = [
"num-iter", "num-iter",
"num-traits", "num-traits",
"rand 0.8.5", "rand 0.8.5",
"serde",
"smallvec", "smallvec",
"zeroize", "zeroize",
] ]
@ -8237,6 +8239,7 @@ dependencies = [
"rand_core 0.10.0", "rand_core 0.10.0",
"sha2 0.11.0", "sha2 0.11.0",
"signature 3.0.0-rc.10", "signature 3.0.0-rc.10",
"spki 0.8.0-rc.4",
"zeroize", "zeroize",
] ]
@ -8292,12 +8295,14 @@ dependencies = [
"pageant", "pageant",
"pbkdf2 0.12.2", "pbkdf2 0.12.2",
"pbkdf2 0.13.0", "pbkdf2 0.13.0",
"pkcs1 0.8.0-rc.4",
"pkcs5", "pkcs5",
"pkcs8 0.11.0-rc.11", "pkcs8 0.11.0-rc.11",
"polyval 0.7.1", "polyval 0.7.1",
"rand 0.10.1", "rand 0.10.1",
"rand_core 0.10.0", "rand_core 0.10.0",
"ring", "ring",
"rsa 0.10.0-rc.16",
"russh-cryptovec", "russh-cryptovec",
"russh-util", "russh-util",
"salsa20", "salsa20",

View File

@ -106,7 +106,7 @@ prost-build = "0.14.3"
qdrant-client = "1.17.0" qdrant-client = "1.17.0"
prost-types = "0.14.3" prost-types = "0.14.3"
rand = "0.10.0" rand = "0.10.0"
russh = { version = "0.60.2", default-features = false, features = ["ring"] } russh = { version = "0.60.2", default-features = false, features = ["ring", "rsa"] }
hmac = { version = "0.13" } hmac = { version = "0.13" }
hkdf = "0.13.0" hkdf = "0.13.0"
sha1_smol = "1.0.1" sha1_smol = "1.0.1"

View File

@ -5,6 +5,7 @@
<link href="/logo.png" rel="icon" type="image/svg+xml"/> <link href="/logo.png" rel="icon" type="image/svg+xml"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/> <meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>GitData.AI</title> <title>GitData.AI</title>
<link rel="preload" href="/@fs/node_modules/@fontsource-variable/geist/files/Geist%5Bwght%5D.woff2" as="font" type="font/woff2" crossorigin />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -14,6 +14,12 @@ pub struct SshAuthService {
db: AppDatabase, db: AppDatabase,
} }
pub struct SshKeyUser {
pub user: user::Model,
pub key_id: i64,
pub key_title: String,
}
impl SshAuthService { impl SshAuthService {
pub fn new(db: AppDatabase) -> Self { pub fn new(db: AppDatabase) -> Self {
Self { db } Self { db }
@ -97,7 +103,7 @@ impl SshAuthService {
pub async fn find_user_by_public_key( pub async fn find_user_by_public_key(
&self, &self,
public_key_str: &str, public_key_str: &str,
) -> Result<Option<user::Model>, DbErr> { ) -> Result<Option<SshKeyUser>, DbErr> {
let fingerprint = match self.generate_fingerprint_from_public_key(public_key_str) { let fingerprint = match self.generate_fingerprint_from_public_key(public_key_str) {
Ok(fp) => fp, Ok(fp) => fp,
Err(e) => { Err(e) => {
@ -144,16 +150,20 @@ impl SshAuthService {
.one(self.db.reader()) .one(self.db.reader())
.await?; .await?;
if let Some(ref user) = user_model { if let Some(user) = user_model {
tracing::info!( tracing::info!(
"user authenticated via SSH key user={} key={}", "SSH key matched user={} key={}",
user.username, user.username,
ssh_key.title ssh_key.title
); );
self.update_key_last_used_async(ssh_key.id); return Ok(Some(SshKeyUser {
user,
key_id: ssh_key.id,
key_title: ssh_key.title,
}));
} }
Ok(user_model) Ok(None)
} }
fn is_key_expired(&self, ssh_key: &user_ssh_key::Model) -> bool { fn is_key_expired(&self, ssh_key: &user_ssh_key::Model) -> bool {
@ -165,7 +175,7 @@ impl SshAuthService {
} }
} }
fn update_key_last_used_async(&self, key_id: i64) { pub fn update_key_last_used_async(&self, key_id: i64) {
let db_clone = self.db.clone(); let db_clone = self.db.clone();
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = Self::update_key_last_used_sync(db_clone, key_id).await { if let Err(e) = Self::update_key_last_used_sync(db_clone, key_id).await {

View File

@ -163,7 +163,52 @@ impl russh::server::Handler for SSHandle {
user: &str, user: &str,
public_key: &PublicKey, public_key: &PublicKey,
) -> Result<Auth, Self::Error> { ) -> Result<Auth, Self::Error> {
self.auth_publickey(user, public_key).await let client_info = self
.client_addr
.map(|addr| format!("{}", addr))
.unwrap_or_else(|| "unknown".to_string());
if user != "git" {
tracing::warn!(
"auth_publickey_offer_rejected_invalid_username user={} client={}",
user,
client_info
);
return Err(russh::Error::NotAuthenticated);
}
let public_key_str = public_key.to_string();
if public_key_str.len() < 32 {
tracing::warn!(
"auth_publickey_offer_rejected_invalid_key_length key_length={}",
public_key_str.len()
);
return Err(russh::Error::NotAuthenticated);
}
tracing::info!("auth_publickey_offer client={}", client_info);
match self.auth.find_user_by_public_key(&public_key_str).await {
Ok(Some(key_user)) => {
tracing::info!(
"auth_publickey_offer_accepted user={} key={} client={}",
key_user.user.username,
key_user.key_title,
client_info
);
Ok(Auth::Accept)
}
Ok(None) => {
tracing::warn!(
"auth_publickey_offer_rejected_key_not_found client={}",
client_info
);
Err(russh::Error::NotAuthenticated)
}
Err(e) => {
tracing::error!("auth_publickey_offer_error error={}", e);
Err(russh::Error::NotAuthenticated)
}
}
} }
async fn auth_publickey( async fn auth_publickey(
&mut self, &mut self,
@ -193,8 +238,8 @@ impl russh::server::Handler for SSHandle {
} }
tracing::info!("auth_publickey_attempt client={}", client_info); tracing::info!("auth_publickey_attempt client={}", client_info);
let user_model = match self.auth.find_user_by_public_key(&public_key_str).await { let key_user = match self.auth.find_user_by_public_key(&public_key_str).await {
Ok(Some(model)) => model, Ok(Some(key_user)) => key_user,
Ok(None) => { Ok(None) => {
tracing::warn!("auth_rejected_key_not_found client={}", client_info); tracing::warn!("auth_rejected_key_not_found client={}", client_info);
return Err(russh::Error::NotAuthenticated); return Err(russh::Error::NotAuthenticated);
@ -207,10 +252,11 @@ impl russh::server::Handler for SSHandle {
tracing::info!( tracing::info!(
"auth_publickey_success user={} client={}", "auth_publickey_success user={} client={}",
user_model.username, key_user.user.username,
client_info client_info
); );
self.operator = Some(user_model); self.auth.update_key_last_used_async(key_user.key_id);
self.operator = Some(key_user.user);
Ok(Auth::Accept) Ok(Auth::Accept)
} }
async fn auth_openssh_certificate( async fn auth_openssh_certificate(
@ -241,8 +287,8 @@ impl russh::server::Handler for SSHandle {
} }
tracing::info!("auth_publickey_attempt client={}", client_info); tracing::info!("auth_publickey_attempt client={}", client_info);
let user_model = match self.auth.find_user_by_public_key(&public_key_str).await { let key_user = match self.auth.find_user_by_public_key(&public_key_str).await {
Ok(Some(model)) => model, Ok(Some(key_user)) => key_user,
Ok(None) => { Ok(None) => {
tracing::warn!("auth_rejected_key_not_found client={}", client_info); tracing::warn!("auth_rejected_key_not_found client={}", client_info);
return Err(russh::Error::NotAuthenticated); return Err(russh::Error::NotAuthenticated);
@ -255,10 +301,11 @@ impl russh::server::Handler for SSHandle {
tracing::info!( tracing::info!(
"auth_publickey_success user={} client={}", "auth_publickey_success user={} client={}",
user_model.username, key_user.user.username,
client_info client_info
); );
self.operator = Some(user_model); self.auth.update_key_last_used_async(key_user.key_id);
self.operator = Some(key_user.user);
Ok(Auth::Accept) Ok(Auth::Accept)
} }
async fn authentication_banner(&mut self) -> Result<Option<String>, Self::Error> { async fn authentication_banner(&mut self) -> Result<Option<String>, Self::Error> {

View File

@ -82,7 +82,9 @@ impl russh::server::Server for SSHServer {
); );
if io_err.kind() == io::ErrorKind::UnexpectedEof { if io_err.kind() == io::ErrorKind::UnexpectedEof {
tracing::warn!("Client disconnected during handshake or before authentication"); tracing::warn!(
"SSH peer closed the connection before a clean disconnect was received"
);
} }
} }
_ => { _ => {

View File

@ -21,22 +21,16 @@
"@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist": "^5.2.8",
"@lobehub/icons": "^5.8.0", "@lobehub/icons": "^5.8.0",
"@radix-ui/react-use-controllable-state": "^1.2.2", "@radix-ui/react-use-controllable-state": "^1.2.2",
"@reduxjs/toolkit": "^2.11.2",
"@streamdown/cjk": "^1.0.3", "@streamdown/cjk": "^1.0.3",
"@streamdown/code": "^1.1.1", "@streamdown/code": "^1.1.1",
"@streamdown/math": "^1.0.2", "@streamdown/math": "^1.0.2",
"@streamdown/mermaid": "^1.0.2", "@streamdown/mermaid": "^1.0.2",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tanstack/react-form": "^1.29.1",
"@tanstack/react-hotkeys": "^0.10.0", "@tanstack/react-hotkeys": "^0.10.0",
"@tanstack/react-pacer": "^0.22.0",
"@tanstack/react-query": "^5.100.8", "@tanstack/react-query": "^5.100.8",
"@tanstack/react-router": "^1.169.1",
"@tanstack/react-store": "^0.11.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.24", "@tanstack/react-virtual": "^3.13.24",
"ai": "^6.0.177", "ai": "^6.0.177",
"alova": "^3.5.1",
"axios": "^1.15.2", "axios": "^1.15.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -44,7 +38,6 @@
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"dexie": "^4.4.2", "dexie": "^4.4.2",
"dexie-react-hooks": "^4.4.0", "dexie-react-hooks": "^4.4.0",
"dnd-kit": "^0.0.2",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"idb": "^8.0.3", "idb": "^8.0.3",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
@ -53,15 +46,12 @@
"motion": "^12.38.0", "motion": "^12.38.0",
"nanoid": "^5.1.11", "nanoid": "^5.1.11",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"orval": "^8.9.0",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.2.4", "react": "^19.2.4",
"react-day-picker": "^9.14.0", "react-day-picker": "^9.14.0",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-hook-form": "^7.75.0", "react-hook-form": "^7.75.0",
"react-i18next": "^17.0.6",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-redux": "^9.2.0",
"react-resizable-panels": "^4.10.0", "react-resizable-panels": "^4.10.0",
"react-router-dom": "^7.14.2", "react-router-dom": "^7.14.2",
"react-virtuoso": "^4.18.6", "react-virtuoso": "^4.18.6",
@ -69,9 +59,7 @@
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0", "rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"shadcn": "^4.7.0",
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"streamdown": "^2.5.0", "streamdown": "^2.5.0",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
@ -96,8 +84,10 @@
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^16.5.0", "globals": "^16.5.0",
"orval": "^8.9.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2", "prettier-plugin-tailwindcss": "^0.7.2",
"shadcn": "^4.7.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.57.1", "typescript-eslint": "^8.57.1",
"vite": "^7.3.1", "vite": "^7.3.1",

View File

@ -1,67 +1,78 @@
import { lazy, Suspense } from "react";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { Provider } from "react-redux";
import { store } from "@/store";
import {
ChangePasswordPage,
ForgotPasswordPage,
LoginPage,
RegisterPage,
ResetPasswordPage,
TwoFactorPage,
VerifyEmailPage,
} from "@/app/auth";
import { RootLayout } from "@/app/layout"; import { RootLayout } from "@/app/layout";
import { MePage, MeLayout } from "@/app/me"; import { MeLayout } from "@/app/me";
import { ChannelLayout } from "@/app/channel"; import { ChannelLayout } from "@/app/channel";
import { import {
ProjectLayout, ProjectLayout,
ReposPage,
IssuesPage,
NewIssuePage,
SkillsPage,
BoardPage,
ChannelPage,
RepoDetailPage,
CommitDetailPage,
IssueDetailPage,
SkillDetailPage,
PullRequestDetailPage,
ProjectSettingsLayout, ProjectSettingsLayout,
GeneralSettings,
MembersSettings,
AccessSettings,
LabelsSettings,
BillingSettings,
} from "@/app/project"; } from "@/app/project";
import { ChatPage } from "@/app/chat";
import { ExplorePage } from "@/app/explore/ExplorePage";
import CodePage from "@/app/project/repo/code";
import CommitsPage from "@/app/project/repo/commits";
import PullsPage from "@/app/project/repo/pulls";
import BranchesPage from "@/app/project/repo/branches";
import TagsPage from "@/app/project/repo/tags";
import RepoSettingsLayout from "@/app/project/repo/settings/RepoSettingsLayout";
import RepoGeneralSettings from "@/app/project/repo/settings/GeneralSettings";
import BranchProtectionSettings from "@/app/project/repo/settings/BranchProtectionSettings";
import TreePage from "@/app/project/repo/tree";
import { RedirectIfAuth, RequireAuth } from "@/components/auth"; import { RedirectIfAuth, RequireAuth } from "@/components/auth";
import { import {
SettingsLayout, SettingsLayout,
MyAccountPage,
BillingPage,
AppearancePage,
NotificationsPage,
PasswordPage,
EmailPage,
SshKeysPage,
AccessKeysPage,
PushSettingsPage,
} from "@/app/settings"; } from "@/app/settings";
import RepoSettingsLayout from "@/app/project/repo/settings/RepoSettingsLayout";
// Lazy-loaded page components
const LoginPage = lazy(() => import("@/app/auth/login").then(m => ({ default: m.LoginPage })));
const RegisterPage = lazy(() => import("@/app/auth/register").then(m => ({ default: m.RegisterPage })));
const ForgotPasswordPage = lazy(() => import("@/app/auth/forgot-password").then(m => ({ default: m.ForgotPasswordPage })));
const ResetPasswordPage = lazy(() => import("@/app/auth/reset-password").then(m => ({ default: m.ResetPasswordPage })));
const TwoFactorPage = lazy(() => import("@/app/auth/two-factor").then(m => ({ default: m.TwoFactorPage })));
const VerifyEmailPage = lazy(() => import("@/app/auth/verify-email").then(m => ({ default: m.VerifyEmailPage })));
const ChangePasswordPage = lazy(() => import("@/app/auth/change-password").then(m => ({ default: m.ChangePasswordPage })));
const MePage = lazy(() => import("@/app/me").then(m => ({ default: m.MePage })));
const ChatPage = lazy(() => import("@/app/chat").then(m => ({ default: m.ChatPage })));
const ExplorePage = lazy(() => import("@/app/explore/ExplorePage").then(m => ({ default: m.ExplorePage })));
const MyAccountPage = lazy(() => import("@/app/settings").then(m => ({ default: m.MyAccountPage })));
const BillingPage = lazy(() => import("@/app/settings").then(m => ({ default: m.BillingPage })));
const AppearancePage = lazy(() => import("@/app/settings").then(m => ({ default: m.AppearancePage })));
const NotificationsPage = lazy(() => import("@/app/settings").then(m => ({ default: m.NotificationsPage })));
const PasswordPage = lazy(() => import("@/app/settings").then(m => ({ default: m.PasswordPage })));
const EmailPage = lazy(() => import("@/app/settings").then(m => ({ default: m.EmailPage })));
const SshKeysPage = lazy(() => import("@/app/settings").then(m => ({ default: m.SshKeysPage })));
const AccessKeysPage = lazy(() => import("@/app/settings").then(m => ({ default: m.AccessKeysPage })));
const PushSettingsPage = lazy(() => import("@/app/settings").then(m => ({ default: m.PushSettingsPage })));
const ReposPage = lazy(() => import("@/app/project").then(m => ({ default: m.ReposPage })));
const IssuesPage = lazy(() => import("@/app/project").then(m => ({ default: m.IssuesPage })));
const NewIssuePage = lazy(() => import("@/app/project").then(m => ({ default: m.NewIssuePage })));
const SkillsPage = lazy(() => import("@/app/project").then(m => ({ default: m.SkillsPage })));
const BoardPage = lazy(() => import("@/app/project").then(m => ({ default: m.BoardPage })));
const ChannelPage = lazy(() => import("@/app/project").then(m => ({ default: m.ChannelPage })));
const RepoDetailPage = lazy(() => import("@/app/project").then(m => ({ default: m.RepoDetailPage })));
const CommitDetailPage = lazy(() => import("@/app/project").then(m => ({ default: m.CommitDetailPage })));
const IssueDetailPage = lazy(() => import("@/app/project").then(m => ({ default: m.IssueDetailPage })));
const SkillDetailPage = lazy(() => import("@/app/project").then(m => ({ default: m.SkillDetailPage })));
const PullRequestDetailPage = lazy(() => import("@/app/project").then(m => ({ default: m.PullRequestDetailPage })));
const GeneralSettings = lazy(() => import("@/app/project").then(m => ({ default: m.GeneralSettings })));
const MembersSettings = lazy(() => import("@/app/project").then(m => ({ default: m.MembersSettings })));
const AccessSettings = lazy(() => import("@/app/project").then(m => ({ default: m.AccessSettings })));
const LabelsSettings = lazy(() => import("@/app/project").then(m => ({ default: m.LabelsSettings })));
const BillingSettings = lazy(() => import("@/app/project").then(m => ({ default: m.BillingSettings })));
const CodePage = lazy(() => import("@/app/project/repo/code"));
const CommitsPage = lazy(() => import("@/app/project/repo/commits"));
const PullsPage = lazy(() => import("@/app/project/repo/pulls"));
const BranchesPage = lazy(() => import("@/app/project/repo/branches"));
const TagsPage = lazy(() => import("@/app/project/repo/tags"));
const RepoGeneralSettings = lazy(() => import("@/app/project/repo/settings/GeneralSettings"));
const BranchProtectionSettings = lazy(() => import("@/app/project/repo/settings/BranchProtectionSettings"));
const TreePage = lazy(() => import("@/app/project/repo/tree"));
function PageLoader() {
return (
<div className="flex h-full items-center justify-center" style={{ backgroundColor: "var(--surface-ground)" }}>
<div className="flex flex-col items-center gap-3">
<div className="h-5 w-5 animate-spin rounded-full border-2" style={{ borderColor: "var(--border-strong)", borderTopColor: "var(--accent)" }} />
<span className="text-xs" style={{ color: "var(--text-muted)" }}>Loading...</span>
</div>
</div>
);
}
export default function App() { export default function App() {
return ( return (
<Provider store={store}>
<BrowserRouter> <BrowserRouter>
<Suspense fallback={<PageLoader />}>
<Routes> <Routes>
<Route element={<RedirectIfAuth />}> <Route element={<RedirectIfAuth />}>
<Route path="/auth/login" element={<LoginPage />} /> <Route path="/auth/login" element={<LoginPage />} />
@ -84,6 +95,7 @@ export default function App() {
<Route path="/me/stars" element={<MePage />} /> <Route path="/me/stars" element={<MePage />} />
<Route path="/me/followers" element={<MePage />} /> <Route path="/me/followers" element={<MePage />} />
<Route path="/me/following" element={<MePage />} /> <Route path="/me/following" element={<MePage />} />
<Route path="/me/notify" element={<MePage />} />
<Route path="/me/chat" element={<ChatPage scope="personal" />} /> <Route path="/me/chat" element={<ChatPage scope="personal" />} />
<Route path="/me/chat/:conversationId" element={<ChatPage scope="personal" />} /> <Route path="/me/chat/:conversationId" element={<ChatPage scope="personal" />} />
<Route path="/explore" element={<ExplorePage />} /> <Route path="/explore" element={<ExplorePage />} />
@ -149,7 +161,7 @@ export default function App() {
<Route path="/" element={<Navigate to="/me" replace />} /> <Route path="/" element={<Navigate to="/me" replace />} />
<Route path="*" element={<Navigate to="/me" replace />} /> <Route path="*" element={<Navigate to="/me" replace />} />
</Routes> </Routes>
</Suspense>
</BrowserRouter> </BrowserRouter>
</Provider>
); );
} }

View File

@ -1,7 +1,9 @@
import { useEffect, useState, useMemo, useRef } from "react"; import { useEffect, useState, useMemo, useRef } from "react";
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { useCurrentUserQuery } from "@/hooks/useAuth"; import { useCurrentUserQuery } from "@/hooks/useAuth";
import { useProjectsQuery } from "@/hooks/useProjectsQuery"; import { useProjectsQuery } from "@/hooks/useProjectsQuery";
import { issueSummary } from "@/client/api";
import { SettingsModalContext } from "@/components/settings/SettingsModalContext"; import { SettingsModalContext } from "@/components/settings/SettingsModalContext";
import { SettingsModal } from "@/components/settings/SettingsModal"; import { SettingsModal } from "@/components/settings/SettingsModal";
import { SettingsDataCacheProvider } from "@/components/settings/SettingsDataCache"; import { SettingsDataCacheProvider } from "@/components/settings/SettingsDataCache";
@ -18,6 +20,7 @@ const WS_CONFIG = {
export function RootLayout() { export function RootLayout() {
const { data: user } = useCurrentUserQuery(); const { data: user } = useCurrentUserQuery();
useProjectsQuery(); // Keep query active for caching useProjectsQuery(); // Keep query active for caching
const queryClient = useQueryClient();
const wsClientRef = useRef<WsClient | null>(null); const wsClientRef = useRef<WsClient | null>(null);
const [showSettingsModal, setShowSettingsModal] = useState(false); const [showSettingsModal, setShowSettingsModal] = useState(false);
@ -68,6 +71,25 @@ export function RootLayout() {
}; };
}, [user]); }, [user]);
// Prefetch common data for faster navigation
useEffect(() => {
if (!user) return;
// Prefetch issue summaries for each project (cached by React Query)
const projects = queryClient.getQueryData(["projects"]);
if (Array.isArray(projects)) {
for (const project of projects.slice(0, 5)) {
const name = typeof project === "string" ? project : (project as { name?: string }).name;
if (name) {
queryClient.prefetchQuery({
queryKey: ["issueSummary", name],
queryFn: () => issueSummary(name).then(r => r.data?.data ?? { total: 0, open: 0, closed: 0 }),
staleTime: 5 * 60 * 1000,
});
}
}
}
}, [user, queryClient]);
return ( return (
<SettingsDataCacheProvider> <SettingsDataCacheProvider>
<SettingsModalContext.Provider value={modalCtx}> <SettingsModalContext.Provider value={modalCtx}>

View File

@ -19,6 +19,7 @@ import { UserCardList } from "./components/UserCardList";
import { FollowerCardList } from "./components/FollowerCardList"; import { FollowerCardList } from "./components/FollowerCardList";
import { RepoList } from "./components/RepoList"; import { RepoList } from "./components/RepoList";
import { ProjectList } from "./components/ProjectList"; import { ProjectList } from "./components/ProjectList";
import { NotificationList } from "./components/NotificationList";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useCurrentUserQuery } from "@/hooks/useAuth"; import { useCurrentUserQuery } from "@/hooks/useAuth";
@ -43,6 +44,7 @@ export function MePage() {
else if (path.includes("/stars")) activeSection = "stars"; else if (path.includes("/stars")) activeSection = "stars";
else if (path.includes("/following")) activeSection = "following"; else if (path.includes("/following")) activeSection = "following";
else if (path.includes("/followers")) activeSection = "followers"; else if (path.includes("/followers")) activeSection = "followers";
else if (path.includes("/notify")) activeSection = "notify";
// Conditional fetching for specific sections // Conditional fetching for specific sections
const { data: activityData, isLoading: isActivityLoading } = useUserActivityQuery(username, 1, 20); const { data: activityData, isLoading: isActivityLoading } = useUserActivityQuery(username, 1, 20);
@ -123,6 +125,12 @@ export function MePage() {
) : ( ) : (
<FollowerCardList users={followers ?? []} /> <FollowerCardList users={followers ?? []} />
); );
case "notify":
return (
<div className="rounded-xl p-6 border-[0.5px]" style={{ backgroundColor: "var(--surface-secondary)", borderColor: "var(--border-subtle)" }}>
<NotificationList />
</div>
);
default: default:
return ( return (
<div className="space-y-6"> <div className="space-y-6">

View File

@ -1,3 +1,4 @@
import { memo } from "react";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { import {
History, History,
@ -35,7 +36,7 @@ const ICON_MAP: Record<string, React.ComponentType<{ className?: string; "aria-h
avatar_upload: ImageIcon, avatar_upload: ImageIcon,
}; };
export function ActivityTimeline({ items, isLoading }: ActivityTimelineProps) { export const ActivityTimeline = memo(function ActivityTimeline({ items, isLoading }: ActivityTimelineProps) {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flow-root"> <div className="flow-root">
@ -93,4 +94,4 @@ export function ActivityTimeline({ items, isLoading }: ActivityTimelineProps) {
</ul> </ul>
</div> </div>
); );
} });

View File

@ -7,6 +7,7 @@ import {
Star, Star,
Users, Users,
MessageSquare, MessageSquare,
Bell,
PanelLeftClose PanelLeftClose
} from "lucide-react"; } from "lucide-react";
import type { ComponentType } from "react"; import type { ComponentType } from "react";
@ -47,6 +48,11 @@ const ME_NAV_ITEMS: NavItem[] = [
name: "Chat", name: "Chat",
icon: MessageSquare, icon: MessageSquare,
}, },
{
path: "/me/notify",
name: "Notifications",
icon: Bell,
},
{ {
path: "/me/stars", path: "/me/stars",
name: "Stars", name: "Stars",

View File

@ -0,0 +1,172 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { notificationList, notificationMarkRead, notificationMarkAllRead } from "@/client/api";
import type { NotificationResponse } from "@/client/model";
import { Bell, CheckCheck, Mail, MailOpen, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
const NOTIFICATION_TYPE_LABELS: Record<string, string> = {
mention: "Mention",
invitation: "Invitation",
role_change: "Role Change",
room_created: "Room Created",
room_deleted: "Room Deleted",
system_announcement: "Announcement",
project_invitation: "Project Invitation",
};
function NotificationItem({ notification }: { notification: NotificationResponse }) {
const queryClient = useQueryClient();
const markReadMutation = useMutation({
mutationFn: () => notificationMarkRead(notification.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["notificationList"] });
},
});
return (
<div
className="p-4 rounded-xl border-[0.5px] transition-all"
style={{
backgroundColor: notification.is_read ? "var(--surface-secondary)" : "var(--surface-tertiary)",
borderColor: "var(--border-subtle)",
opacity: notification.is_read ? 0.75 : 1,
}}
onClick={() => {
if (!notification.is_read) {
markReadMutation.mutate();
}
}}
>
<div className="flex items-start gap-3">
<div
className="mt-0.5 w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
style={{ backgroundColor: notification.is_read ? "var(--surface-ground)" : "var(--accent)" }}
>
{notification.is_read ? (
<MailOpen className="w-4 h-4" style={{ color: "var(--text-muted)" }} />
) : (
<Mail className="w-4 h-4 text-white" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-[12px] font-medium" style={{ color: "var(--text-primary)" }}>
{NOTIFICATION_TYPE_LABELS[notification.notification_type] || notification.notification_type}
</span>
{!notification.is_read && (
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: "var(--accent)" }} />
)}
<span className="text-[11px] ml-auto shrink-0" style={{ color: "var(--text-tertiary)" }}>
{new Date(notification.created_at).toLocaleString()}
</span>
</div>
<p className="text-[13px] font-medium truncate" style={{ color: "var(--text-primary)" }}>
{notification.title}
</p>
{notification.content && (
<p className="text-[12px] mt-1 line-clamp-2" style={{ color: "var(--text-secondary)" }}>
{notification.content}
</p>
)}
{(notification.room || notification.project) && (
<div className="flex items-center gap-2 mt-2 text-[11px]" style={{ color: "var(--text-tertiary)" }}>
{notification.project && <span>Project: {notification.project}</span>}
{notification.room && <span>Room: {notification.room}</span>}
</div>
)}
</div>
</div>
</div>
);
}
export function NotificationList() {
const queryClient = useQueryClient();
const [onlyUnread, setOnlyUnread] = useState(false);
const { data, isLoading, isError } = useQuery({
queryKey: ["notificationList", onlyUnread],
queryFn: () => notificationList({ only_unread: onlyUnread || undefined, limit: 50 }),
});
const markAllReadMutation = useMutation({
mutationFn: notificationMarkAllRead,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["notificationList"] });
},
});
if (isLoading) {
return (
<div className="flex justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin" style={{ color: "var(--accent)" }} />
</div>
);
}
if (isError) {
return (
<div className="text-center py-12" style={{ color: "var(--text-muted)" }}>
<p>Failed to load notifications</p>
</div>
);
}
const notifications = data?.data?.data?.notifications ?? [];
const unreadCount = data?.data?.data?.unread_count ?? 0;
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Bell className="w-4 h-4" style={{ color: "var(--text-tertiary)" }} />
<span className="text-[13px]" style={{ color: "var(--text-secondary)" }}>
{unreadCount > 0 ? `${unreadCount} unread` : "All read"}
</span>
</div>
<div className="flex items-center gap-2">
<button
className="text-[12px] px-2.5 py-1 rounded-md transition-colors"
style={{
color: onlyUnread ? "var(--accent)" : "var(--text-secondary)",
backgroundColor: onlyUnread ? "color-mix(in srgb, var(--accent) 10%, transparent)" : "transparent",
}}
onClick={() => setOnlyUnread(!onlyUnread)}
>
Unread only
</button>
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
className="text-[12px] h-7 gap-1"
onClick={() => markAllReadMutation.mutate()}
disabled={markAllReadMutation.isPending}
>
<CheckCheck className="w-3.5 h-3.5" />
Mark all read
</Button>
)}
</div>
</div>
{notifications.length === 0 ? (
<div className="text-center py-16" style={{ color: "var(--text-muted)" }}>
<Bell className="w-10 h-10 mx-auto mb-3" style={{ color: "var(--text-tertiary)" }} />
<p className="text-[14px] font-medium">No notifications</p>
<p className="text-[12px] mt-1">
{onlyUnread ? "No unread notifications" : "You're all caught up"}
</p>
</div>
) : (
<div className="space-y-2">
{notifications.map((n) => (
<NotificationItem key={n.id} notification={n} />
))}
</div>
)}
</div>
);
}

View File

@ -1,5 +1,7 @@
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { useIssuesQuery, useIssueSummaryQuery } from "@/hooks/useIssuesQuery"; import { useIssuesQuery, useIssueSummaryQuery } from "@/hooks/useIssuesQuery";
import { useQueryClient } from "@tanstack/react-query";
import { issueGet } from "@/client/api";
import { LoadingState } from "@/components/ui/LoadingState"; import { LoadingState } from "@/components/ui/LoadingState";
import { EmptyState } from "@/components/ui/EmptyState"; import { EmptyState } from "@/components/ui/EmptyState";
import { ErrorState } from "@/components/ui/ErrorState"; import { ErrorState } from "@/components/ui/ErrorState";
@ -17,16 +19,100 @@ import {
Zap Zap
} from "lucide-react"; } from "lucide-react";
import { ISSUES_PAGE } from "@/css/issues/styles"; import { ISSUES_PAGE } from "@/css/issues/styles";
import { useState, useMemo } from "react"; import { memo, useState, useMemo, useDeferredValue, useRef, useCallback } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { stripMarkdown, truncate } from "@/lib/utils"; import { stripMarkdown, truncate } from "@/lib/utils";
import type { IssueResponse, IssueLabelResponse } from "@/client/model"; import type { IssueResponse, IssueLabelResponse } from "@/client/model";
interface IssueRowProps {
issue: IssueResponse;
projectName: string;
onNavigate: (path: string) => void;
onPrefetch?: (projectName: string, issueNumber: number) => void;
}
const IssueRow = memo(function IssueRow({ issue, projectName, onNavigate, onPrefetch }: IssueRowProps) {
const priorityLabel = issue.labels?.find((l: IssueLabelResponse) => l.label_name?.toLowerCase().startsWith('priority:'));
const priority = priorityLabel ? (priorityLabel.label_name ?? '').split(':')[1].toLowerCase() : null;
const otherLabels = issue.labels?.filter((l: IssueLabelResponse) => !l.label_name?.toLowerCase().startsWith('priority:')) || [];
return (
<div
key={issue.number}
onClick={() => onNavigate(`/${projectName}/issues/${issue.number}`)}
onMouseEnter={() => onPrefetch?.(projectName, issue.number)}
className={ISSUES_PAGE.issueRow}
>
<div className={`${ISSUES_PAGE.statusIcon} ${issue.state === 'open' ? 'text-green-500' : 'text-purple-500'}`}>
{issue.state === 'open' ? <CircleDot className="w-4 h-4" /> : <CheckCircle2 className="w-4 h-4" />}
</div>
<div className={ISSUES_PAGE.issueBody}>
<div className={ISSUES_PAGE.issueTop}>
<span className={ISSUES_PAGE.issueNum}>#{issue.number}</span>
<h3 className={ISSUES_PAGE.issueTitle}>{issue.title}</h3>
<div className="flex items-center gap-1.5 flex-wrap">
{otherLabels.map((l: IssueLabelResponse) => (
<span
key={l.label_id}
className={ISSUES_PAGE.label}
style={{
backgroundColor: `#${l.label_color}20`,
color: `#${l.label_color}`,
border: `1px solid #${l.label_color}30`
}}
>
{l.label_name}
</span>
))}
</div>
</div>
{issue.body && (
<p className={ISSUES_PAGE.issueExcerpt}>
{truncate(stripMarkdown(issue.body), 120)}
</p>
)}
<div className={ISSUES_PAGE.issueMeta}>
{priority && (
<span className={`${ISSUES_PAGE.priorityPill} ${
priority === 'critical' ? ISSUES_PAGE.pCritical :
priority === 'high' ? ISSUES_PAGE.pHigh :
priority === 'medium' ? ISSUES_PAGE.pMedium :
ISSUES_PAGE.pLow
}`}>
{priority === 'critical' ? <Zap className="w-3 h-3" /> : <AlertTriangle className="w-3 h-3" />}
{priority}
</span>
)}
<div className={ISSUES_PAGE.metaItem}>
<User className="w-3.5 h-3.5" />
<span>{issue.author_username || 'anonymous'}</span>
</div>
<div className={ISSUES_PAGE.metaItem}>
<Calendar className="w-3.5 h-3.5" />
<span>{new Date(issue.created_at).toLocaleDateString()}</span>
</div>
</div>
</div>
</div>
);
});
const ESTIMATED_ROW_SIZE = 120;
const OVERSCAN = 5;
export function IssuesPage() { export function IssuesPage() {
const { projectName } = useParams<{ projectName: string }>(); const { projectName } = useParams<{ projectName: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<'open' | 'closed'>('open'); const [activeTab, setActiveTab] = useState<'open' | 'closed'>('open');
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const deferredQuery = useDeferredValue(searchQuery);
const isSearchStale = searchQuery !== deferredQuery;
const { data: issues = [], isLoading, error, refetch } = useIssuesQuery(projectName, { const { data: issues = [], isLoading, error, refetch } = useIssuesQuery(projectName, {
state: activeTab state: activeTab
@ -35,18 +121,44 @@ export function IssuesPage() {
const { data: summary } = useIssueSummaryQuery(projectName); const { data: summary } = useIssueSummaryQuery(projectName);
const filteredIssues = useMemo(() => { const filteredIssues = useMemo(() => {
if (!searchQuery) return issues; if (!deferredQuery) return issues;
const lowQuery = searchQuery.toLowerCase(); const lowQuery = deferredQuery.toLowerCase();
return issues.filter(i => return issues.filter(i =>
i.title.toLowerCase().includes(lowQuery) || i.title.toLowerCase().includes(lowQuery) ||
(i.body && i.body.toLowerCase().includes(lowQuery)) || (i.body && i.body.toLowerCase().includes(lowQuery)) ||
String(i.number).includes(lowQuery) String(i.number).includes(lowQuery)
); );
}, [issues, searchQuery]); }, [issues, deferredQuery]);
const openCount = summary?.open ?? 0; const openCount = summary?.open ?? 0;
const closedCount = summary?.closed ?? 0; const closedCount = summary?.closed ?? 0;
const scrollRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: filteredIssues.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => ESTIMATED_ROW_SIZE,
overscan: OVERSCAN,
});
const handleNavigate = useCallback(
(path: string) => navigate(path),
[navigate]
);
const queryClient = useQueryClient();
const handlePrefetch = useCallback(
(project: string, issueNumber: number) => {
queryClient.prefetchQuery({
queryKey: ["issues", project, issueNumber],
queryFn: () => issueGet(project, issueNumber).then(r => (r.data?.data as IssueResponse) ?? null),
staleTime: 5 * 60 * 1000,
});
},
[queryClient]
);
if (!projectName) { if (!projectName) {
return ( return (
<div className="h-full flex items-center justify-center"> <div className="h-full flex items-center justify-center">
@ -80,7 +192,7 @@ export function IssuesPage() {
} }
return ( return (
<div className={ISSUES_PAGE.container}> <div className={`${ISSUES_PAGE.container} flex flex-col`}>
{/* Page Header */} {/* Page Header */}
<div className={ISSUES_PAGE.headerRow}> <div className={ISSUES_PAGE.headerRow}>
<div className={ISSUES_PAGE.titleGroup}> <div className={ISSUES_PAGE.titleGroup}>
@ -106,6 +218,11 @@ export function IssuesPage() {
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />
{isSearchStale && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<div className="h-3 w-3 animate-spin rounded-full border" style={{ borderColor: "var(--border-strong)", borderTopColor: "var(--accent)" }} />
</div>
)}
</div> </div>
<button className={ISSUES_PAGE.filterBtn}> <button className={ISSUES_PAGE.filterBtn}>
<Tag className="w-3.5 h-3.5" /> <Tag className="w-3.5 h-3.5" />
@ -162,83 +279,29 @@ export function IssuesPage() {
)} )}
</div> </div>
) : ( ) : (
<div className={ISSUES_PAGE.issueList}> <div ref={scrollRef} className={`${ISSUES_PAGE.issueList} flex-1 overflow-y-auto`}>
{filteredIssues.map((issue: IssueResponse) => { <div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
// Find priority label if exists {virtualizer.getVirtualItems().map((virtualItem) => {
const priorityLabel = issue.labels?.find((l: IssueLabelResponse) => l.label_name?.toLowerCase().startsWith('priority:')); const issue = filteredIssues[virtualItem.index];
const priority = priorityLabel ? (priorityLabel.label_name ?? '').split(':')[1].toLowerCase() : null;
// Other labels
const otherLabels = issue.labels?.filter((l: IssueLabelResponse) => !l.label_name?.toLowerCase().startsWith('priority:')) || [];
return ( return (
<div <div
key={issue.number} key={virtualItem.key}
onClick={() => navigate(`/${projectName}/issues/${issue.number}`)} data-index={virtualItem.index}
className={ISSUES_PAGE.issueRow} ref={virtualizer.measureElement}
>
<div className={`${ISSUES_PAGE.statusIcon} ${issue.state === 'open' ? 'text-green-500' : 'text-purple-500'}`}>
{issue.state === 'open' ? <CircleDot className="w-4 h-4" /> : <CheckCircle2 className="w-4 h-4" />}
</div>
<div className={ISSUES_PAGE.issueBody}>
<div className={ISSUES_PAGE.issueTop}>
<span className={ISSUES_PAGE.issueNum}>#{issue.number}</span>
<h3 className={ISSUES_PAGE.issueTitle}>{issue.title}</h3>
{/* Inline Labels */}
<div className="flex items-center gap-1.5 flex-wrap">
{otherLabels.map((l: IssueLabelResponse) => (
<span
key={l.label_id}
className={ISSUES_PAGE.label}
style={{ style={{
backgroundColor: `#${l.label_color}20`, position: "absolute",
color: `#${l.label_color}`, top: 0,
border: `1px solid #${l.label_color}30` left: 0,
width: "100%",
transform: `translateY(${virtualItem.start}px)`,
}} }}
> >
{l.label_name} <IssueRow issue={issue} projectName={projectName} onNavigate={handleNavigate} onPrefetch={handlePrefetch} />
</span>
))}
</div>
</div>
{issue.body && (
<p className={ISSUES_PAGE.issueExcerpt}>
{truncate(stripMarkdown(issue.body), 120)}
</p>
)}
<div className={ISSUES_PAGE.issueMeta}>
{/* Priority Pill */}
{priority && (
<span className={`${ISSUES_PAGE.priorityPill} ${
priority === 'critical' ? ISSUES_PAGE.pCritical :
priority === 'high' ? ISSUES_PAGE.pHigh :
priority === 'medium' ? ISSUES_PAGE.pMedium :
ISSUES_PAGE.pLow
}`}>
{priority === 'critical' ? <Zap className="w-3 h-3" /> : <AlertTriangle className="w-3 h-3" />}
{priority}
</span>
)}
<div className={ISSUES_PAGE.metaItem}>
<User className="w-3.5 h-3.5" />
<span>{issue.author_username || 'anonymous'}</span>
</div>
<div className={ISSUES_PAGE.metaItem}>
<Calendar className="w-3.5 h-3.5" />
<span>{new Date(issue.created_at).toLocaleDateString()}</span>
</div>
</div>
</div>
</div> </div>
); );
})} })}
</div> </div>
</div>
)} )}
</div> </div>
); );

View File

@ -1,5 +0,0 @@
import { useDispatch, useSelector, type TypedUseSelectorHook } from "react-redux";
import type { RootState, AppDispatch } from "./index";
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

View File

@ -1,8 +0,0 @@
import { configureStore } from "@reduxjs/toolkit";
export const store = configureStore({
reducer: {},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

View File

@ -20,6 +20,43 @@ export default defineConfig({
optimizeDeps: { optimizeDeps: {
entries: ["src/**/*.{ts,tsx}"], entries: ["src/**/*.{ts,tsx}"],
}, },
build: {
rollupOptions: {
output: {
manualChunks(id: string) {
if (id.includes("node_modules")) {
// React + deps that import React — keep together to avoid circular deps
if (id.includes("react-dom") || id.includes("react-router-dom") || id.includes("scheduler") || id.includes("@tanstack/react-query")) {
return "vendor-react";
}
if (id.includes("react-markdown") || id.includes("remark-gfm") || id.includes("rehype-raw") || id.includes("rehype-sanitize")) {
return "vendor-markdown";
}
if (id.includes("lucide-react")) {
return "vendor-lucide";
}
if (id.includes("motion")) {
return "vendor-motion";
}
if (id.includes("recharts")) {
return "vendor-recharts";
}
// Streamdown + diagram deps — keep together to avoid circular deps
if (id.includes("streamdown") || id.includes("@streamdown") || id.includes("cytoscape") || id.includes("d3-") || id.includes("dagre")) {
return "vendor-streamdown";
}
if (id.includes("@tanstack/react-table")) {
return "vendor-table";
}
if (id.includes("radix-ui")) {
return "vendor-radix";
}
}
},
},
},
chunkSizeWarningLimit: 400,
},
resolve: { resolve: {
alias: { alias: {
"@": path.resolve(__dirname, "./src"), "@": path.resolve(__dirname, "./src"),