diff --git a/.agents/skills/nodejs-best-practices/SKILL.md b/.agents/skills/nodejs-best-practices/SKILL.md
new file mode 100644
index 0000000..b92ac38
--- /dev/null
+++ b/.agents/skills/nodejs-best-practices/SKILL.md
@@ -0,0 +1,338 @@
+---
+name: nodejs-best-practices
+description: "Node.js development principles and decision-making. Framework selection, async patterns, security, and architecture. Teaches thinking, not copying."
+risk: unknown
+source: community
+date_added: "2026-02-27"
+---
+
+# Node.js Best Practices
+
+> Principles and decision-making for Node.js development in 2025.
+> **Learn to THINK, not memorize code patterns.**
+
+## When to Use
+Use this skill when making Node.js architecture decisions, choosing frameworks, designing async patterns, or applying security and deployment best practices.
+
+---
+
+## ⚠️ How to Use This Skill
+
+This skill teaches **decision-making principles**, not fixed code to copy.
+
+- ASK user for preferences when unclear
+- Choose framework/pattern based on CONTEXT
+- Don't default to same solution every time
+
+---
+
+## 1. Framework Selection (2025)
+
+### Decision Tree
+
+```
+What are you building?
+│
+├── Edge/Serverless (Cloudflare, Vercel)
+│ └── Hono (zero-dependency, ultra-fast cold starts)
+│
+├── High Performance API
+│ └── Fastify (2-3x faster than Express)
+│
+├── Enterprise/Team familiarity
+│ └── NestJS (structured, DI, decorators)
+│
+├── Legacy/Stable/Maximum ecosystem
+│ └── Express (mature, most middleware)
+│
+└── Full-stack with frontend
+ └── Next.js API Routes or tRPC
+```
+
+### Comparison Principles
+
+| Factor | Hono | Fastify | Express |
+|--------|------|---------|---------|
+| **Best for** | Edge, serverless | Performance | Legacy, learning |
+| **Cold start** | Fastest | Fast | Moderate |
+| **Ecosystem** | Growing | Good | Largest |
+| **TypeScript** | Native | Excellent | Good |
+| **Learning curve** | Low | Medium | Low |
+
+### Selection Questions to Ask:
+1. What's the deployment target?
+2. Is cold start time critical?
+3. Does team have existing experience?
+4. Is there legacy code to maintain?
+
+---
+
+## 2. Runtime Considerations (2025)
+
+### Native TypeScript
+
+```
+Node.js 22+: --experimental-strip-types
+├── Run .ts files directly
+├── No build step needed for simple projects
+└── Consider for: scripts, simple APIs
+```
+
+### Module System Decision
+
+```
+ESM (import/export)
+├── Modern standard
+├── Better tree-shaking
+├── Async module loading
+└── Use for: new projects
+
+CommonJS (require)
+├── Legacy compatibility
+├── More npm packages support
+└── Use for: existing codebases, some edge cases
+```
+
+### Runtime Selection
+
+| Runtime | Best For |
+|---------|----------|
+| **Node.js** | General purpose, largest ecosystem |
+| **Bun** | Performance, built-in bundler |
+| **Deno** | Security-first, built-in TypeScript |
+
+---
+
+## 3. Architecture Principles
+
+### Layered Structure Concept
+
+```
+Request Flow:
+│
+├── Controller/Route Layer
+│ ├── Handles HTTP specifics
+│ ├── Input validation at boundary
+│ └── Calls service layer
+│
+├── Service Layer
+│ ├── Business logic
+│ ├── Framework-agnostic
+│ └── Calls repository layer
+│
+└── Repository Layer
+ ├── Data access only
+ ├── Database queries
+ └── ORM interactions
+```
+
+### Why This Matters:
+- **Testability**: Mock layers independently
+- **Flexibility**: Swap database without touching business logic
+- **Clarity**: Each layer has single responsibility
+
+### When to Simplify:
+- Small scripts → Single file OK
+- Prototypes → Less structure acceptable
+- Always ask: "Will this grow?"
+
+---
+
+## 4. Error Handling Principles
+
+### Centralized Error Handling
+
+```
+Pattern:
+├── Create custom error classes
+├── Throw from any layer
+├── Catch at top level (middleware)
+└── Format consistent response
+```
+
+### Error Response Philosophy
+
+```
+Client gets:
+├── Appropriate HTTP status
+├── Error code for programmatic handling
+├── User-friendly message
+└── NO internal details (security!)
+
+Logs get:
+├── Full stack trace
+├── Request context
+├── User ID (if applicable)
+└── Timestamp
+```
+
+### Status Code Selection
+
+| Situation | Status | When |
+|-----------|--------|------|
+| Bad input | 400 | Client sent invalid data |
+| No auth | 401 | Missing or invalid credentials |
+| No permission | 403 | Valid auth, but not allowed |
+| Not found | 404 | Resource doesn't exist |
+| Conflict | 409 | Duplicate or state conflict |
+| Validation | 422 | Schema valid but business rules fail |
+| Server error | 500 | Our fault, log everything |
+
+---
+
+## 5. Async Patterns Principles
+
+### When to Use Each
+
+| Pattern | Use When |
+|---------|----------|
+| `async/await` | Sequential async operations |
+| `Promise.all` | Parallel independent operations |
+| `Promise.allSettled` | Parallel where some can fail |
+| `Promise.race` | Timeout or first response wins |
+
+### Event Loop Awareness
+
+```
+I/O-bound (async helps):
+├── Database queries
+├── HTTP requests
+├── File system
+└── Network operations
+
+CPU-bound (async doesn't help):
+├── Crypto operations
+├── Image processing
+├── Complex calculations
+└── → Use worker threads or offload
+```
+
+### Avoiding Event Loop Blocking
+
+- Never use sync methods in production (fs.readFileSync, etc.)
+- Offload CPU-intensive work
+- Use streaming for large data
+
+---
+
+## 6. Validation Principles
+
+### Validate at Boundaries
+
+```
+Where to validate:
+├── API entry point (request body/params)
+├── Before database operations
+├── External data (API responses, file uploads)
+└── Environment variables (startup)
+```
+
+### Validation Library Selection
+
+| Library | Best For |
+|---------|----------|
+| **Zod** | TypeScript first, inference |
+| **Valibot** | Smaller bundle (tree-shakeable) |
+| **ArkType** | Performance critical |
+| **Yup** | Existing React Form usage |
+
+### Validation Philosophy
+
+- Fail fast: Validate early
+- Be specific: Clear error messages
+- Don't trust: Even "internal" data
+
+---
+
+## 7. Security Principles
+
+### Security Checklist (Not Code)
+
+- [ ] **Input validation**: All inputs validated
+- [ ] **Parameterized queries**: No string concatenation for SQL
+- [ ] **Password hashing**: bcrypt or argon2
+- [ ] **JWT verification**: Always verify signature and expiry
+- [ ] **Rate limiting**: Protect from abuse
+- [ ] **Security headers**: Helmet.js or equivalent
+- [ ] **HTTPS**: Everywhere in production
+- [ ] **CORS**: Properly configured
+- [ ] **Secrets**: Environment variables only
+- [ ] **Dependencies**: Regularly audited
+
+### Security Mindset
+
+```
+Trust nothing:
+├── Query params → validate
+├── Request body → validate
+├── Headers → verify
+├── Cookies → validate
+├── File uploads → scan
+└── External APIs → validate response
+```
+
+---
+
+## 8. Testing Principles
+
+### Test Strategy Selection
+
+| Type | Purpose | Tools |
+|------|---------|-------|
+| **Unit** | Business logic | node:test, Vitest |
+| **Integration** | API endpoints | Supertest |
+| **E2E** | Full flows | Playwright |
+
+### What to Test (Priorities)
+
+1. **Critical paths**: Auth, payments, core business
+2. **Edge cases**: Empty inputs, boundaries
+3. **Error handling**: What happens when things fail?
+4. **Not worth testing**: Framework code, trivial getters
+
+### Built-in Test Runner (Node.js 22+)
+
+```
+node --test src/**/*.test.ts
+├── No external dependency
+├── Good coverage reporting
+└── Watch mode available
+```
+
+---
+
+## 10. Anti-Patterns to Avoid
+
+### ❌ DON'T:
+- Use Express for new edge projects (use Hono)
+- Use sync methods in production code
+- Put business logic in controllers
+- Skip input validation
+- Hardcode secrets
+- Trust external data without validation
+- Block event loop with CPU work
+
+### ✅ DO:
+- Choose framework based on context
+- Ask user for preferences when unclear
+- Use layered architecture for growing projects
+- Validate all inputs
+- Use environment variables for secrets
+- Profile before optimizing
+
+---
+
+## 11. Decision Checklist
+
+Before implementing:
+
+- [ ] **Asked user about stack preference?**
+- [ ] **Chosen framework for THIS context?** (not just default)
+- [ ] **Considered deployment target?**
+- [ ] **Planned error handling strategy?**
+- [ ] **Identified validation points?**
+- [ ] **Considered security requirements?**
+
+---
+
+> **Remember**: Node.js best practices are about decision-making, not memorizing patterns. Every project deserves fresh consideration based on its requirements.
diff --git a/.claude/skills/nodejs-best-practices/SKILL.md b/.claude/skills/nodejs-best-practices/SKILL.md
new file mode 100644
index 0000000..b92ac38
--- /dev/null
+++ b/.claude/skills/nodejs-best-practices/SKILL.md
@@ -0,0 +1,338 @@
+---
+name: nodejs-best-practices
+description: "Node.js development principles and decision-making. Framework selection, async patterns, security, and architecture. Teaches thinking, not copying."
+risk: unknown
+source: community
+date_added: "2026-02-27"
+---
+
+# Node.js Best Practices
+
+> Principles and decision-making for Node.js development in 2025.
+> **Learn to THINK, not memorize code patterns.**
+
+## When to Use
+Use this skill when making Node.js architecture decisions, choosing frameworks, designing async patterns, or applying security and deployment best practices.
+
+---
+
+## ⚠️ How to Use This Skill
+
+This skill teaches **decision-making principles**, not fixed code to copy.
+
+- ASK user for preferences when unclear
+- Choose framework/pattern based on CONTEXT
+- Don't default to same solution every time
+
+---
+
+## 1. Framework Selection (2025)
+
+### Decision Tree
+
+```
+What are you building?
+│
+├── Edge/Serverless (Cloudflare, Vercel)
+│ └── Hono (zero-dependency, ultra-fast cold starts)
+│
+├── High Performance API
+│ └── Fastify (2-3x faster than Express)
+│
+├── Enterprise/Team familiarity
+│ └── NestJS (structured, DI, decorators)
+│
+├── Legacy/Stable/Maximum ecosystem
+│ └── Express (mature, most middleware)
+│
+└── Full-stack with frontend
+ └── Next.js API Routes or tRPC
+```
+
+### Comparison Principles
+
+| Factor | Hono | Fastify | Express |
+|--------|------|---------|---------|
+| **Best for** | Edge, serverless | Performance | Legacy, learning |
+| **Cold start** | Fastest | Fast | Moderate |
+| **Ecosystem** | Growing | Good | Largest |
+| **TypeScript** | Native | Excellent | Good |
+| **Learning curve** | Low | Medium | Low |
+
+### Selection Questions to Ask:
+1. What's the deployment target?
+2. Is cold start time critical?
+3. Does team have existing experience?
+4. Is there legacy code to maintain?
+
+---
+
+## 2. Runtime Considerations (2025)
+
+### Native TypeScript
+
+```
+Node.js 22+: --experimental-strip-types
+├── Run .ts files directly
+├── No build step needed for simple projects
+└── Consider for: scripts, simple APIs
+```
+
+### Module System Decision
+
+```
+ESM (import/export)
+├── Modern standard
+├── Better tree-shaking
+├── Async module loading
+└── Use for: new projects
+
+CommonJS (require)
+├── Legacy compatibility
+├── More npm packages support
+└── Use for: existing codebases, some edge cases
+```
+
+### Runtime Selection
+
+| Runtime | Best For |
+|---------|----------|
+| **Node.js** | General purpose, largest ecosystem |
+| **Bun** | Performance, built-in bundler |
+| **Deno** | Security-first, built-in TypeScript |
+
+---
+
+## 3. Architecture Principles
+
+### Layered Structure Concept
+
+```
+Request Flow:
+│
+├── Controller/Route Layer
+│ ├── Handles HTTP specifics
+│ ├── Input validation at boundary
+│ └── Calls service layer
+│
+├── Service Layer
+│ ├── Business logic
+│ ├── Framework-agnostic
+│ └── Calls repository layer
+│
+└── Repository Layer
+ ├── Data access only
+ ├── Database queries
+ └── ORM interactions
+```
+
+### Why This Matters:
+- **Testability**: Mock layers independently
+- **Flexibility**: Swap database without touching business logic
+- **Clarity**: Each layer has single responsibility
+
+### When to Simplify:
+- Small scripts → Single file OK
+- Prototypes → Less structure acceptable
+- Always ask: "Will this grow?"
+
+---
+
+## 4. Error Handling Principles
+
+### Centralized Error Handling
+
+```
+Pattern:
+├── Create custom error classes
+├── Throw from any layer
+├── Catch at top level (middleware)
+└── Format consistent response
+```
+
+### Error Response Philosophy
+
+```
+Client gets:
+├── Appropriate HTTP status
+├── Error code for programmatic handling
+├── User-friendly message
+└── NO internal details (security!)
+
+Logs get:
+├── Full stack trace
+├── Request context
+├── User ID (if applicable)
+└── Timestamp
+```
+
+### Status Code Selection
+
+| Situation | Status | When |
+|-----------|--------|------|
+| Bad input | 400 | Client sent invalid data |
+| No auth | 401 | Missing or invalid credentials |
+| No permission | 403 | Valid auth, but not allowed |
+| Not found | 404 | Resource doesn't exist |
+| Conflict | 409 | Duplicate or state conflict |
+| Validation | 422 | Schema valid but business rules fail |
+| Server error | 500 | Our fault, log everything |
+
+---
+
+## 5. Async Patterns Principles
+
+### When to Use Each
+
+| Pattern | Use When |
+|---------|----------|
+| `async/await` | Sequential async operations |
+| `Promise.all` | Parallel independent operations |
+| `Promise.allSettled` | Parallel where some can fail |
+| `Promise.race` | Timeout or first response wins |
+
+### Event Loop Awareness
+
+```
+I/O-bound (async helps):
+├── Database queries
+├── HTTP requests
+├── File system
+└── Network operations
+
+CPU-bound (async doesn't help):
+├── Crypto operations
+├── Image processing
+├── Complex calculations
+└── → Use worker threads or offload
+```
+
+### Avoiding Event Loop Blocking
+
+- Never use sync methods in production (fs.readFileSync, etc.)
+- Offload CPU-intensive work
+- Use streaming for large data
+
+---
+
+## 6. Validation Principles
+
+### Validate at Boundaries
+
+```
+Where to validate:
+├── API entry point (request body/params)
+├── Before database operations
+├── External data (API responses, file uploads)
+└── Environment variables (startup)
+```
+
+### Validation Library Selection
+
+| Library | Best For |
+|---------|----------|
+| **Zod** | TypeScript first, inference |
+| **Valibot** | Smaller bundle (tree-shakeable) |
+| **ArkType** | Performance critical |
+| **Yup** | Existing React Form usage |
+
+### Validation Philosophy
+
+- Fail fast: Validate early
+- Be specific: Clear error messages
+- Don't trust: Even "internal" data
+
+---
+
+## 7. Security Principles
+
+### Security Checklist (Not Code)
+
+- [ ] **Input validation**: All inputs validated
+- [ ] **Parameterized queries**: No string concatenation for SQL
+- [ ] **Password hashing**: bcrypt or argon2
+- [ ] **JWT verification**: Always verify signature and expiry
+- [ ] **Rate limiting**: Protect from abuse
+- [ ] **Security headers**: Helmet.js or equivalent
+- [ ] **HTTPS**: Everywhere in production
+- [ ] **CORS**: Properly configured
+- [ ] **Secrets**: Environment variables only
+- [ ] **Dependencies**: Regularly audited
+
+### Security Mindset
+
+```
+Trust nothing:
+├── Query params → validate
+├── Request body → validate
+├── Headers → verify
+├── Cookies → validate
+├── File uploads → scan
+└── External APIs → validate response
+```
+
+---
+
+## 8. Testing Principles
+
+### Test Strategy Selection
+
+| Type | Purpose | Tools |
+|------|---------|-------|
+| **Unit** | Business logic | node:test, Vitest |
+| **Integration** | API endpoints | Supertest |
+| **E2E** | Full flows | Playwright |
+
+### What to Test (Priorities)
+
+1. **Critical paths**: Auth, payments, core business
+2. **Edge cases**: Empty inputs, boundaries
+3. **Error handling**: What happens when things fail?
+4. **Not worth testing**: Framework code, trivial getters
+
+### Built-in Test Runner (Node.js 22+)
+
+```
+node --test src/**/*.test.ts
+├── No external dependency
+├── Good coverage reporting
+└── Watch mode available
+```
+
+---
+
+## 10. Anti-Patterns to Avoid
+
+### ❌ DON'T:
+- Use Express for new edge projects (use Hono)
+- Use sync methods in production code
+- Put business logic in controllers
+- Skip input validation
+- Hardcode secrets
+- Trust external data without validation
+- Block event loop with CPU work
+
+### ✅ DO:
+- Choose framework based on context
+- Ask user for preferences when unclear
+- Use layered architecture for growing projects
+- Validate all inputs
+- Use environment variables for secrets
+- Profile before optimizing
+
+---
+
+## 11. Decision Checklist
+
+Before implementing:
+
+- [ ] **Asked user about stack preference?**
+- [ ] **Chosen framework for THIS context?** (not just default)
+- [ ] **Considered deployment target?**
+- [ ] **Planned error handling strategy?**
+- [ ] **Identified validation points?**
+- [ ] **Considered security requirements?**
+
+---
+
+> **Remember**: Node.js best practices are about decision-making, not memorizing patterns. Every project deserves fresh consideration based on its requirements.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..b09712d
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,66 @@
+name: CI
+
+on:
+ push:
+ branches: [main, develop]
+ pull_request:
+ branches: [main]
+
+jobs:
+ server:
+ runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: ankane/pgvector
+ env:
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: codegraph_test
+ ports:
+ - 5432:5432
+ redis:
+ image: redis:7
+ ports:
+ - 6379:6379
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ - name: Install server dependencies
+ run: npm ci
+ working-directory: server
+ - name: Run migrations
+ run: npm run migrate
+ working-directory: server
+ env:
+ DATABASE_URL: postgres://postgres:postgres@localhost:5432/codegraph_test
+ - name: Run Vitest unit tests
+ run: npm run test:coverage
+ working-directory: server
+ env:
+ DATABASE_URL: postgres://postgres:postgres@localhost:5432/codegraph_test
+ REDIS_URL: redis://localhost:6379
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
+ JWT_SECRET: test_secret
+ - name: Run Node integration tests
+ run: npm test
+ working-directory: server
+ env:
+ DATABASE_URL: postgres://postgres:postgres@localhost:5432/codegraph_test
+ REDIS_URL: redis://localhost:6379
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
+ JWT_SECRET: test_secret
+
+ client:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ - name: Install client dependencies
+ run: npm ci
+ working-directory: client
+ - name: Build client
+ run: npm run build
+ working-directory: client
diff --git a/client/.env.example b/client/.env.example
index f083e39..67aac5d 100644
--- a/client/.env.example
+++ b/client/.env.example
@@ -1,5 +1,9 @@
VITE_API_BASE_URL=http://localhost:5000
# App Info (Optional)
-VITE_APP_NAME=StarterApp
-VITE_APP_ENV=development
\ No newline at end of file
+VITE_APP_NAME=CodeGraph AI
+VITE_APP_ENV=development
+
+# Observability (Sentry)
+VITE_SENTRY_DSN=
+VITE_SENTRY_TRACES_SAMPLE_RATE=0.1
\ No newline at end of file
diff --git a/client/package-lock.json b/client/package-lock.json
index b9f5dff..2af86b9 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -12,6 +12,8 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@reduxjs/toolkit": "^2.11.2",
+ "@sentry/react": "^10.46.0",
+ "@sentry/tracing": "^7.120.4",
"@tailwindcss/vite": "^4.1.18",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
@@ -2336,6 +2338,157 @@
"win32"
]
},
+ "node_modules/@sentry-internal/browser-utils": {
+ "version": "10.46.0",
+ "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.46.0.tgz",
+ "integrity": "sha512-WB1gBT9G13V02ekZ6NpUhoI1aGHV2eNfjEPthkU2bGBvFpQKnstwzjg7waIRGR7cu+YSW2Q6UI6aQLgBeOPD1g==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry/core": "10.46.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@sentry-internal/feedback": {
+ "version": "10.46.0",
+ "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.46.0.tgz",
+ "integrity": "sha512-c4pI/z9nZCQXe9GYEw/hE/YTY9AxGBp8/wgKI+T8zylrN35SGHaXv63szzE1WbI8lacBY8lBF7rstq9bQVCaHw==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry/core": "10.46.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@sentry-internal/replay": {
+ "version": "10.46.0",
+ "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.46.0.tgz",
+ "integrity": "sha512-JBsWeXG6bRbxBFK8GzWymWGOB9QE7Kl57BeF3jzgdHTuHSWZ2mRnAmb1K05T4LU+gVygk6yW0KmdC8Py9Qzg9A==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry-internal/browser-utils": "10.46.0",
+ "@sentry/core": "10.46.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@sentry-internal/replay-canvas": {
+ "version": "10.46.0",
+ "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.46.0.tgz",
+ "integrity": "sha512-ub314MWUsekVCuoH0/HJbbimlI24SkV745UW2pj9xRbxOAEf1wjkmIzxKrMDbTgJGuEunug02XZVdJFJUzOcDw==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry-internal/replay": "10.46.0",
+ "@sentry/core": "10.46.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@sentry-internal/tracing": {
+ "version": "7.120.4",
+ "resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.120.4.tgz",
+ "integrity": "sha512-Fz5+4XCg3akeoFK+K7g+d7HqGMjmnLoY2eJlpONJmaeT9pXY7yfUyXKZMmMajdE2LxxKJgQ2YKvSCaGVamTjHw==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry/core": "7.120.4",
+ "@sentry/types": "7.120.4",
+ "@sentry/utils": "7.120.4"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@sentry-internal/tracing/node_modules/@sentry/core": {
+ "version": "7.120.4",
+ "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.120.4.tgz",
+ "integrity": "sha512-TXu3Q5kKiq8db9OXGkWyXUbIxMMuttB5vJ031yolOl5T/B69JRyAoKuojLBjRv1XX583gS1rSSoX8YXX7ATFGA==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry/types": "7.120.4",
+ "@sentry/utils": "7.120.4"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@sentry/browser": {
+ "version": "10.46.0",
+ "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.46.0.tgz",
+ "integrity": "sha512-80DmGlTk5Z2/OxVOzLNxwolMyouuAYKqG8KUcoyintZqHbF6kO1RulI610HmyUt3OagKeBCqt9S7w0VIfCRL+Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry-internal/browser-utils": "10.46.0",
+ "@sentry-internal/feedback": "10.46.0",
+ "@sentry-internal/replay": "10.46.0",
+ "@sentry-internal/replay-canvas": "10.46.0",
+ "@sentry/core": "10.46.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@sentry/core": {
+ "version": "10.46.0",
+ "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.46.0.tgz",
+ "integrity": "sha512-N3fj4zqBQOhXliS1Ne9euqIKuciHCGOJfPGQLwBoW9DNz03jF+NB8+dUKtrJ79YLoftjVgf8nbgwtADK7NR+2Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@sentry/react": {
+ "version": "10.46.0",
+ "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.46.0.tgz",
+ "integrity": "sha512-Rb1S+9OuUPVwsz7GWnQ6Kgf3azbsseUymIegg3JZHNcW/fM1nPpaljzTBnuineia113DH0pgMBcdrrZDLaosFQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry/browser": "10.46.0",
+ "@sentry/core": "10.46.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "react": "^16.14.0 || 17.x || 18.x || 19.x"
+ }
+ },
+ "node_modules/@sentry/tracing": {
+ "version": "7.120.4",
+ "resolved": "https://registry.npmjs.org/@sentry/tracing/-/tracing-7.120.4.tgz",
+ "integrity": "sha512-cAtpLh23qW3hoqZJ6c36EvFki5NhFWUSK71ALHefqDXEocMlfDc9I+IGn3B/ola2D2TDEDamCy3x32vctKqOag==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry-internal/tracing": "7.120.4"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@sentry/types": {
+ "version": "7.120.4",
+ "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.120.4.tgz",
+ "integrity": "sha512-cUq2hSSe6/qrU6oZsEP4InMI5VVdD86aypE+ENrQ6eZEVLTCYm1w6XhW1NvIu3UuWh7gZec4a9J7AFpYxki88Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@sentry/utils": {
+ "version": "7.120.4",
+ "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.120.4.tgz",
+ "integrity": "sha512-zCKpyDIWKHwtervNK2ZlaK8mMV7gVUijAgFeJStH+CU/imcdquizV3pFLlSQYRswG+Lbyd6CT/LGRh3IbtkCFw==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry/types": "7.120.4"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
diff --git a/client/package.json b/client/package.json
index 8f4c04b..070296d 100644
--- a/client/package.json
+++ b/client/package.json
@@ -14,6 +14,8 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@reduxjs/toolkit": "^2.11.2",
+ "@sentry/react": "^10.46.0",
+ "@sentry/tracing": "^7.120.4",
"@tailwindcss/vite": "^4.1.18",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
diff --git a/client/src/features/ai/components/AiPanel.jsx b/client/src/features/ai/components/AiPanel.jsx
index 057982e..d5048c6 100644
--- a/client/src/features/ai/components/AiPanel.jsx
+++ b/client/src/features/ai/components/AiPanel.jsx
@@ -1,21 +1,82 @@
-import React from 'react';
-import { useSelector } from 'react-redux';
-import { X, AlertTriangle } from 'lucide-react';
-import { selectAiExplainState, selectAiImpactState } from '../slices/aiSlice';
+import React, { useEffect, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { X, AlertTriangle, Loader2, Zap } from 'lucide-react';
+import {
+ analyzeImpact,
+ selectAiImpactState,
+} from '../slices/aiSlice';
+import { selectGraphData } from '../../graph/slices/graphSlice';
+import { aiService } from '../services/aiService';
export default function AiPanel({ nodeId, graph, onClose }) {
- if (!nodeId || !graph?.[nodeId]) return null;
+ const dispatch = useDispatch();
+ const graphData = useSelector(selectGraphData);
+ const impactState = useSelector(selectAiImpactState);
+ const jobId = graphData?.jobId;
+ const [streamedText, setStreamedText] = useState('');
+ const [isStreaming, setIsStreaming] = useState(false);
+ const [streamError, setStreamError] = useState('');
+
+ const nodeData = nodeId ? graph?.[nodeId] : null;
+
+ useEffect(() => {
+ if (!nodeId || !jobId) {
+ setStreamedText('');
+ setIsStreaming(false);
+ setStreamError('');
+ return;
+ }
+
+ let isCancelled = false;
+ const controller = new AbortController();
+
+ setStreamedText('');
+ setIsStreaming(true);
+ setStreamError('');
+
+ aiService
+ .streamExplain({
+ question: `Explain the file ${nodeId} and include its purpose, key functions, dependencies, and risks.`,
+ jobId,
+ signal: controller.signal,
+ onChunk: (text) => {
+ if (isCancelled) return;
+ setStreamedText((prev) => prev + text);
+ },
+ onDone: () => {
+ if (isCancelled) return;
+ setIsStreaming(false);
+ },
+ onError: (error) => {
+ if (isCancelled) return;
+ setStreamError(error?.message || 'Failed to load explanation');
+ setIsStreaming(false);
+ },
+ })
+ .catch(() => {
+ // Errors are handled in onError callback.
+ });
+
+ return () => {
+ isCancelled = true;
+ controller.abort();
+ };
+ }, [nodeId, jobId]);
+
+ if (!nodeId || !nodeData) return null;
- const { deps = [], type, declarations = [] } = graph[nodeId];
+ const { deps = [], type, declarations = [], summary } = nodeData;
const usedBy = Object.entries(graph)
.filter(([, value]) => value.deps?.includes(nodeId))
.map(([file]) => file);
- const explainState = useSelector(selectAiExplainState);
- const impactState = useSelector(selectAiImpactState);
-
- const explanation = explainState?.data?.answer || explainState?.data?.explanation || null;
const impactedFiles = impactState?.data?.affectedFiles || [];
+ const isImpacting = impactState?.status === 'loading';
+
+ const handleSimulateImpact = () => {
+ if (!jobId || !nodeId) return;
+ dispatch(analyzeImpact({ jobId, filePath: nodeId }));
+ };
return (
@@ -36,6 +97,33 @@ export default function AiPanel({ nodeId, graph, onClose }) {
Type:
{type}
+ {summary && !streamedText && !isStreaming && !streamError && (
+
+ )}
+
+
+
+ AI Explanation
+
+ {isStreaming && (
+
+
+ Analyzing...
+
+ )}
+ {streamError && (
+
+ {streamError}
+
+ )}
+ {streamedText && (
+
{streamedText}
+ )}
+
+
{declarations.length > 0 && (
@@ -51,12 +139,29 @@ export default function AiPanel({ nodeId, graph, onClose }) {
)}
- {explanation && (
-
-
AI Explanation
-
{explanation}
+
+
+
Impact Analysis
+
- )}
+
+ {impactedFiles.length > 0 && (
+
+
+ {impactedFiles.map((file) => (
+ - {file}
+ ))}
+
+
+ )}
+
{deps.length > 0 && (
@@ -79,20 +184,6 @@ export default function AiPanel({ nodeId, graph, onClose }) {
)}
-
- {impactedFiles.length > 0 && (
-
-
-
- Impacted Files ({impactedFiles.length})
-
-
- {impactedFiles.map((file) => (
- - {file}
- ))}
-
-
- )}
);
}
diff --git a/client/src/features/ai/components/QueryBar.jsx b/client/src/features/ai/components/QueryBar.jsx
index d2ea23e..a4ce3b0 100644
--- a/client/src/features/ai/components/QueryBar.jsx
+++ b/client/src/features/ai/components/QueryBar.jsx
@@ -13,9 +13,9 @@ export default function QueryBar({ jobId }) {
const inputRef = useRef(null);
const { status, data, error } = queryState;
- const isLoading = status === 'pending';
- const hasResult = data && status === 'fulfilled';
- const hasError = error && status === 'rejected';
+ const isLoading = status === 'loading';
+ const hasResult = data && status === 'succeeded';
+ const hasError = error && status === 'failed';
const highlightCount = data?.highlightedFiles?.length || 0;
// Auto-focus input when expanded
diff --git a/client/src/features/ai/components/QueryHistory.jsx b/client/src/features/ai/components/QueryHistory.jsx
new file mode 100644
index 0000000..2abc625
--- /dev/null
+++ b/client/src/features/ai/components/QueryHistory.jsx
@@ -0,0 +1,193 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { useDispatch } from 'react-redux';
+import { ChevronDown, History, Loader2, RotateCw } from 'lucide-react';
+import { queryGraph } from '../slices/aiSlice';
+import { aiService } from '../services/aiService';
+
+const HISTORY_LIMIT = 5;
+
+function formatRelativeDate(value) {
+ if (!value) return null;
+
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return null;
+
+ const now = Date.now();
+ const diffMs = now - date.getTime();
+ const diffMinutes = Math.floor(diffMs / (60 * 1000));
+
+ if (diffMinutes < 1) return 'just now';
+ if (diffMinutes < 60) return `${diffMinutes}m ago`;
+
+ const diffHours = Math.floor(diffMinutes / 60);
+ if (diffHours < 24) return `${diffHours}h ago`;
+
+ const diffDays = Math.floor(diffHours / 24);
+ if (diffDays < 7) return `${diffDays}d ago`;
+
+ return date.toLocaleDateString();
+}
+
+export default function QueryHistory({ jobId }) {
+ const dispatch = useDispatch();
+ const [isOpen, setIsOpen] = useState(false);
+ const [queries, setQueries] = useState([]);
+ const [status, setStatus] = useState('idle');
+ const [error, setError] = useState('');
+
+ const hasQueries = queries.length > 0;
+ const isLoading = status === 'loading';
+
+ useEffect(() => {
+ let cancelled = false;
+
+ async function run() {
+ if (!jobId) {
+ if (!cancelled) {
+ setQueries([]);
+ setStatus('idle');
+ setError('');
+ setIsOpen(false);
+ }
+ return;
+ }
+
+ setStatus('loading');
+ setError('');
+
+ try {
+ const data = await aiService.getQueryHistory({
+ jobId,
+ page: 1,
+ limit: HISTORY_LIMIT,
+ });
+
+ if (cancelled) return;
+
+ setQueries(data.queries || []);
+ setStatus('succeeded');
+ if ((data.queries || []).length === 0) {
+ setIsOpen(false);
+ }
+ } catch (loadError) {
+ if (cancelled) return;
+
+ setQueries([]);
+ setStatus('failed');
+ setError(
+ loadError?.response?.data?.error ||
+ loadError?.message ||
+ 'Failed to load query history.',
+ );
+ }
+ }
+
+ run();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [jobId]);
+
+ const visibleQueries = useMemo(() => queries.slice(0, HISTORY_LIMIT), [queries]);
+
+ if (!jobId) return null;
+
+ return (
+
+
+
+ {isOpen && (
+
+ {error && (
+
{error}
+ )}
+
+ {!error && !isLoading && visibleQueries.length === 0 && (
+
No saved queries for this analysis yet.
+ )}
+
+ {!error && visibleQueries.length > 0 && (
+
+ {visibleQueries.map((queryItem) => (
+ -
+
+
+ ))}
+
+ )}
+
+ {!error && queries.length > HISTORY_LIMIT && (
+
+ Showing most recent {HISTORY_LIMIT} queries.
+
+ )}
+
+ {!error && !isLoading && (
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/client/src/features/ai/index.js b/client/src/features/ai/index.js
index 3d5f8bc..65ebe2e 100644
--- a/client/src/features/ai/index.js
+++ b/client/src/features/ai/index.js
@@ -13,4 +13,5 @@ export {
export { aiService } from './services/aiService';
export { default as QueryBar } from './components/QueryBar';
+export { default as QueryHistory } from './components/QueryHistory';
export { default as AiPanel } from './components/AiPanel';
diff --git a/client/src/features/ai/services/aiService.js b/client/src/features/ai/services/aiService.js
index 1586347..df616d6 100644
--- a/client/src/features/ai/services/aiService.js
+++ b/client/src/features/ai/services/aiService.js
@@ -12,6 +12,18 @@ function normalizeText(value) {
return String(value || '').trim();
}
+function resolveApiUrl(pathname) {
+ const trimmedBase = apiBaseUrl.trim();
+
+ if (!trimmedBase) return pathname;
+
+ if (/^https?:\/\//i.test(trimmedBase)) {
+ return new URL(pathname, trimmedBase).toString();
+ }
+
+ return `${trimmedBase.replace(/\/$/, '')}${pathname}`;
+}
+
function buildExplainQuestion({ filePath, nodeLabel, question }) {
const customQuestion = normalizeText(question);
if (customQuestion) return customQuestion;
@@ -45,6 +57,23 @@ export const aiService = {
});
},
+ async getQueryHistory({ jobId, page = 1, limit = 20 } = {}) {
+ const params = {
+ page: Math.max(1, Number.parseInt(page, 10) || 1),
+ limit: Math.min(50, Math.max(1, Number.parseInt(limit, 10) || 20)),
+ };
+
+ const normalizedJobId = normalizeText(jobId);
+ if (normalizedJobId) params.jobId = normalizedJobId;
+
+ const { data } = await aiClient.get('/api/ai/queries', { params });
+ return {
+ queries: Array.isArray(data?.queries) ? data.queries : [],
+ page: Number.isFinite(data?.page) ? data.page : params.page,
+ limit: Number.isFinite(data?.limit) ? data.limit : params.limit,
+ };
+ },
+
async explainNode({ jobId, filePath, nodeLabel, question }) {
const normalizedJobId = normalizeText(jobId);
if (!normalizedJobId) {
@@ -77,4 +106,101 @@ export const aiService = {
return data;
},
+
+ async streamExplain({ question, jobId, onChunk, onDone, onError, signal } = {}) {
+ const normalizedQuestion = normalizeText(question);
+ const normalizedJobId = normalizeText(jobId);
+
+ if (!normalizedQuestion || !normalizedJobId) {
+ throw new Error('streamExplain requires question and jobId.');
+ }
+
+ const url = resolveApiUrl('/api/ai/explain/stream');
+ const response = await fetch(url, {
+ method: 'POST',
+ credentials: 'include',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ question: normalizedQuestion, jobId: normalizedJobId }),
+ signal,
+ });
+
+ if (!response.ok) {
+ let message = `Streaming request failed with status ${response.status}.`;
+
+ try {
+ const payload = await response.json();
+ if (payload?.error) message = payload.error;
+ } catch {
+ // Ignore JSON parsing failures and keep the fallback message.
+ }
+
+ const error = new Error(message);
+ onError?.(error);
+ throw error;
+ }
+
+ if (!response.body) {
+ const error = new Error('Streaming response body is not available.');
+ onError?.(error);
+ throw error;
+ }
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split('\n');
+ buffer = lines.pop() || '';
+
+ for (const line of lines) {
+ if (!line.startsWith('data: ')) continue;
+
+ const payload = line.slice(6).trim();
+ if (!payload) continue;
+
+ if (payload === '[DONE]') {
+ onDone?.();
+ return;
+ }
+
+ try {
+ const parsed = JSON.parse(payload);
+ if (parsed?.error) {
+ const error = new Error(parsed.error);
+ onError?.(error);
+ throw error;
+ }
+
+ if (parsed?.text) {
+ onChunk?.(parsed.text);
+ }
+ } catch (error) {
+ if (error instanceof SyntaxError) {
+ // Ignore malformed stream chunks and continue receiving valid chunks.
+ continue;
+ }
+
+ throw error;
+ }
+ }
+ }
+
+ onDone?.();
+ } catch (error) {
+ if (error?.name === 'AbortError') {
+ return;
+ }
+
+ onError?.(error);
+ throw error;
+ } finally {
+ reader.releaseLock();
+ }
+ },
};
diff --git a/client/src/features/dashboard/index.js b/client/src/features/dashboard/index.js
index a827480..dd9c992 100644
--- a/client/src/features/dashboard/index.js
+++ b/client/src/features/dashboard/index.js
@@ -3,6 +3,7 @@ export { default as DashboardPage } from './pages/DashboardPage';
export {
fetchAnalyzedRepositories,
fetchRepositoryJobs,
+ toggleRepositoryStar,
selectDashboardStatus,
selectDashboardError,
selectAnalyzedRepositories,
diff --git a/client/src/features/dashboard/pages/DashboardPage.jsx b/client/src/features/dashboard/pages/DashboardPage.jsx
index b384299..bfdacf6 100644
--- a/client/src/features/dashboard/pages/DashboardPage.jsx
+++ b/client/src/features/dashboard/pages/DashboardPage.jsx
@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react';
-import { Link, useSearchParams } from 'react-router-dom';
+import { Link, useSearchParams, useNavigate } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import {
Network,
@@ -15,6 +15,8 @@ import {
ChevronDown,
ChevronUp,
Loader2,
+ Star,
+ RotateCcw,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -37,12 +39,14 @@ import { useAuth } from '@/features/auth/context/AuthContext';
import {
fetchAnalyzedRepositories,
fetchRepositoryJobs,
+ toggleRepositoryStar,
selectAnalyzedRepositories,
selectDashboardError,
selectRepositoryJobsById,
selectDashboardStatus,
selectDashboardSummary,
} from '../index';
+import { analyzeCodebase } from '@/features/graph/slices/graphSlice';
const QUICK_ACTIONS = [
{
@@ -162,6 +166,7 @@ function RepositoryListSkeleton() {
export default function DashboardPage() {
const [searchParams, setSearchParams] = useSearchParams();
const dispatch = useDispatch();
+ const navigate = useNavigate();
const { user } = useAuth();
const [sortBy, setSortBy] = useState(() =>
parseSortFromQuery(searchParams.get('sort')),
@@ -171,6 +176,8 @@ export default function DashboardPage() {
);
const [searchTerm, setSearchTerm] = useState(() => searchParams.get('q') || '');
const [expandedRepos, setExpandedRepos] = useState({});
+ const [starringRepoId, setStarringRepoId] = useState(null);
+ const [reanalyzingRepoId, setReanalyzingRepoId] = useState(null);
const status = useSelector(selectDashboardStatus);
const error = useSelector(selectDashboardError);
@@ -266,6 +273,10 @@ export default function DashboardPage() {
});
return filtered.toSorted((a, b) => {
+ if (a.isStarred !== b.isStarred) {
+ return a.isStarred ? -1 : 1;
+ }
+
if (sortBy === 'oldest') {
return getAnalysisTime(a) - getAnalysisTime(b);
}
@@ -341,6 +352,46 @@ export default function DashboardPage() {
};
};
+ const handleToggleStar = async (repoId, e) => {
+ e?.preventDefault();
+ setStarringRepoId(repoId);
+ try {
+ await dispatch(toggleRepositoryStar({ repositoryId: repoId })).unwrap();
+ } catch (error) {
+ console.error('Failed to toggle star:', error);
+ } finally {
+ setStarringRepoId(null);
+ }
+ };
+
+ const handleReanalyze = (repo, e) => {
+ e?.preventDefault();
+ e?.stopPropagation();
+ setReanalyzingRepoId(repo.id);
+
+ const config =
+ repo.source === 'local'
+ ? {
+ source: 'local',
+ localPath: repo.fullName,
+ }
+ : {
+ source: 'github',
+ github: {
+ mode:
+ repo.githubMode ||
+ (repo.sourceCategory === 'github-public' ? 'public' : 'owned'),
+ owner: repo.owner,
+ repo: repo.name,
+ branch: repo.branch || 'main',
+ },
+ };
+
+ dispatch(analyzeCodebase(config));
+ navigate('/graph');
+ setReanalyzingRepoId(null);
+ };
+
return (
@@ -615,6 +666,36 @@ export default function DashboardPage() {
+
+
+
+
- ) : (
-
- )}
+ ) : null}
{expandedRepos[repo.id] ? (
diff --git a/client/src/features/dashboard/services/dashboardService.js b/client/src/features/dashboard/services/dashboardService.js
index 696c740..2343ddf 100644
--- a/client/src/features/dashboard/services/dashboardService.js
+++ b/client/src/features/dashboard/services/dashboardService.js
@@ -1,11 +1,9 @@
import axios from 'axios';
-const BASE_URL = import.meta.env.VITE_API_BASE_URL
- ? `${import.meta.env.VITE_API_BASE_URL}/api`
- : 'http://localhost:5000/api';
+const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || '';
const dashboardClient = axios.create({
- baseURL: BASE_URL,
+ baseURL: apiBaseUrl,
withCredentials: true,
headers: { 'Content-Type': 'application/json' },
});
@@ -72,6 +70,7 @@ const normalizeRepository = (raw) => {
language: raw?.language ?? null,
visibility: raw?.visibility ?? null,
status: raw?.status ?? raw?.latestJob?.status ?? 'completed',
+ isStarred: raw?.isStarred ?? raw?.is_starred ?? false,
};
};
@@ -103,7 +102,7 @@ const normalizePayload = (payload) => {
export const dashboardService = {
async getAnalyzedRepositories({ userId, page = 1, limit = 25 } = {}) {
- const { data } = await dashboardClient.get('/repositories', {
+ const { data } = await dashboardClient.get('/api/repositories', {
params: {
page,
limit,
@@ -114,7 +113,7 @@ export const dashboardService = {
},
async getRepositoryJobs({ repositoryId, page = 1, limit = 20 } = {}) {
- const { data } = await dashboardClient.get(`/repositories/${repositoryId}/jobs`, {
+ const { data } = await dashboardClient.get(`/api/repositories/${repositoryId}/jobs`, {
params: { page, limit },
});
@@ -138,4 +137,12 @@ export const dashboardService = {
pagination: data?.pagination ?? null,
};
},
+
+ async toggleStar(repositoryId) {
+ const { data } = await dashboardClient.patch(`/api/repositories/${repositoryId}/star`);
+ return {
+ id: data?.id,
+ isStarred: data?.isStarred ?? data?.is_starred ?? false,
+ };
+ },
};
diff --git a/client/src/features/dashboard/slices/dashboardSlice.js b/client/src/features/dashboard/slices/dashboardSlice.js
index 8069df9..aff93e3 100644
--- a/client/src/features/dashboard/slices/dashboardSlice.js
+++ b/client/src/features/dashboard/slices/dashboardSlice.js
@@ -45,6 +45,22 @@ export const fetchRepositoryJobs = createAsyncThunk(
},
);
+export const toggleRepositoryStar = createAsyncThunk(
+ 'dashboard/toggleRepositoryStar',
+ async ({ repositoryId } = {}, { rejectWithValue }) => {
+ try {
+ return await dashboardService.toggleStar(repositoryId);
+ } catch (err) {
+ const backendError = err?.response?.data?.error;
+ return rejectWithValue({
+ repositoryId,
+ code: 'REQUEST_FAILED',
+ message: backendError || err?.message || 'Failed to update repository star.',
+ });
+ }
+ },
+);
+
const initialState = {
repositories: [],
summary: {
@@ -114,6 +130,33 @@ const dashboardSlice = createSlice({
message: 'Could not load repository jobs.',
},
};
+ })
+ .addCase(toggleRepositoryStar.pending, (state, action) => {
+ const repositoryId = action.meta.arg?.repositoryId;
+ if (!repositoryId) return;
+
+ const repository = state.repositories.find((repo) => repo.id === repositoryId);
+ if (repository) {
+ repository.isStarred = !repository.isStarred;
+ }
+ })
+ .addCase(toggleRepositoryStar.fulfilled, (state, action) => {
+ const repositoryId = action.payload?.id;
+ if (!repositoryId) return;
+
+ const repository = state.repositories.find((repo) => repo.id === repositoryId);
+ if (repository) {
+ repository.isStarred = Boolean(action.payload.isStarred);
+ }
+ })
+ .addCase(toggleRepositoryStar.rejected, (state, action) => {
+ const repositoryId = action.payload?.repositoryId || action.meta.arg?.repositoryId;
+ if (!repositoryId) return;
+
+ const repository = state.repositories.find((repo) => repo.id === repositoryId);
+ if (repository) {
+ repository.isStarred = !repository.isStarred;
+ }
});
},
});
diff --git a/client/src/features/graph/components/AnalyzeForm.jsx b/client/src/features/graph/components/AnalyzeForm.jsx
index 32d988a..4d08f57 100644
--- a/client/src/features/graph/components/AnalyzeForm.jsx
+++ b/client/src/features/graph/components/AnalyzeForm.jsx
@@ -1,5 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
+import { useLocation } from 'react-router-dom';
import {
AlertCircle,
CheckCircle2,
@@ -106,6 +107,7 @@ function GitHubModeToggle({ value, onChange, disabled }) {
export default function AnalyzeForm() {
const dispatch = useDispatch();
+ const location = useLocation();
const status = useSelector(selectGraphStatus);
const { isAuthenticated, loginWithGithub } = useAuth();
@@ -144,6 +146,46 @@ export default function AnalyzeForm() {
const isLoading = status === 'loading';
+ // Handle re-analyze: pre-fill form with previous repo configuration
+ useEffect(() => {
+ const reanalyzeConfig = location.state?.reanalyzeConfig;
+ if (!reanalyzeConfig) return;
+
+ const { source: configSource, owner, repo, branch, fullName } = reanalyzeConfig;
+
+ if (configSource === 'local') {
+ // Re-analyzing local repository
+ setSource('local');
+ if (fullName) {
+ setLocalPath(fullName);
+ setLocalValidationState('idle');
+ }
+ } else if (configSource === 'github' || configSource === 'github-owned' || configSource === 'github-public') {
+ // Re-analyzing GitHub repository
+ // Default to 'owned' mode since we have owner and repo available
+ setSource('github');
+ setGitHubMode('owned');
+
+ if (owner && repo) {
+ // Pre-populate with the repo data
+ // This allows the form to show selected repo while still allowing branch selection
+ setSelectedOwnedRepo({
+ id: reanalyzeConfig.id,
+ owner,
+ name: repo,
+ fullName: fullName || `${owner}/${repo}`,
+ defaultBranch: branch || 'main',
+ });
+
+ if (branch) {
+ setOwnedBranch(branch);
+ // Also populate ownedBranches with at least the current branch
+ setOwnedBranches([{ name: branch, isDefault: true }]);
+ }
+ }
+ }
+ }, [location.state?.reanalyzeConfig]);
+
const filteredOwnedRepos = useMemo(() => {
const query = repoQuery.trim().toLowerCase();
if (!query) return ownedRepos;
diff --git a/client/src/features/graph/components/GraphToolbar.jsx b/client/src/features/graph/components/GraphToolbar.jsx
index 16dbce5..e06f7e5 100644
--- a/client/src/features/graph/components/GraphToolbar.jsx
+++ b/client/src/features/graph/components/GraphToolbar.jsx
@@ -1,15 +1,44 @@
import React, { useState, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
-import { RotateCcw, Code2, FolderOpen, FileCode2, Maximize2, Minimize2 } from 'lucide-react';
+import {
+ RotateCcw,
+ Code2,
+ FolderOpen,
+ FileCode2,
+ Maximize2,
+ Minimize2,
+ Share2,
+ Loader2,
+} from 'lucide-react';
import { Button } from '@/components/ui/button';
+import { graphService } from '../services/graphService';
import { clearGraph, selectGraphData } from '../slices/graphSlice';
+async function copyToClipboard(value) {
+ if (navigator?.clipboard?.writeText) {
+ await navigator.clipboard.writeText(value);
+ return;
+ }
+
+ const element = document.createElement('textarea');
+ element.value = value;
+ element.setAttribute('readonly', '');
+ element.style.position = 'absolute';
+ element.style.left = '-9999px';
+ document.body.appendChild(element);
+ element.select();
+ document.execCommand('copy');
+ document.body.removeChild(element);
+}
+
export default function GraphToolbar({ graphContainerId = 'graph-container' }) {
const dispatch = useDispatch();
const navigate = useNavigate();
const data = useSelector(selectGraphData);
const [isFullscreen, setIsFullscreen] = useState(false);
+ const [isSharing, setIsSharing] = useState(false);
+ const [shareFeedback, setShareFeedback] = useState(null);
useEffect(() => {
const handleFullscreenChange = () => {
@@ -20,9 +49,19 @@ export default function GraphToolbar({ graphContainerId = 'graph-container' }) {
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
}, []);
+ useEffect(() => {
+ if (!shareFeedback) return;
+
+ const timeout = window.setTimeout(() => {
+ setShareFeedback(null);
+ }, 3500);
+
+ return () => window.clearTimeout(timeout);
+ }, [shareFeedback]);
+
if (!data) return null;
- const { rootDir, fileCount } = data;
+ const { rootDir, fileCount, jobId } = data;
const handleFullscreen = async () => {
const element = document.getElementById(graphContainerId);
@@ -43,6 +82,32 @@ export default function GraphToolbar({ graphContainerId = 'graph-container' }) {
}
};
+ const handleShare = async () => {
+ if (!jobId || isSharing) return;
+
+ setIsSharing(true);
+
+ try {
+ const { shareUrl } = await graphService.shareGraph(jobId);
+ if (!shareUrl) {
+ throw new Error('Share URL was not returned by the server.');
+ }
+
+ await copyToClipboard(shareUrl);
+ setShareFeedback({
+ type: 'success',
+ message: 'Share link copied to clipboard.',
+ });
+ } catch (error) {
+ setShareFeedback({
+ type: 'error',
+ message: error?.response?.data?.error || error?.message || 'Failed to create share link.',
+ });
+ } finally {
+ setIsSharing(false);
+ }
+ };
+
return (
@@ -69,6 +134,26 @@ export default function GraphToolbar({ graphContainerId = 'graph-container' }) {
+ {shareFeedback?.message && (
+
+ {shareFeedback.message}
+
+ )}
+