Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f40f79a2db | |||
| 603b9a24bf | |||
| ecb6158024 | |||
| ae13ce14c2 | |||
| e3b219288e | |||
| 6204d5fc2b | |||
| f735f0b49b | |||
| a4870fa0c3 | |||
| f1b8cd3a7a |
@@ -1,10 +0,0 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env*
|
||||
*.md
|
||||
tests
|
||||
scripts
|
||||
guides
|
||||
apps/frontend
|
||||
apps/backend
|
||||
@@ -1,165 +0,0 @@
|
||||
# Publish OSS Packages to npm
|
||||
#
|
||||
# Builds and publishes @auto-claude/types and @auto-claude/ui to npm
|
||||
# when a version tag is pushed. Includes a dry-run job on PRs to catch
|
||||
# issues before release.
|
||||
|
||||
name: Publish Packages
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'packages/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- '.github/workflows/publish-packages.yml'
|
||||
|
||||
concurrency:
|
||||
group: publish-packages-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# --------------------------------------------------------------------------
|
||||
# Validate - Typecheck and test before publishing
|
||||
# --------------------------------------------------------------------------
|
||||
validate:
|
||||
name: Validate packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
with:
|
||||
ignore-scripts: 'true'
|
||||
|
||||
- name: Typecheck @auto-claude/types
|
||||
run: npx tsc --project packages/types/tsconfig.json --noEmit
|
||||
|
||||
- name: Typecheck @auto-claude/ui
|
||||
run: npx tsc --project packages/ui/tsconfig.json --noEmit
|
||||
|
||||
- name: Build @auto-claude/types
|
||||
run: npx tsc --project packages/types/tsconfig.build.json
|
||||
|
||||
- name: Build @auto-claude/ui
|
||||
run: npx tsc --project packages/ui/tsconfig.build.json
|
||||
|
||||
- name: Typecheck frontend
|
||||
working-directory: apps/frontend
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Run frontend tests
|
||||
working-directory: apps/frontend
|
||||
run: npm run test
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Dry Run - Verify publish would succeed (PRs only)
|
||||
# --------------------------------------------------------------------------
|
||||
dry-run:
|
||||
name: Publish dry run
|
||||
runs-on: ubuntu-latest
|
||||
needs: validate
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
with:
|
||||
ignore-scripts: 'true'
|
||||
|
||||
- name: Build @auto-claude/types
|
||||
run: npx tsc --project packages/types/tsconfig.build.json
|
||||
|
||||
- name: Build @auto-claude/ui
|
||||
run: npx tsc --project packages/ui/tsconfig.build.json
|
||||
|
||||
- name: Dry run publish @auto-claude/types
|
||||
run: npm publish --workspace packages/types --dry-run
|
||||
|
||||
- name: Dry run publish @auto-claude/ui
|
||||
run: npm publish --workspace packages/ui --dry-run
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Publish - Push packages to npm (tagged releases only)
|
||||
# --------------------------------------------------------------------------
|
||||
publish:
|
||||
name: Publish to npm
|
||||
runs-on: ubuntu-latest
|
||||
needs: validate
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
with:
|
||||
ignore-scripts: 'true'
|
||||
|
||||
- name: Configure npm authentication
|
||||
run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Validate tag matches package versions
|
||||
run: |
|
||||
TAG_VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
TYPES_VERSION=$(node -p "require('./packages/types/package.json').version")
|
||||
UI_VERSION=$(node -p "require('./packages/ui/package.json').version")
|
||||
echo "Tag version: ${TAG_VERSION}"
|
||||
echo "types version: ${TYPES_VERSION}"
|
||||
echo "ui version: ${UI_VERSION}"
|
||||
if [ "$TAG_VERSION" != "$TYPES_VERSION" ]; then
|
||||
echo "::error::Tag v${TAG_VERSION} does not match @auto-claude/types version ${TYPES_VERSION}"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$TAG_VERSION" != "$UI_VERSION" ]; then
|
||||
echo "::error::Tag v${TAG_VERSION} does not match @auto-claude/ui version ${UI_VERSION}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build @auto-claude/types
|
||||
run: npx tsc --project packages/types/tsconfig.build.json
|
||||
|
||||
- name: Build @auto-claude/ui
|
||||
run: npx tsc --project packages/ui/tsconfig.build.json
|
||||
|
||||
- name: Publish @auto-claude/types
|
||||
run: |
|
||||
PACKAGE_VERSION=$(node -p "require('./packages/types/package.json').version")
|
||||
HTTP_CODE=$(npm view "@auto-claude/types@${PACKAGE_VERSION}" version --json 2>&1) && RC=$? || RC=$?
|
||||
if [ $RC -eq 0 ] && echo "$HTTP_CODE" | grep -q "$PACKAGE_VERSION"; then
|
||||
echo "::notice::@auto-claude/types@${PACKAGE_VERSION} already published, skipping"
|
||||
elif echo "$HTTP_CODE" | grep -qi "E404\|not found\|is not in this registry"; then
|
||||
npm publish --workspace packages/types --access public --provenance
|
||||
else
|
||||
echo "::warning::Unexpected error checking @auto-claude/types@${PACKAGE_VERSION}: ${HTTP_CODE}"
|
||||
npm publish --workspace packages/types --access public --provenance
|
||||
fi
|
||||
|
||||
- name: Publish @auto-claude/ui
|
||||
run: |
|
||||
PACKAGE_VERSION=$(node -p "require('./packages/ui/package.json').version")
|
||||
HTTP_CODE=$(npm view "@auto-claude/ui@${PACKAGE_VERSION}" version --json 2>&1) && RC=$? || RC=$?
|
||||
if [ $RC -eq 0 ] && echo "$HTTP_CODE" | grep -q "$PACKAGE_VERSION"; then
|
||||
echo "::notice::@auto-claude/ui@${PACKAGE_VERSION} already published, skipping"
|
||||
elif echo "$HTTP_CODE" | grep -qi "E404\|not found\|is not in this registry"; then
|
||||
npm publish --workspace packages/ui --access public --provenance
|
||||
else
|
||||
echo "::warning::Unexpected error checking @auto-claude/ui@${PACKAGE_VERSION}: ${HTTP_CODE}"
|
||||
npm publish --workspace packages/ui --access public --provenance
|
||||
fi
|
||||
@@ -119,7 +119,6 @@ apps/frontend/node_modules
|
||||
# Build output
|
||||
dist/
|
||||
out/
|
||||
.next
|
||||
*.tsbuildinfo
|
||||
apps/frontend/python-runtime/
|
||||
|
||||
|
||||
+31
-4
@@ -127,6 +127,13 @@ fi
|
||||
if git diff --cached --name-only | grep -q "^apps/backend/.*\.py$"; then
|
||||
echo "Python changes detected, running backend checks..."
|
||||
|
||||
# Detect if we're in a worktree
|
||||
IS_WORKTREE=false
|
||||
if [ -f ".git" ]; then
|
||||
# .git is a file (not directory) in worktrees
|
||||
IS_WORKTREE=true
|
||||
fi
|
||||
|
||||
# Determine ruff command (venv or global)
|
||||
RUFF=""
|
||||
if [ -f "apps/backend/.venv/bin/ruff" ]; then
|
||||
@@ -158,7 +165,16 @@ if git diff --cached --name-only | grep -q "^apps/backend/.*\.py$"; then
|
||||
echo "$STAGED_PY_FILES" | xargs git add
|
||||
fi
|
||||
else
|
||||
echo "Warning: ruff not found, skipping Python linting. Install with: uv pip install ruff"
|
||||
if [ "$IS_WORKTREE" = true ]; then
|
||||
echo ""
|
||||
echo "⚠️ WARNING: ruff not available in this worktree."
|
||||
echo " Python linting checks will be skipped."
|
||||
echo " This is expected for auto-claude worktrees."
|
||||
echo " Full validation will occur when PR is created/merged."
|
||||
echo ""
|
||||
else
|
||||
echo "Warning: ruff not found, skipping Python linting. Install with: uv pip install ruff"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Run pytest (skip slow/integration tests and Windows-incompatible tests for pre-commit speed)
|
||||
@@ -192,17 +208,28 @@ if git diff --cached --name-only | grep -q "^apps/backend/.*\.py$"; then
|
||||
elif [ -d "apps/backend/.venv" ]; then
|
||||
echo "Warning: venv exists but Python not found in it, using system Python"
|
||||
PYTHONPATH=apps/backend python -m pytest tests/ -v --tb=short -x -m "not slow and not integration" -k "not windows_path" $IGNORE_TESTS
|
||||
elif [ "$IS_WORKTREE" = true ]; then
|
||||
echo ""
|
||||
echo "⚠️ WARNING: Python venv not available in this worktree."
|
||||
echo " Python tests will be skipped."
|
||||
echo " This is expected for auto-claude worktrees."
|
||||
echo " Full validation will occur when PR is created/merged."
|
||||
echo ""
|
||||
exit 77 # GNU convention for 'test skipped' (avoids pytest exit-code collision)
|
||||
else
|
||||
echo "Warning: No .venv found in apps/backend, using system Python"
|
||||
PYTHONPATH=apps/backend python -m pytest tests/ -v --tb=short -x -m "not slow and not integration" -k "not windows_path" $IGNORE_TESTS
|
||||
fi
|
||||
)
|
||||
if [ $? -ne 0 ]; then
|
||||
PYTHON_EXIT=$?
|
||||
if [ $PYTHON_EXIT -eq 77 ]; then
|
||||
echo "Backend checks passed! (Python tests skipped — worktree)"
|
||||
elif [ $PYTHON_EXIT -ne 0 ]; then
|
||||
echo "Python tests failed. Please fix failing tests before committing."
|
||||
exit 1
|
||||
else
|
||||
echo "Backend checks passed!"
|
||||
fi
|
||||
|
||||
echo "Backend checks passed!"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -52,6 +52,15 @@ Auto Claude is a desktop application (+ CLI) where users describe a goal and AI
|
||||
|
||||
**Electron path resolution** — For bug fixes in the Electron app, always check path resolution differences between dev and production builds (`app.isPackaged`, `process.resourcesPath`). Paths that work in dev often break when Electron is bundled for production — verify both contexts.
|
||||
|
||||
### Resetting PR Review State
|
||||
|
||||
To fully clear all PR review data so reviews run fresh, delete/reset these three things in `.auto-claude/github/`:
|
||||
|
||||
1. `rm .auto-claude/github/pr/logs_*.json` — review log files
|
||||
2. `rm .auto-claude/github/pr/review_*.json` — review result files
|
||||
3. Reset `pr/index.json` to `{"reviews": [], "last_updated": null}`
|
||||
4. Reset `bot_detection_state.json` to `{"reviewed_commits": {}}` — this is the gatekeeper; without clearing it, the bot detector skips already-seen commits
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
|
||||
@@ -16,17 +16,17 @@
|
||||
### Stable Release
|
||||
|
||||
<!-- STABLE_VERSION_BADGE -->
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.7)
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.5)
|
||||
<!-- STABLE_VERSION_BADGE_END -->
|
||||
|
||||
<!-- STABLE_DOWNLOADS -->
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| **Windows** | [Auto-Claude-2.7.7-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.7/Auto-Claude-2.7.7-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.7-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.7/Auto-Claude-2.7.7-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.7-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.7/Auto-Claude-2.7.7-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.7-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.7/Auto-Claude-2.7.7-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.7-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.7/Auto-Claude-2.7.7-linux-amd64.deb) |
|
||||
| **Windows** | [Auto-Claude-2.7.5-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.5-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.5-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.5-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.5-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Auto-Claude-2.7.5-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-linux-x86_64.flatpak) |
|
||||
<!-- STABLE_DOWNLOADS_END -->
|
||||
|
||||
@@ -35,18 +35,18 @@
|
||||
> ⚠️ Beta releases may contain bugs and breaking changes. [View all releases](https://github.com/AndyMik90/Auto-Claude/releases)
|
||||
|
||||
<!-- BETA_VERSION_BADGE -->
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.6-beta.4)
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.6-beta.5)
|
||||
<!-- BETA_VERSION_BADGE_END -->
|
||||
|
||||
<!-- BETA_DOWNLOADS -->
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| **Windows** | [Auto-Claude-2.7.6-beta.4-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.4/Auto-Claude-2.7.6-beta.4-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.6-beta.4-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.4/Auto-Claude-2.7.6-beta.4-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.6-beta.4-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.4/Auto-Claude-2.7.6-beta.4-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.6-beta.4-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.4/Auto-Claude-2.7.6-beta.4-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.6-beta.4-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.4/Auto-Claude-2.7.6-beta.4-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Auto-Claude-2.7.6-beta.4-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.4/Auto-Claude-2.7.6-beta.4-linux-x86_64.flatpak) |
|
||||
| **Windows** | [Auto-Claude-2.7.6-beta.5-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.5/Auto-Claude-2.7.6-beta.5-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.6-beta.5-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.5/Auto-Claude-2.7.6-beta.5-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.6-beta.5-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.5/Auto-Claude-2.7.6-beta.5-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.6-beta.5-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.5/Auto-Claude-2.7.6-beta.5-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.6-beta.5-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.5/Auto-Claude-2.7.6-beta.5-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Auto-Claude-2.7.6-beta.5-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.5/Auto-Claude-2.7.6-beta.5-linux-x86_64.flatpak) |
|
||||
<!-- BETA_DOWNLOADS_END -->
|
||||
|
||||
> All releases include SHA256 checksums and VirusTotal scan results for security verification.
|
||||
|
||||
@@ -19,5 +19,5 @@ Quick Start:
|
||||
See README.md for full documentation.
|
||||
"""
|
||||
|
||||
__version__ = "2.7.7"
|
||||
__version__ = "2.7.6-beta.5"
|
||||
__author__ = "Auto Claude Team"
|
||||
|
||||
@@ -31,6 +31,7 @@ class ProjectAnalyzer:
|
||||
"""Run full project analysis."""
|
||||
self._detect_project_type()
|
||||
self._find_and_analyze_services()
|
||||
self._aggregate_dependency_locations()
|
||||
self._analyze_infrastructure()
|
||||
self._detect_conventions()
|
||||
self._map_dependencies()
|
||||
@@ -124,6 +125,63 @@ class ProjectAnalyzer:
|
||||
|
||||
self.index["services"] = services
|
||||
|
||||
def _aggregate_dependency_locations(self) -> None:
|
||||
"""Aggregate dependency location metadata from all services.
|
||||
|
||||
Collects dependency_locations from each service and stores them as
|
||||
paths relative to the project root (e.g., 'apps/backend/.venv'
|
||||
instead of just '.venv').
|
||||
"""
|
||||
aggregated: list[dict[str, Any]] = []
|
||||
|
||||
for service_name, service_info in self.index.get("services", {}).items():
|
||||
service_deps = service_info.get("dependency_locations", [])
|
||||
service_path = service_info.get("path", "")
|
||||
|
||||
# Compute service-relative prefix once per service
|
||||
service_rel: Path | None = None
|
||||
if service_path:
|
||||
try:
|
||||
service_rel = Path(service_path).relative_to(self.project_dir)
|
||||
except ValueError:
|
||||
# Service path is outside the project root — skip its deps
|
||||
# to avoid producing absolute paths that bypass containment
|
||||
continue
|
||||
|
||||
for dep in service_deps:
|
||||
dep_path = dep.get("path")
|
||||
if not dep_path:
|
||||
continue
|
||||
|
||||
# Build project-relative path from service path + dep path
|
||||
if service_rel is not None:
|
||||
project_relative = str(service_rel / dep_path)
|
||||
else:
|
||||
project_relative = dep_path
|
||||
|
||||
entry: dict[str, Any] = {
|
||||
"type": dep.get("type", "unknown"),
|
||||
"path": project_relative,
|
||||
"exists": dep.get("exists", False),
|
||||
"service": service_name,
|
||||
}
|
||||
if dep.get("requirements_file"):
|
||||
# Convert to project-relative path like we do for "path"
|
||||
if service_rel is not None:
|
||||
entry["requirements_file"] = str(
|
||||
service_rel / dep["requirements_file"]
|
||||
)
|
||||
else:
|
||||
entry["requirements_file"] = dep["requirements_file"]
|
||||
pkg_mgr = dep.get("package_manager") or service_info.get(
|
||||
"package_manager"
|
||||
)
|
||||
if pkg_mgr:
|
||||
entry["package_manager"] = pkg_mgr
|
||||
aggregated.append(entry)
|
||||
|
||||
self.index["dependency_locations"] = aggregated
|
||||
|
||||
def _analyze_infrastructure(self) -> None:
|
||||
"""Analyze infrastructure configuration."""
|
||||
infra = {}
|
||||
|
||||
@@ -40,6 +40,8 @@ class ServiceAnalyzer(BaseAnalyzer):
|
||||
self._find_key_directories()
|
||||
self._find_entry_points()
|
||||
self._detect_dependencies()
|
||||
self._detect_dependency_locations()
|
||||
self._detect_package_manager()
|
||||
self._detect_testing()
|
||||
self._find_dockerfile()
|
||||
|
||||
@@ -209,6 +211,121 @@ class ServiceAnalyzer(BaseAnalyzer):
|
||||
deps.append(match.group(1))
|
||||
self.analysis["dependencies"] = deps[:20]
|
||||
|
||||
def _detect_dependency_locations(self) -> None:
|
||||
"""Detect where dependencies live on disk for this service."""
|
||||
locations: list[dict[str, Any]] = []
|
||||
|
||||
# Node.js: node_modules (only if package.json exists)
|
||||
if self._exists("package.json"):
|
||||
node_modules = self.path / "node_modules"
|
||||
locations.append(
|
||||
{
|
||||
"type": "node_modules",
|
||||
"path": "node_modules",
|
||||
"exists": node_modules.exists() and node_modules.is_dir(),
|
||||
}
|
||||
)
|
||||
|
||||
# Python: .venv or venv
|
||||
for venv_dir in [".venv", "venv"]:
|
||||
venv_path = self.path / venv_dir
|
||||
if venv_path.exists() and venv_path.is_dir():
|
||||
entry: dict[str, Any] = {
|
||||
"type": "venv",
|
||||
"path": venv_dir,
|
||||
"exists": True,
|
||||
}
|
||||
# Find requirements file
|
||||
for req_file in ["requirements.txt", "pyproject.toml", "Pipfile"]:
|
||||
if self._exists(req_file):
|
||||
entry["requirements_file"] = req_file
|
||||
break
|
||||
locations.append(entry)
|
||||
break
|
||||
else:
|
||||
# No venv found, still record requirements file if present
|
||||
for req_file in ["requirements.txt", "pyproject.toml", "Pipfile"]:
|
||||
if self._exists(req_file):
|
||||
locations.append(
|
||||
{
|
||||
"type": "venv",
|
||||
"path": ".venv",
|
||||
"exists": False,
|
||||
"requirements_file": req_file,
|
||||
}
|
||||
)
|
||||
break
|
||||
|
||||
# PHP: vendor
|
||||
vendor_path = self.path / "vendor"
|
||||
if vendor_path.exists() and vendor_path.is_dir():
|
||||
locations.append(
|
||||
{
|
||||
"type": "vendor_php",
|
||||
"path": "vendor",
|
||||
"exists": True,
|
||||
}
|
||||
)
|
||||
|
||||
# Rust: target
|
||||
target_path = self.path / "target"
|
||||
if target_path.exists() and target_path.is_dir():
|
||||
locations.append(
|
||||
{
|
||||
"type": "cargo_target",
|
||||
"path": "target",
|
||||
"exists": True,
|
||||
}
|
||||
)
|
||||
|
||||
# Ruby: vendor/bundle
|
||||
bundle_path = self.path / "vendor" / "bundle"
|
||||
if bundle_path.exists() and bundle_path.is_dir():
|
||||
locations.append(
|
||||
{
|
||||
"type": "vendor_bundle",
|
||||
"path": "vendor/bundle",
|
||||
"exists": True,
|
||||
}
|
||||
)
|
||||
|
||||
self.analysis["dependency_locations"] = locations
|
||||
|
||||
def _detect_package_manager(self) -> None:
|
||||
"""Detect the package manager used by this service."""
|
||||
# Node.js package managers
|
||||
if self._exists("package-lock.json"):
|
||||
self.analysis["package_manager"] = "npm"
|
||||
elif self._exists("yarn.lock"):
|
||||
self.analysis["package_manager"] = "yarn"
|
||||
elif self._exists("pnpm-lock.yaml"):
|
||||
self.analysis["package_manager"] = "pnpm"
|
||||
elif self._exists("bun.lockb") or self._exists("bun.lock"):
|
||||
self.analysis["package_manager"] = "bun"
|
||||
# Python package managers
|
||||
elif self._exists("Pipfile"):
|
||||
self.analysis["package_manager"] = "pipenv"
|
||||
elif self._exists("pyproject.toml"):
|
||||
if self._exists("uv.lock"):
|
||||
self.analysis["package_manager"] = "uv"
|
||||
elif self._exists("poetry.lock"):
|
||||
self.analysis["package_manager"] = "poetry"
|
||||
else:
|
||||
self.analysis["package_manager"] = "pip"
|
||||
elif self._exists("requirements.txt"):
|
||||
self.analysis["package_manager"] = "pip"
|
||||
# Other
|
||||
elif self._exists("Cargo.toml"):
|
||||
self.analysis["package_manager"] = "cargo"
|
||||
elif self._exists("go.mod"):
|
||||
self.analysis["package_manager"] = "go_mod"
|
||||
elif self._exists("Gemfile"):
|
||||
self.analysis["package_manager"] = "gem"
|
||||
elif self._exists("composer.json"):
|
||||
self.analysis["package_manager"] = "composer"
|
||||
else:
|
||||
self.analysis["package_manager"] = None
|
||||
|
||||
def _detect_testing(self) -> None:
|
||||
"""Detect testing framework and configuration."""
|
||||
if self._exists("package.json"):
|
||||
|
||||
@@ -694,10 +694,25 @@ def get_auth_token(config_dir: str | None = None) -> str | None:
|
||||
Returns:
|
||||
Token string if found, None otherwise
|
||||
"""
|
||||
_debug = os.environ.get("DEBUG", "").lower() in ("true", "1")
|
||||
|
||||
if _debug:
|
||||
# Log which auth env vars are set (presence only, never values)
|
||||
set_vars = [v for v in AUTH_TOKEN_ENV_VARS if os.environ.get(v)]
|
||||
logger.info(
|
||||
"[Auth] get_auth_token() called — config_dir param=%s, "
|
||||
"env vars present: %s, CLAUDE_CONFIG_DIR env=%s",
|
||||
repr(config_dir),
|
||||
set_vars or "(none)",
|
||||
"set" if os.environ.get("CLAUDE_CONFIG_DIR") else "unset",
|
||||
)
|
||||
|
||||
# First check environment variables (highest priority)
|
||||
for var in AUTH_TOKEN_ENV_VARS:
|
||||
token = os.environ.get(var)
|
||||
if token:
|
||||
if _debug:
|
||||
logger.info("[Auth] Token resolved from env var: %s", var)
|
||||
return _try_decrypt_token(token)
|
||||
|
||||
# Check CLAUDE_CONFIG_DIR environment variable (profile's custom config directory)
|
||||
@@ -705,12 +720,13 @@ def get_auth_token(config_dir: str | None = None) -> str | None:
|
||||
effective_config_dir = config_dir or env_config_dir
|
||||
|
||||
# Debug: Log which config_dir is being used for credential resolution
|
||||
debug = os.environ.get("DEBUG", "").lower() in ("true", "1")
|
||||
if debug and effective_config_dir:
|
||||
if _debug and effective_config_dir:
|
||||
service_name = _get_keychain_service_name(effective_config_dir)
|
||||
logger.info(
|
||||
f"[Auth] Resolving credentials for profile config_dir: {effective_config_dir} "
|
||||
f"(Keychain service: {service_name})"
|
||||
"[Auth] Resolving credentials for profile config_dir: %s "
|
||||
"(Keychain service: %s)",
|
||||
effective_config_dir,
|
||||
service_name,
|
||||
)
|
||||
|
||||
# If a custom config directory is specified, read from there first
|
||||
@@ -718,24 +734,37 @@ def get_auth_token(config_dir: str | None = None) -> str | None:
|
||||
# Try reading from .credentials.json file in the config directory
|
||||
token = _get_token_from_config_dir(effective_config_dir)
|
||||
if token:
|
||||
if _debug:
|
||||
logger.info(
|
||||
"[Auth] Token resolved from config dir file: %s",
|
||||
effective_config_dir,
|
||||
)
|
||||
return _try_decrypt_token(token)
|
||||
|
||||
# Also try the system credential store with hash-based service name
|
||||
# This is needed because macOS stores credentials in Keychain, not files
|
||||
token = get_token_from_keychain(effective_config_dir)
|
||||
if token:
|
||||
if _debug:
|
||||
logger.info("[Auth] Token resolved from Keychain (profile-specific)")
|
||||
return _try_decrypt_token(token)
|
||||
|
||||
# If config_dir was explicitly provided, DON'T fall back to default keychain
|
||||
# - that would return the wrong profile's token
|
||||
logger.debug(
|
||||
f"No credentials found for config_dir '{effective_config_dir}' "
|
||||
"in file or keychain"
|
||||
"No credentials found for config_dir '%s' in file or keychain",
|
||||
effective_config_dir,
|
||||
)
|
||||
return None
|
||||
|
||||
# No config_dir specified - use default system credential store
|
||||
return _try_decrypt_token(get_token_from_keychain())
|
||||
keychain_token = get_token_from_keychain()
|
||||
if _debug:
|
||||
logger.info(
|
||||
"[Auth] Token resolved from default Keychain: %s",
|
||||
"found" if keychain_token else "not found",
|
||||
)
|
||||
return _try_decrypt_token(keychain_token)
|
||||
|
||||
|
||||
def get_auth_token_source(config_dir: str | None = None) -> str | None:
|
||||
@@ -970,8 +999,18 @@ def configure_sdk_authentication(config_dir: str | None = None) -> None:
|
||||
- API profile mode: requires ANTHROPIC_AUTH_TOKEN
|
||||
- OAuth mode: requires CLAUDE_CODE_OAUTH_TOKEN (from Keychain or env)
|
||||
"""
|
||||
_debug = os.environ.get("DEBUG", "").lower() in ("true", "1")
|
||||
api_profile_mode = bool(os.environ.get("ANTHROPIC_BASE_URL", "").strip())
|
||||
|
||||
if _debug:
|
||||
logger.info(
|
||||
"[Auth] configure_sdk_authentication() — mode=%s, config_dir=%s, "
|
||||
"CLAUDE_CONFIG_DIR env=%s",
|
||||
"api_profile" if api_profile_mode else "oauth",
|
||||
repr(config_dir),
|
||||
"set" if os.environ.get("CLAUDE_CONFIG_DIR") else "unset",
|
||||
)
|
||||
|
||||
if api_profile_mode:
|
||||
# API profile mode: ensure ANTHROPIC_AUTH_TOKEN is present
|
||||
if not os.environ.get("ANTHROPIC_AUTH_TOKEN"):
|
||||
@@ -999,6 +1038,14 @@ def configure_sdk_authentication(config_dir: str | None = None) -> None:
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
|
||||
logger.info("Using OAuth authentication")
|
||||
|
||||
if _debug:
|
||||
logger.info(
|
||||
"[Auth] SDK env check — CLAUDE_CONFIG_DIR=%s, "
|
||||
"CLAUDE_CODE_OAUTH_TOKEN=%s",
|
||||
"set" if os.environ.get("CLAUDE_CONFIG_DIR") else "unset",
|
||||
"set" if os.environ.get("CLAUDE_CODE_OAUTH_TOKEN") else "unset",
|
||||
)
|
||||
|
||||
|
||||
def ensure_claude_code_oauth_token() -> None:
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
Dependency Strategy Mapping
|
||||
============================
|
||||
|
||||
Maps dependency types to sharing strategies for worktree creation.
|
||||
|
||||
Each dependency ecosystem has different constraints:
|
||||
|
||||
- **node_modules**: Safe to symlink. Node's resolution algorithm follows symlinks
|
||||
correctly, and the directory is self-contained.
|
||||
|
||||
- **venv / .venv**: Must be recreated. Python's ``pyvenv.cfg`` discovery walks the
|
||||
real directory hierarchy without resolving symlinks (CPython bug #106045), so a
|
||||
symlinked venv resolves paths relative to the *target*, not the worktree.
|
||||
|
||||
- **vendor (PHP)**: Safe to symlink. Composer's autoloader uses ``__DIR__``-relative
|
||||
paths that resolve correctly through symlinks.
|
||||
|
||||
- **cargo target / go modules**: Skip entirely. Rust's ``target/`` dir contains
|
||||
per-machine build artifacts that must be rebuilt. Go uses a global module cache
|
||||
(``$GOPATH/pkg/mod``), so there is nothing in-tree to share.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from .models import DependencyShareConfig, DependencyStrategy
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default strategy map
|
||||
# ---------------------------------------------------------------------------
|
||||
# Maps dependency type identifiers to the strategy that should be used when
|
||||
# sharing that dependency across worktrees. Data-driven — add new entries
|
||||
# here rather than writing if/else branches.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_STRATEGY_MAP: dict[str, DependencyStrategy] = {
|
||||
# JavaScript / Node.js — symlink is safe and fast
|
||||
"node_modules": DependencyStrategy.SYMLINK,
|
||||
# Python — venvs MUST be recreated (pyvenv.cfg symlink bug)
|
||||
"venv": DependencyStrategy.RECREATE,
|
||||
".venv": DependencyStrategy.RECREATE,
|
||||
# PHP — Composer vendor dir is safe to symlink
|
||||
"vendor_php": DependencyStrategy.SYMLINK,
|
||||
# Ruby — Bundler vendor/bundle is safe to symlink
|
||||
"vendor_bundle": DependencyStrategy.SYMLINK,
|
||||
# Rust — build output dir, skip (rebuilt per-worktree)
|
||||
"cargo_target": DependencyStrategy.SKIP,
|
||||
# Go — global module cache, nothing in-tree to share
|
||||
"go_modules": DependencyStrategy.SKIP,
|
||||
}
|
||||
|
||||
|
||||
def get_dependency_configs(
|
||||
project_index: dict | None,
|
||||
project_dir: Path | None = None,
|
||||
) -> list[DependencyShareConfig]:
|
||||
"""Derive dependency share configs from a project index.
|
||||
|
||||
If *project_index* is ``None`` or lacks ``dependency_locations``,
|
||||
falls back to a hardcoded node_modules config for backward compatibility
|
||||
with existing worktree setups.
|
||||
|
||||
Args:
|
||||
project_index: Parsed ``project_index.json`` dict, or ``None``.
|
||||
project_dir: Project root directory for resolved-path containment
|
||||
checks (defense-in-depth). Should always be provided when
|
||||
*project_index* is not ``None`` — omitting it disables the
|
||||
resolved-path security check.
|
||||
|
||||
Returns:
|
||||
List of :class:`DependencyShareConfig` objects — one per discovered
|
||||
dependency location.
|
||||
"""
|
||||
|
||||
configs: list[DependencyShareConfig] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
if project_index is not None:
|
||||
if project_dir is None:
|
||||
logger.warning(
|
||||
"get_dependency_configs called with project_index but no "
|
||||
"project_dir — resolved-path containment check is disabled"
|
||||
)
|
||||
|
||||
# Use the aggregated top-level dependency_locations which already
|
||||
# contain project-relative paths (e.g. "apps/backend/.venv" instead
|
||||
# of just ".venv"). This avoids a monorepo path resolution bug
|
||||
# where service-relative paths were incorrectly treated as project-
|
||||
# relative.
|
||||
dep_locations = project_index.get("dependency_locations") or []
|
||||
for dep in dep_locations:
|
||||
if not isinstance(dep, dict):
|
||||
continue
|
||||
|
||||
dep_type = dep.get("type", "")
|
||||
rel_path = dep.get("path", "")
|
||||
|
||||
if not dep_type or not rel_path:
|
||||
continue
|
||||
|
||||
# Path containment: reject absolute paths and traversals.
|
||||
# Check both POSIX and Windows path styles for cross-platform safety.
|
||||
p = PurePosixPath(rel_path)
|
||||
if p.is_absolute() or PureWindowsPath(rel_path).is_absolute():
|
||||
continue
|
||||
if ".." in p.parts or ".." in PureWindowsPath(rel_path).parts:
|
||||
continue
|
||||
|
||||
# Defense-in-depth: verify the resolved path stays within project_dir
|
||||
if project_dir is not None:
|
||||
resolved = (project_dir / rel_path).resolve()
|
||||
if not str(resolved).startswith(str(project_dir.resolve()) + os.sep):
|
||||
continue
|
||||
|
||||
# Deduplicate by relative path
|
||||
if rel_path in seen:
|
||||
continue
|
||||
seen.add(rel_path)
|
||||
|
||||
strategy = DEFAULT_STRATEGY_MAP.get(dep_type, DependencyStrategy.SKIP)
|
||||
|
||||
# Validate requirements_file path containment too
|
||||
req_file = dep.get("requirements_file")
|
||||
if req_file:
|
||||
rp = PurePosixPath(req_file)
|
||||
if (
|
||||
rp.is_absolute()
|
||||
or PureWindowsPath(req_file).is_absolute()
|
||||
or ".." in rp.parts
|
||||
or ".." in PureWindowsPath(req_file).parts
|
||||
):
|
||||
req_file = None
|
||||
|
||||
# Defense-in-depth: resolved-path containment (matches rel_path check)
|
||||
if req_file and project_dir is not None:
|
||||
resolved_req = (project_dir / req_file).resolve()
|
||||
if not str(resolved_req).startswith(
|
||||
str(project_dir.resolve()) + os.sep
|
||||
):
|
||||
req_file = None
|
||||
|
||||
configs.append(
|
||||
DependencyShareConfig(
|
||||
dep_type=dep_type,
|
||||
strategy=strategy,
|
||||
source_rel_path=rel_path,
|
||||
requirements_file=req_file,
|
||||
package_manager=dep.get("package_manager"),
|
||||
)
|
||||
)
|
||||
|
||||
# Fallback: if no configs were discovered, default to node_modules-only
|
||||
# so existing worktree behaviour is preserved.
|
||||
if not configs:
|
||||
configs.append(
|
||||
DependencyShareConfig(
|
||||
dep_type="node_modules",
|
||||
strategy=DependencyStrategy.SYMLINK,
|
||||
source_rel_path="node_modules",
|
||||
)
|
||||
)
|
||||
configs.append(
|
||||
DependencyShareConfig(
|
||||
dep_type="node_modules",
|
||||
strategy=DependencyStrategy.SYMLINK,
|
||||
source_rel_path="apps/frontend/node_modules",
|
||||
)
|
||||
)
|
||||
|
||||
return configs
|
||||
@@ -273,3 +273,31 @@ class SpecNumberLock:
|
||||
pass
|
||||
|
||||
return max_num
|
||||
|
||||
|
||||
class DependencyStrategy(Enum):
|
||||
"""Strategy for sharing dependency directories across worktrees.
|
||||
|
||||
SYMLINK is fast but unsafe for certain ecosystems. Notably, Python venv
|
||||
breaks when symlinked because CPython's pyvenv.cfg discovery walks the
|
||||
real directory hierarchy without resolving symlinks first
|
||||
(CPython bug #106045). This means a symlinked venv resolves its home
|
||||
path relative to the symlink target's parent, not the worktree, causing
|
||||
import failures and broken interpreters.
|
||||
"""
|
||||
|
||||
SYMLINK = "symlink" # Create a symlink to the source (fast, works for node_modules)
|
||||
RECREATE = "recreate" # Re-run the package manager to create a fresh copy
|
||||
COPY = "copy" # Deep-copy the directory (slow but always correct)
|
||||
SKIP = "skip" # Do nothing; let the agent handle it
|
||||
|
||||
|
||||
@dataclass
|
||||
class DependencyShareConfig:
|
||||
"""Configuration for how a specific dependency type should be shared."""
|
||||
|
||||
dep_type: str # e.g. "node_modules", "venv", ".venv"
|
||||
strategy: DependencyStrategy
|
||||
source_rel_path: str # Relative path from project root, e.g. "node_modules"
|
||||
requirements_file: str | None = None # e.g. "requirements.txt", "pyproject.toml"
|
||||
package_manager: str | None = None # e.g. "npm", "uv", "pip"
|
||||
|
||||
@@ -14,6 +14,7 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
from core.git_executable import run_git
|
||||
from core.platform import is_windows
|
||||
from merge import FileTimelineTracker
|
||||
from security.constants import ALLOWLIST_FILENAME, PROFILE_FILENAME
|
||||
from ui import (
|
||||
@@ -28,8 +29,9 @@ from ui import (
|
||||
)
|
||||
from worktree import WorktreeManager
|
||||
|
||||
from .dependency_strategy import get_dependency_configs
|
||||
from .git_utils import has_uncommitted_changes
|
||||
from .models import WorkspaceMode
|
||||
from .models import DependencyShareConfig, DependencyStrategy, WorkspaceMode
|
||||
|
||||
# Import debug utilities
|
||||
try:
|
||||
@@ -189,11 +191,37 @@ def symlink_node_modules_to_worktree(
|
||||
"""
|
||||
Symlink node_modules directories from project root to worktree.
|
||||
|
||||
This ensures the worktree has access to dependencies for TypeScript checks
|
||||
and other tooling without requiring a separate npm install.
|
||||
.. deprecated::
|
||||
Use :func:`setup_worktree_dependencies` instead, which handles all
|
||||
dependency types (node_modules, venvs, vendor dirs, etc.) via
|
||||
strategy-based dispatch.
|
||||
|
||||
Works with npm workspace hoisting where dependencies are hoisted to root
|
||||
and workspace-specific dependencies remain in nested node_modules.
|
||||
This is a thin backward-compatibility wrapper that delegates to
|
||||
``setup_worktree_dependencies()`` with no project index (fallback mode).
|
||||
|
||||
Args:
|
||||
project_dir: The main project directory
|
||||
worktree_path: Path to the worktree
|
||||
|
||||
Returns:
|
||||
List of symlinked paths (relative to worktree)
|
||||
"""
|
||||
results = setup_worktree_dependencies(
|
||||
project_dir, worktree_path, project_index=None
|
||||
)
|
||||
# Flatten all processed paths for backward-compatible return value
|
||||
return [path for paths in results.values() for path in paths]
|
||||
|
||||
|
||||
def symlink_claude_config_to_worktree(
|
||||
project_dir: Path, worktree_path: Path
|
||||
) -> list[str]:
|
||||
"""
|
||||
Symlink .claude/ directory from project root to worktree.
|
||||
|
||||
This ensures the worktree has access to Claude Code configuration
|
||||
(settings, CLAUDE.md, MCP servers, etc.) so that terminals opened
|
||||
in the worktree behave identically to the project root.
|
||||
|
||||
Args:
|
||||
project_dir: The main project directory
|
||||
@@ -204,81 +232,52 @@ def symlink_node_modules_to_worktree(
|
||||
"""
|
||||
symlinked = []
|
||||
|
||||
# Node modules locations to symlink for TypeScript and tooling support.
|
||||
# These are the standard locations for this monorepo structure.
|
||||
#
|
||||
# Design rationale:
|
||||
# - Hardcoded paths are intentional for simplicity and reliability
|
||||
# - Dynamic discovery (reading workspaces from package.json) would add complexity
|
||||
# and potential failure points without significant benefit
|
||||
# - This monorepo uses npm workspaces with hoisting, so dependencies are primarily
|
||||
# in root node_modules with workspace-specific deps in apps/frontend/node_modules
|
||||
#
|
||||
# To add new workspace locations:
|
||||
# 1. Add (source_rel, target_rel) tuple below
|
||||
# 2. Update the parallel TypeScript implementation in
|
||||
# apps/frontend/src/main/ipc-handlers/terminal/worktree-handlers.ts
|
||||
# 3. Update the pre-commit hook check in .husky/pre-commit if needed
|
||||
node_modules_locations = [
|
||||
("node_modules", "node_modules"),
|
||||
("apps/frontend/node_modules", "apps/frontend/node_modules"),
|
||||
]
|
||||
source_path = project_dir / ".claude"
|
||||
target_path = worktree_path / ".claude"
|
||||
|
||||
for source_rel, target_rel in node_modules_locations:
|
||||
source_path = project_dir / source_rel
|
||||
target_path = worktree_path / target_rel
|
||||
# Skip if source doesn't exist
|
||||
if not source_path.exists():
|
||||
debug(MODULE, "Skipping .claude/ - source does not exist")
|
||||
return symlinked
|
||||
|
||||
# Skip if source doesn't exist
|
||||
if not source_path.exists():
|
||||
debug(MODULE, f"Skipping {source_rel} - source does not exist")
|
||||
continue
|
||||
# Skip if target already exists
|
||||
if target_path.exists():
|
||||
debug(MODULE, "Skipping .claude/ - target already exists")
|
||||
return symlinked
|
||||
|
||||
# Skip if target already exists (don't overwrite existing node_modules)
|
||||
if target_path.exists():
|
||||
debug(MODULE, f"Skipping {target_rel} - target already exists")
|
||||
continue
|
||||
# Also skip if target is a symlink (even if broken)
|
||||
if target_path.is_symlink():
|
||||
debug(MODULE, "Skipping .claude/ - symlink already exists (possibly broken)")
|
||||
return symlinked
|
||||
|
||||
# Also skip if target is a symlink (even if broken - exists() returns False for broken symlinks)
|
||||
if target_path.is_symlink():
|
||||
debug(
|
||||
MODULE,
|
||||
f"Skipping {target_rel} - symlink already exists (possibly broken)",
|
||||
)
|
||||
continue
|
||||
|
||||
# Ensure parent directory exists
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
# On Windows, use junctions instead of symlinks (no admin rights required)
|
||||
# Junctions require absolute paths
|
||||
result = subprocess.run(
|
||||
["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise OSError(result.stderr or "mklink /J failed")
|
||||
else:
|
||||
# On macOS/Linux, use relative symlinks for portability
|
||||
relative_source = os.path.relpath(source_path, target_path.parent)
|
||||
os.symlink(relative_source, target_path)
|
||||
symlinked.append(target_rel)
|
||||
debug(MODULE, f"Symlinked {target_rel} -> {source_path}")
|
||||
except OSError as e:
|
||||
# Symlink/junction creation can fail on some systems (e.g., FAT32 filesystem)
|
||||
# Log warning but don't fail - worktree is still usable, just without
|
||||
# TypeScript checking
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"Could not symlink {target_rel}: {e}. TypeScript checks may fail.",
|
||||
)
|
||||
# Warn user - pre-commit hooks may fail without dependencies
|
||||
print_status(
|
||||
f"Warning: Could not link {target_rel} - TypeScript checks may fail",
|
||||
"warning",
|
||||
# Ensure parent directory exists
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
# On Windows, use junctions instead of symlinks (no admin rights required)
|
||||
result = subprocess.run(
|
||||
["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise OSError(result.stderr or "mklink /J failed")
|
||||
else:
|
||||
# On macOS/Linux, use relative symlinks for portability
|
||||
relative_source = os.path.relpath(source_path, target_path.parent)
|
||||
os.symlink(relative_source, target_path)
|
||||
symlinked.append(".claude")
|
||||
debug(MODULE, f"Symlinked .claude/ -> {source_path}")
|
||||
except OSError as e:
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"Could not symlink .claude/: {e}. Claude Code features may not work in worktree terminals.",
|
||||
)
|
||||
print_status(
|
||||
"Warning: Could not link .claude/ - Claude Code features may not work in terminals",
|
||||
"warning",
|
||||
)
|
||||
|
||||
return symlinked
|
||||
|
||||
@@ -374,13 +373,33 @@ def setup_workspace(
|
||||
f"Environment files copied: {', '.join(copied_env_files)}", "success"
|
||||
)
|
||||
|
||||
# Symlink node_modules to worktree for TypeScript and tooling support
|
||||
# This allows pre-commit hooks to run typecheck without npm install in worktree
|
||||
symlinked_modules = symlink_node_modules_to_worktree(
|
||||
# Set up dependencies in worktree using strategy-based dispatch
|
||||
# Load project index if available for ecosystem-aware dependency handling
|
||||
project_index = None
|
||||
project_index_path = project_dir / ".auto-claude" / "project_index.json"
|
||||
if project_index_path.is_file():
|
||||
try:
|
||||
with open(project_index_path, encoding="utf-8") as f:
|
||||
project_index = json.load(f)
|
||||
debug(MODULE, "Loaded project_index.json for dependency setup")
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
debug_warning(MODULE, f"Could not load project_index.json: {e}")
|
||||
|
||||
dep_results = setup_worktree_dependencies(
|
||||
project_dir, worktree_info.path, project_index=project_index
|
||||
)
|
||||
for strategy_name, paths in dep_results.items():
|
||||
if paths:
|
||||
print_status(
|
||||
f"Dependencies ({strategy_name}): {', '.join(paths)}", "success"
|
||||
)
|
||||
|
||||
# Symlink .claude/ config to worktree for Claude Code features (settings, commands, etc.)
|
||||
symlinked_claude = symlink_claude_config_to_worktree(
|
||||
project_dir, worktree_info.path
|
||||
)
|
||||
if symlinked_modules:
|
||||
print_status(f"Dependencies linked: {', '.join(symlinked_modules)}", "success")
|
||||
if symlinked_claude:
|
||||
print_status(f"Claude config linked: {', '.join(symlinked_claude)}", "success")
|
||||
|
||||
# Copy security configuration files if they exist
|
||||
# Note: Unlike env files, security files always overwrite to ensure
|
||||
@@ -574,6 +593,299 @@ def initialize_timeline_tracking(
|
||||
print(muted(f" Note: Timeline tracking could not be initialized: {e}"))
|
||||
|
||||
|
||||
def setup_worktree_dependencies(
|
||||
project_dir: Path,
|
||||
worktree_path: Path,
|
||||
project_index: dict | None = None,
|
||||
) -> dict[str, list[str]]:
|
||||
"""
|
||||
Set up dependencies in a worktree using strategy-based dispatch.
|
||||
|
||||
Reads dependency configs from the project index and applies the correct
|
||||
strategy for each: symlink, recreate, copy, or skip.
|
||||
|
||||
All operations are non-blocking — failures produce warnings but do not
|
||||
prevent worktree creation.
|
||||
|
||||
Args:
|
||||
project_dir: The main project directory
|
||||
worktree_path: Path to the worktree
|
||||
project_index: Parsed project_index.json dict, or None
|
||||
|
||||
Returns:
|
||||
Dict mapping strategy names to lists of paths that were processed.
|
||||
"""
|
||||
configs = get_dependency_configs(project_index, project_dir=project_dir)
|
||||
results: dict[str, list[str]] = {}
|
||||
|
||||
for config in configs:
|
||||
strategy_name = config.strategy.value
|
||||
if strategy_name not in results:
|
||||
results[strategy_name] = []
|
||||
|
||||
try:
|
||||
performed = True
|
||||
if config.strategy == DependencyStrategy.SYMLINK:
|
||||
performed = _apply_symlink_strategy(project_dir, worktree_path, config)
|
||||
elif config.strategy == DependencyStrategy.RECREATE:
|
||||
performed = _apply_recreate_strategy(project_dir, worktree_path, config)
|
||||
elif config.strategy == DependencyStrategy.COPY:
|
||||
performed = _apply_copy_strategy(project_dir, worktree_path, config)
|
||||
elif config.strategy == DependencyStrategy.SKIP:
|
||||
_apply_skip_strategy(config)
|
||||
# Don't record skipped entries — only report actual work
|
||||
continue
|
||||
if performed:
|
||||
results[strategy_name].append(config.source_rel_path)
|
||||
except Exception as e:
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"Failed to apply {strategy_name} strategy for "
|
||||
f"{config.source_rel_path}: {e}",
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _apply_symlink_strategy(
|
||||
project_dir: Path,
|
||||
worktree_path: Path,
|
||||
config: DependencyShareConfig,
|
||||
) -> bool:
|
||||
"""Create a symlink (or Windows junction) from worktree to project source.
|
||||
|
||||
Returns True if a symlink was created, False if skipped.
|
||||
"""
|
||||
source_path = project_dir / config.source_rel_path
|
||||
target_path = worktree_path / config.source_rel_path
|
||||
|
||||
if not source_path.exists():
|
||||
debug(MODULE, f"Skipping symlink {config.source_rel_path} - source missing")
|
||||
return False
|
||||
|
||||
if target_path.exists() or target_path.is_symlink():
|
||||
debug(MODULE, f"Skipping symlink {config.source_rel_path} - target exists")
|
||||
return False
|
||||
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
if is_windows():
|
||||
# Windows: use directory junctions (no admin rights required).
|
||||
# os.symlink creates a directory symlink that needs admin/DevMode,
|
||||
# so we use mklink /J which creates a junction without privileges.
|
||||
result = subprocess.run(
|
||||
["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise OSError(result.stderr or "mklink /J failed")
|
||||
else:
|
||||
# macOS/Linux: relative symlinks for portability
|
||||
relative_source = os.path.relpath(source_path, target_path.parent)
|
||||
os.symlink(relative_source, target_path)
|
||||
debug(MODULE, f"Symlinked {config.source_rel_path} -> {source_path}")
|
||||
return True
|
||||
except subprocess.TimeoutExpired:
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"Symlink creation timed out for {config.source_rel_path}",
|
||||
)
|
||||
print_status(
|
||||
f"Warning: Symlink creation timed out for {config.source_rel_path}",
|
||||
"warning",
|
||||
)
|
||||
return False
|
||||
except OSError as e:
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"Could not symlink {config.source_rel_path}: {e}",
|
||||
)
|
||||
print_status(f"Warning: Could not link {config.source_rel_path}", "warning")
|
||||
return False
|
||||
|
||||
|
||||
def _apply_recreate_strategy(
|
||||
project_dir: Path,
|
||||
worktree_path: Path,
|
||||
config: DependencyShareConfig,
|
||||
) -> bool:
|
||||
"""Create a fresh virtual environment in the worktree and install deps.
|
||||
|
||||
Returns True if the venv was successfully created, False if skipped or failed.
|
||||
"""
|
||||
venv_path = worktree_path / config.source_rel_path
|
||||
|
||||
if venv_path.exists():
|
||||
debug(MODULE, f"Skipping recreate {config.source_rel_path} - already exists")
|
||||
return False
|
||||
|
||||
# Detect Python executable from the source venv or fall back to sys.executable
|
||||
source_venv = project_dir / config.source_rel_path
|
||||
python_exec = sys.executable
|
||||
|
||||
if source_venv.exists():
|
||||
# Try to use the same Python version as the source venv
|
||||
for candidate in ("bin/python", "Scripts/python.exe"):
|
||||
candidate_path = source_venv / candidate
|
||||
if candidate_path.exists():
|
||||
python_exec = str(candidate_path.resolve())
|
||||
break
|
||||
|
||||
# Create the venv
|
||||
try:
|
||||
debug(MODULE, f"Creating venv at {venv_path}")
|
||||
result = subprocess.run(
|
||||
[python_exec, "-m", "venv", str(venv_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
debug_warning(MODULE, f"venv creation failed: {result.stderr}")
|
||||
print_status(
|
||||
f"Warning: Could not create venv at {config.source_rel_path}",
|
||||
"warning",
|
||||
)
|
||||
# Clean up partial venv so retries aren't blocked
|
||||
if venv_path.exists():
|
||||
shutil.rmtree(venv_path, ignore_errors=True)
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
debug_warning(MODULE, f"venv creation timed out for {config.source_rel_path}")
|
||||
print_status(
|
||||
f"Warning: venv creation timed out for {config.source_rel_path}",
|
||||
"warning",
|
||||
)
|
||||
# Clean up partial venv so retries aren't blocked
|
||||
if venv_path.exists():
|
||||
shutil.rmtree(venv_path, ignore_errors=True)
|
||||
return False
|
||||
|
||||
# Install from requirements file if specified
|
||||
req_file = config.requirements_file
|
||||
if req_file:
|
||||
req_path = project_dir / req_file
|
||||
if req_path.is_file():
|
||||
# Determine pip executable inside the new venv
|
||||
if is_windows():
|
||||
pip_exec = str(venv_path / "Scripts" / "pip.exe")
|
||||
else:
|
||||
pip_exec = str(venv_path / "bin" / "pip")
|
||||
|
||||
# Build install command based on file type
|
||||
req_basename = Path(req_file).name
|
||||
if req_basename == "pyproject.toml":
|
||||
# pyproject.toml: snapshot-install from the worktree copy.
|
||||
# Non-editable so the venv doesn't symlink back to the source.
|
||||
worktree_req = worktree_path / req_file
|
||||
install_dir = str(
|
||||
worktree_req.parent if worktree_req.is_file() else req_path.parent
|
||||
)
|
||||
install_cmd = [pip_exec, "install", install_dir]
|
||||
elif req_basename == "Pipfile":
|
||||
# Pipfile: not directly installable via pip, skip
|
||||
debug(
|
||||
MODULE,
|
||||
f"Skipping Pipfile-based install for {req_file} "
|
||||
"(use pipenv in the worktree)",
|
||||
)
|
||||
install_cmd = None
|
||||
else:
|
||||
# requirements.txt or similar: pip install -r
|
||||
install_cmd = [pip_exec, "install", "-r", str(req_path)]
|
||||
|
||||
if install_cmd:
|
||||
try:
|
||||
debug(MODULE, f"Installing deps from {req_file}")
|
||||
pip_result = subprocess.run(
|
||||
install_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
if pip_result.returncode != 0:
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"pip install failed (exit {pip_result.returncode}): "
|
||||
f"{pip_result.stderr}",
|
||||
)
|
||||
print_status(
|
||||
f"Warning: Dependency install failed for {req_file}",
|
||||
"warning",
|
||||
)
|
||||
# Clean up broken venv so retries aren't blocked
|
||||
if venv_path.exists():
|
||||
shutil.rmtree(venv_path, ignore_errors=True)
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"pip install timed out for {req_file}",
|
||||
)
|
||||
print_status(
|
||||
f"Warning: Dependency install timed out for {req_file}",
|
||||
"warning",
|
||||
)
|
||||
# Clean up broken venv so retries aren't blocked
|
||||
if venv_path.exists():
|
||||
shutil.rmtree(venv_path, ignore_errors=True)
|
||||
return False
|
||||
except OSError as e:
|
||||
debug_warning(MODULE, f"pip install failed: {e}")
|
||||
# Clean up broken venv so retries aren't blocked
|
||||
if venv_path.exists():
|
||||
shutil.rmtree(venv_path, ignore_errors=True)
|
||||
return False
|
||||
|
||||
debug(MODULE, f"Recreated venv at {config.source_rel_path}")
|
||||
return True
|
||||
|
||||
|
||||
def _apply_copy_strategy(
|
||||
project_dir: Path,
|
||||
worktree_path: Path,
|
||||
config: DependencyShareConfig,
|
||||
) -> bool:
|
||||
"""Deep-copy a dependency directory from project to worktree.
|
||||
|
||||
Returns True if the copy was performed, False if skipped.
|
||||
"""
|
||||
source_path = project_dir / config.source_rel_path
|
||||
target_path = worktree_path / config.source_rel_path
|
||||
|
||||
if not source_path.exists():
|
||||
debug(MODULE, f"Skipping copy {config.source_rel_path} - source missing")
|
||||
return False
|
||||
|
||||
if target_path.exists():
|
||||
debug(MODULE, f"Skipping copy {config.source_rel_path} - target exists")
|
||||
return False
|
||||
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
if source_path.is_file():
|
||||
shutil.copy2(source_path, target_path)
|
||||
else:
|
||||
shutil.copytree(source_path, target_path)
|
||||
debug(MODULE, f"Copied {config.source_rel_path} to worktree")
|
||||
return True
|
||||
except (OSError, shutil.Error) as e:
|
||||
debug_warning(MODULE, f"Could not copy {config.source_rel_path}: {e}")
|
||||
print_status(f"Warning: Could not copy {config.source_rel_path}", "warning")
|
||||
return False
|
||||
|
||||
|
||||
def _apply_skip_strategy(config: DependencyShareConfig) -> None:
|
||||
"""Skip — nothing to do for this dependency type."""
|
||||
debug(
|
||||
MODULE, f"Skipping {config.dep_type} ({config.source_rel_path}) - skip strategy"
|
||||
)
|
||||
|
||||
|
||||
# Export private functions for backward compatibility
|
||||
_ensure_timeline_hook_installed = ensure_timeline_hook_installed
|
||||
_initialize_timeline_tracking = initialize_timeline_tracking
|
||||
|
||||
@@ -572,6 +572,9 @@ class PRReviewResult:
|
||||
) # IDs of posted findings
|
||||
posted_at: str | None = None # Timestamp when findings were posted
|
||||
|
||||
# In-progress review tracking
|
||||
in_progress_since: str | None = None # ISO timestamp when active review started
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"pr_number": self.pr_number,
|
||||
@@ -603,6 +606,8 @@ class PRReviewResult:
|
||||
"has_posted_findings": self.has_posted_findings,
|
||||
"posted_finding_ids": self.posted_finding_ids,
|
||||
"posted_at": self.posted_at,
|
||||
# In-progress review tracking
|
||||
"in_progress_since": self.in_progress_since,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -650,6 +655,8 @@ class PRReviewResult:
|
||||
has_posted_findings=data.get("has_posted_findings", False),
|
||||
posted_finding_ids=data.get("posted_finding_ids", []),
|
||||
posted_at=data.get("posted_at"),
|
||||
# In-progress review tracking
|
||||
in_progress_since=data.get("in_progress_since"),
|
||||
)
|
||||
|
||||
async def save(self, github_dir: Path) -> None:
|
||||
|
||||
@@ -395,8 +395,28 @@ class GitHubOrchestrator:
|
||||
else:
|
||||
# No existing review found, create skip result
|
||||
return await self._create_skip_result(pr_number, skip_reason)
|
||||
elif "Review already in progress" in skip_reason:
|
||||
# Return an in-progress result WITHOUT saving to disk
|
||||
# to avoid overwriting the partial result being written by the active review
|
||||
started_at = self.bot_detector.state.in_progress_reviews.get(
|
||||
str(pr_number)
|
||||
)
|
||||
safe_print(
|
||||
f"[BOT DETECTION] Review in progress for PR #{pr_number} "
|
||||
f"(started: {started_at})",
|
||||
flush=True,
|
||||
)
|
||||
return PRReviewResult(
|
||||
pr_number=pr_number,
|
||||
repo=self.config.repo,
|
||||
success=True,
|
||||
findings=[],
|
||||
summary="Review in progress",
|
||||
overall_status="in_progress",
|
||||
in_progress_since=started_at,
|
||||
)
|
||||
else:
|
||||
# For other skip reasons (bot-authored, cooling off, in-progress), create a skip result
|
||||
# For other skip reasons (bot-authored, cooling off), create a skip result
|
||||
return await self._create_skip_result(pr_number, skip_reason)
|
||||
|
||||
# Mark review as started (prevents concurrent reviews)
|
||||
|
||||
@@ -235,6 +235,12 @@ async def cmd_review_pr(args) -> int:
|
||||
safe_print(f"[DEBUG] review_pr returned, success={result.success}")
|
||||
|
||||
if result.success:
|
||||
# For in_progress results (not saved to disk), output JSON so the frontend
|
||||
# can parse it from stdout instead of relying on the disk file.
|
||||
if result.overall_status == "in_progress":
|
||||
safe_print(f"__RESULT_JSON__:{json.dumps(result.to_dict())}")
|
||||
return 0
|
||||
|
||||
safe_print(f"\n{'=' * 60}")
|
||||
safe_print(f"PR #{result.pr_number} Review Complete")
|
||||
safe_print(f"{'=' * 60}")
|
||||
|
||||
@@ -25,6 +25,8 @@ if TYPE_CHECKING:
|
||||
from ..models import FollowupReviewContext, GitHubRunnerConfig
|
||||
|
||||
try:
|
||||
from ...core.client import create_client
|
||||
from ...phase_config import resolve_model_id
|
||||
from ..gh_client import GHClient
|
||||
from ..models import (
|
||||
MergeVerdict,
|
||||
@@ -37,8 +39,11 @@ try:
|
||||
from .category_utils import map_category
|
||||
from .io_utils import safe_print
|
||||
from .prompt_manager import PromptManager
|
||||
from .pydantic_models import FollowupReviewResponse
|
||||
from .pydantic_models import FollowupExtractionResponse, FollowupReviewResponse
|
||||
from .recovery_utils import create_finding_from_summary
|
||||
from .sdk_utils import process_sdk_stream
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from core.client import create_client
|
||||
from gh_client import GHClient
|
||||
from models import (
|
||||
MergeVerdict,
|
||||
@@ -48,10 +53,16 @@ except (ImportError, ValueError, SystemError):
|
||||
ReviewSeverity,
|
||||
_utc_now_iso,
|
||||
)
|
||||
from phase_config import resolve_model_id
|
||||
from services.category_utils import map_category
|
||||
from services.io_utils import safe_print
|
||||
from services.prompt_manager import PromptManager
|
||||
from services.pydantic_models import FollowupReviewResponse
|
||||
from services.pydantic_models import (
|
||||
FollowupExtractionResponse,
|
||||
FollowupReviewResponse,
|
||||
)
|
||||
from services.recovery_utils import create_finding_from_summary
|
||||
from services.sdk_utils import process_sdk_stream
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -698,6 +709,9 @@ Analyze this follow-up review context and provide your structured response.
|
||||
)
|
||||
safe_print(f"[Followup] SDK query with output_format, model={model}")
|
||||
|
||||
# Capture assistant text for extraction fallback
|
||||
captured_text = ""
|
||||
|
||||
# Iterate through messages from the query
|
||||
# Note: max_turns=2 because structured output uses a tool call + response
|
||||
async for message in query(
|
||||
@@ -722,7 +736,9 @@ Analyze this follow-up review context and provide your structured response.
|
||||
content = getattr(message, "content", [])
|
||||
for block in content:
|
||||
block_type = type(block).__name__
|
||||
if block_type == "ToolUseBlock":
|
||||
if block_type == "TextBlock":
|
||||
captured_text += getattr(block, "text", "")
|
||||
elif block_type == "ToolUseBlock":
|
||||
tool_name = getattr(block, "name", "")
|
||||
if tool_name == "StructuredOutput":
|
||||
# Extract structured data from tool input
|
||||
@@ -765,9 +781,31 @@ Analyze this follow-up review context and provide your structured response.
|
||||
logger.warning(
|
||||
"Claude could not produce valid structured output after retries"
|
||||
)
|
||||
# Attempt extraction call recovery before giving up
|
||||
if captured_text:
|
||||
safe_print(
|
||||
"[Followup] Attempting extraction call recovery...",
|
||||
flush=True,
|
||||
)
|
||||
extraction_result = await self._attempt_extraction_call(
|
||||
captured_text, context
|
||||
)
|
||||
if extraction_result is not None:
|
||||
return extraction_result
|
||||
return None
|
||||
|
||||
logger.warning("No structured output received from AI")
|
||||
# Attempt extraction call recovery before giving up
|
||||
if captured_text:
|
||||
safe_print(
|
||||
"[Followup] No structured output — attempting extraction call recovery...",
|
||||
flush=True,
|
||||
)
|
||||
extraction_result = await self._attempt_extraction_call(
|
||||
captured_text, context
|
||||
)
|
||||
if extraction_result is not None:
|
||||
return extraction_result
|
||||
return None
|
||||
|
||||
except ValueError as e:
|
||||
@@ -840,6 +878,115 @@ Analyze this follow-up review context and provide your structured response.
|
||||
"verdict_reasoning": result.verdict_reasoning,
|
||||
}
|
||||
|
||||
async def _attempt_extraction_call(
|
||||
self,
|
||||
text: str,
|
||||
context: FollowupReviewContext,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Attempt a short SDK call with minimal schema to recover review data.
|
||||
|
||||
This is the extraction recovery step when full structured output validation fails.
|
||||
Uses FollowupExtractionResponse (~6 flat fields) which has near-100% success rate.
|
||||
|
||||
Uses create_client() + process_sdk_stream() for proper OAuth handling,
|
||||
matching the pattern in parallel_followup_reviewer.py.
|
||||
|
||||
Returns parsed result dict on success, None on failure.
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return None
|
||||
|
||||
try:
|
||||
extraction_prompt = (
|
||||
"Extract the key review data from the following AI analysis output. "
|
||||
"Return the verdict, reasoning, resolved finding IDs, unresolved finding IDs, "
|
||||
"one-line summaries of any new findings, and counts of confirmed/dismissed findings.\n\n"
|
||||
f"--- AI ANALYSIS OUTPUT ---\n{text[:8000]}\n--- END ---"
|
||||
)
|
||||
|
||||
model_shorthand = self.config.model or "sonnet"
|
||||
model = resolve_model_id(model_shorthand)
|
||||
|
||||
extraction_client = create_client(
|
||||
project_dir=self.project_dir,
|
||||
spec_dir=self.github_dir,
|
||||
model=model,
|
||||
agent_type="pr_followup_extraction",
|
||||
output_format={
|
||||
"type": "json_schema",
|
||||
"schema": FollowupExtractionResponse.model_json_schema(),
|
||||
},
|
||||
)
|
||||
|
||||
async with extraction_client:
|
||||
await extraction_client.query(extraction_prompt)
|
||||
|
||||
stream_result = await process_sdk_stream(
|
||||
client=extraction_client,
|
||||
context_name="FollowupExtraction",
|
||||
model=model,
|
||||
system_prompt=extraction_prompt,
|
||||
max_messages=20,
|
||||
)
|
||||
|
||||
if stream_result.get("error"):
|
||||
logger.warning(
|
||||
f"[Followup] Extraction call also failed: {stream_result['error']}"
|
||||
)
|
||||
return None
|
||||
|
||||
extraction_output = stream_result.get("structured_output")
|
||||
if not extraction_output:
|
||||
logger.warning(
|
||||
"[Followup] Extraction call returned no structured output"
|
||||
)
|
||||
return None
|
||||
|
||||
extracted = FollowupExtractionResponse.model_validate(extraction_output)
|
||||
|
||||
# Convert extraction to internal format with reconstructed findings
|
||||
new_findings = []
|
||||
for i, summary in enumerate(extracted.new_finding_summaries):
|
||||
new_findings.append(
|
||||
create_finding_from_summary(summary, i, id_prefix="FR")
|
||||
)
|
||||
|
||||
# Build finding_resolutions from extraction data for _apply_ai_resolutions
|
||||
# (unresolved findings are handled via finding_resolutions + _apply_ai_resolutions)
|
||||
finding_resolutions = []
|
||||
for fid in extracted.resolved_finding_ids:
|
||||
finding_resolutions.append(
|
||||
{"finding_id": fid, "status": "resolved", "resolution_notes": None}
|
||||
)
|
||||
for fid in extracted.unresolved_finding_ids:
|
||||
finding_resolutions.append(
|
||||
{
|
||||
"finding_id": fid,
|
||||
"status": "unresolved",
|
||||
"resolution_notes": None,
|
||||
}
|
||||
)
|
||||
|
||||
safe_print(
|
||||
f"[Followup] Extraction recovered: verdict={extracted.verdict}, "
|
||||
f"{len(extracted.resolved_finding_ids)} resolved, "
|
||||
f"{len(extracted.unresolved_finding_ids)} unresolved, "
|
||||
f"{len(new_findings)} new findings",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
return {
|
||||
"finding_resolutions": finding_resolutions,
|
||||
"new_findings": new_findings,
|
||||
"comment_findings": [],
|
||||
"verdict": extracted.verdict,
|
||||
"verdict_reasoning": f"[Recovered via extraction] {extracted.verdict_reasoning}",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[Followup] Extraction call failed: {e}")
|
||||
return None
|
||||
|
||||
def _apply_ai_resolutions(
|
||||
self,
|
||||
previous_findings: list[PRReviewFinding],
|
||||
|
||||
@@ -52,6 +52,7 @@ try:
|
||||
from .io_utils import safe_print
|
||||
from .pr_worktree_manager import PRWorktreeManager
|
||||
from .pydantic_models import FollowupExtractionResponse, ParallelFollowupResponse
|
||||
from .recovery_utils import create_finding_from_summary
|
||||
from .sdk_utils import process_sdk_stream
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from context_gatherer import _validate_git_ref
|
||||
@@ -79,6 +80,7 @@ except (ImportError, ValueError, SystemError):
|
||||
FollowupExtractionResponse,
|
||||
ParallelFollowupResponse,
|
||||
)
|
||||
from services.recovery_utils import create_finding_from_summary
|
||||
from services.sdk_utils import process_sdk_stream
|
||||
|
||||
|
||||
@@ -1199,18 +1201,52 @@ The SDK will run invoked agents in parallel automatically.
|
||||
}
|
||||
verdict = verdict_map.get(extracted.verdict, MergeVerdict.NEEDS_REVISION)
|
||||
|
||||
# Reconstruct findings from extraction data
|
||||
findings = []
|
||||
new_finding_ids = []
|
||||
|
||||
# 1. Convert new_finding_summaries to minimal PRReviewFinding objects
|
||||
# Uses shared helper for "SEVERITY: description" parsing and ID generation
|
||||
for i, summary in enumerate(extracted.new_finding_summaries):
|
||||
finding = create_finding_from_summary(summary, i, id_prefix="FU")
|
||||
new_finding_ids.append(finding.id)
|
||||
findings.append(finding)
|
||||
|
||||
# 2. Reconstruct unresolved findings from previous review context
|
||||
if extracted.unresolved_finding_ids and context.previous_review.findings:
|
||||
previous_map = {f.id: f for f in context.previous_review.findings}
|
||||
for uid in extracted.unresolved_finding_ids:
|
||||
original = previous_map.get(uid)
|
||||
if original:
|
||||
findings.append(
|
||||
PRReviewFinding(
|
||||
id=original.id,
|
||||
severity=original.severity,
|
||||
category=original.category,
|
||||
title=f"[UNRESOLVED] {original.title}",
|
||||
description=original.description,
|
||||
file=original.file,
|
||||
line=original.line,
|
||||
suggested_fix=original.suggested_fix,
|
||||
fixable=original.fixable,
|
||||
is_impact_finding=original.is_impact_finding,
|
||||
)
|
||||
)
|
||||
|
||||
safe_print(
|
||||
f"[ParallelFollowup] Extraction recovered: verdict={extracted.verdict}, "
|
||||
f"{len(extracted.resolved_finding_ids)} resolved, "
|
||||
f"{len(extracted.new_finding_summaries)} new findings",
|
||||
f"{len(extracted.unresolved_finding_ids)} unresolved, "
|
||||
f"{len(new_finding_ids)} new findings, "
|
||||
f"{len(findings)} total findings reconstructed",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
return {
|
||||
"findings": [], # Full findings not recoverable via extraction
|
||||
"findings": findings,
|
||||
"resolved_ids": extracted.resolved_finding_ids,
|
||||
"unresolved_ids": extracted.unresolved_finding_ids,
|
||||
"new_finding_ids": [],
|
||||
"new_finding_ids": new_finding_ids,
|
||||
"dismissed_false_positive_ids": [],
|
||||
"confirmed_valid_count": extracted.confirmed_finding_count,
|
||||
"dismissed_finding_count": extracted.dismissed_finding_count,
|
||||
@@ -1250,6 +1286,7 @@ The SDK will run invoked agents in parallel automatically.
|
||||
|
||||
This handles cases where the AI produced valid data but it doesn't exactly
|
||||
match the expected schema (missing optional fields, type mismatches, etc.).
|
||||
Defensively extracts findings from the raw dict so partial results are preserved.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
@@ -1257,6 +1294,7 @@ The SDK will run invoked agents in parallel automatically.
|
||||
resolved_ids = []
|
||||
unresolved_ids = []
|
||||
new_finding_ids = []
|
||||
findings = []
|
||||
|
||||
# Try to extract resolution verifications
|
||||
resolution_verifications = data.get("resolution_verifications", [])
|
||||
@@ -1275,14 +1313,68 @@ The SDK will run invoked agents in parallel automatically.
|
||||
):
|
||||
unresolved_ids.append(finding_id)
|
||||
|
||||
# Try to extract new findings
|
||||
new_findings = data.get("new_findings", [])
|
||||
if isinstance(new_findings, list):
|
||||
for nf in new_findings:
|
||||
if isinstance(nf, dict):
|
||||
finding_id = nf.get("id", "")
|
||||
if finding_id:
|
||||
new_finding_ids.append(finding_id)
|
||||
# Try to extract new findings as PRReviewFinding objects
|
||||
new_findings_raw = data.get("new_findings", [])
|
||||
if isinstance(new_findings_raw, list):
|
||||
for nf in new_findings_raw:
|
||||
if not isinstance(nf, dict):
|
||||
continue
|
||||
try:
|
||||
finding_id = nf.get("id", "") or self._generate_finding_id(
|
||||
nf.get("file", "unknown"),
|
||||
nf.get("line", 0),
|
||||
nf.get("title", "unknown"),
|
||||
)
|
||||
new_finding_ids.append(finding_id)
|
||||
findings.append(
|
||||
PRReviewFinding(
|
||||
id=finding_id,
|
||||
severity=_map_severity(nf.get("severity", "medium")),
|
||||
category=map_category(nf.get("category", "quality")),
|
||||
title=nf.get("title", "Unknown issue"),
|
||||
description=nf.get("description", ""),
|
||||
file=nf.get("file", "unknown"),
|
||||
line=nf.get("line", 0) or 0,
|
||||
suggested_fix=nf.get("suggested_fix"),
|
||||
fixable=bool(nf.get("fixable", False)),
|
||||
is_impact_finding=bool(nf.get("is_impact_finding", False)),
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"[ParallelFollowup] Skipping malformed new finding: {e}"
|
||||
)
|
||||
|
||||
# Try to extract comment findings as PRReviewFinding objects
|
||||
comment_findings_raw = data.get("comment_findings", [])
|
||||
if isinstance(comment_findings_raw, list):
|
||||
for cf in comment_findings_raw:
|
||||
if not isinstance(cf, dict):
|
||||
continue
|
||||
try:
|
||||
finding_id = cf.get("id", "") or self._generate_finding_id(
|
||||
cf.get("file", "unknown"),
|
||||
cf.get("line", 0),
|
||||
cf.get("title", "unknown"),
|
||||
)
|
||||
new_finding_ids.append(finding_id)
|
||||
findings.append(
|
||||
PRReviewFinding(
|
||||
id=finding_id,
|
||||
severity=_map_severity(cf.get("severity", "medium")),
|
||||
category=map_category(cf.get("category", "quality")),
|
||||
title=f"[FROM COMMENTS] {cf.get('title', 'Unknown issue')}",
|
||||
description=cf.get("description", ""),
|
||||
file=cf.get("file", "unknown"),
|
||||
line=cf.get("line", 0) or 0,
|
||||
suggested_fix=cf.get("suggested_fix"),
|
||||
fixable=bool(cf.get("fixable", False)),
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"[ParallelFollowup] Skipping malformed comment finding: {e}"
|
||||
)
|
||||
|
||||
# Try to extract verdict
|
||||
verdict_str = data.get("verdict", "NEEDS_REVISION")
|
||||
@@ -1297,14 +1389,15 @@ The SDK will run invoked agents in parallel automatically.
|
||||
verdict_reasoning = data.get("verdict_reasoning", "Extracted from partial data")
|
||||
|
||||
# Only return if we got any useful data
|
||||
if resolved_ids or unresolved_ids or new_finding_ids:
|
||||
if resolved_ids or unresolved_ids or new_finding_ids or findings:
|
||||
return {
|
||||
"findings": [], # Can't reliably extract full findings without validation
|
||||
"findings": findings,
|
||||
"resolved_ids": resolved_ids,
|
||||
"unresolved_ids": unresolved_ids,
|
||||
"new_finding_ids": new_finding_ids,
|
||||
"dismissed_false_positive_ids": [],
|
||||
"confirmed_valid_count": 0,
|
||||
"dismissed_finding_count": 0,
|
||||
"needs_human_review_count": 0,
|
||||
"verdict": verdict,
|
||||
"verdict_reasoning": f"[Partial extraction] {verdict_reasoning}",
|
||||
|
||||
@@ -633,7 +633,14 @@ Report findings with specific file paths, line numbers, and code evidence.
|
||||
logger.error(
|
||||
f"[Specialist:{specialist_name}] Failed to parse structured output: {e}"
|
||||
)
|
||||
# Fall through to text parsing
|
||||
# Attempt to extract findings from raw dict before falling to text parsing
|
||||
findings = self._extract_specialist_partial_data(
|
||||
specialist_name, structured_output
|
||||
)
|
||||
if findings:
|
||||
logger.info(
|
||||
f"[Specialist:{specialist_name}] Recovered {len(findings)} findings from partial extraction"
|
||||
)
|
||||
|
||||
if not findings and result_text:
|
||||
# Fallback to text parsing
|
||||
@@ -643,6 +650,63 @@ Report findings with specific file paths, line numbers, and code evidence.
|
||||
|
||||
return findings
|
||||
|
||||
def _extract_specialist_partial_data(
|
||||
self,
|
||||
specialist_name: str,
|
||||
data: dict[str, Any],
|
||||
) -> list[PRReviewFinding]:
|
||||
"""Extract findings from raw specialist dict when Pydantic validation fails.
|
||||
|
||||
Defensively extracts each finding individually so partial results are preserved
|
||||
even if some findings have validation issues.
|
||||
"""
|
||||
findings = []
|
||||
raw_findings = data.get("findings", [])
|
||||
if not isinstance(raw_findings, list):
|
||||
return findings
|
||||
|
||||
for f in raw_findings:
|
||||
if not isinstance(f, dict):
|
||||
continue
|
||||
try:
|
||||
file_path = f.get("file", "unknown")
|
||||
line = f.get("line", 0) or 0
|
||||
title = f.get("title", "Unknown issue")
|
||||
|
||||
finding_id = hashlib.md5(
|
||||
f"{file_path}:{line}:{title}".encode(),
|
||||
usedforsecurity=False,
|
||||
).hexdigest()[:12]
|
||||
|
||||
category = map_category(f.get("category", "quality"))
|
||||
|
||||
try:
|
||||
severity = ReviewSeverity(str(f.get("severity", "medium")).lower())
|
||||
except ValueError:
|
||||
severity = ReviewSeverity.MEDIUM
|
||||
|
||||
finding = PRReviewFinding(
|
||||
id=finding_id,
|
||||
file=file_path,
|
||||
line=line,
|
||||
end_line=f.get("end_line"),
|
||||
title=title,
|
||||
description=f.get("description", ""),
|
||||
category=category,
|
||||
severity=severity,
|
||||
suggested_fix=f.get("suggested_fix", ""),
|
||||
evidence=f.get("evidence"),
|
||||
source_agents=[specialist_name],
|
||||
is_impact_finding=bool(f.get("is_impact_finding", False)),
|
||||
)
|
||||
findings.append(finding)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"[Specialist:{specialist_name}] Skipping malformed finding: {e}"
|
||||
)
|
||||
|
||||
return findings
|
||||
|
||||
async def _run_parallel_specialists(
|
||||
self,
|
||||
context: PRContext,
|
||||
@@ -910,13 +974,15 @@ The SDK will run invoked agents in parallel automatically.
|
||||
except ValueError:
|
||||
severity = ReviewSeverity.MEDIUM
|
||||
|
||||
# Extract evidence: prefer verification.code_examined, fallback to evidence field
|
||||
evidence = finding_data.evidence
|
||||
# Extract evidence from verification.code_examined if available
|
||||
evidence = None
|
||||
if hasattr(finding_data, "verification") and finding_data.verification:
|
||||
# Structured verification has more detailed evidence
|
||||
verification = finding_data.verification
|
||||
if hasattr(verification, "code_examined") and verification.code_examined:
|
||||
evidence = verification.code_examined
|
||||
# Fallback to evidence field if present (e.g. from dict-based parsing)
|
||||
if not evidence:
|
||||
evidence = getattr(finding_data, "evidence", None)
|
||||
|
||||
# Extract end_line if present
|
||||
end_line = getattr(finding_data, "end_line", None)
|
||||
|
||||
@@ -26,10 +26,10 @@ from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
# =============================================================================
|
||||
# Verification Evidence (Required for All Findings)
|
||||
# Verification Evidence (Optional for findings — only code_examined is consumed)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@@ -50,102 +50,28 @@ class VerificationEvidence(BaseModel):
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Common Finding Types
|
||||
# Severity / Category Validators
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class BaseFinding(BaseModel):
|
||||
"""Base class for all finding types."""
|
||||
|
||||
id: str = Field(description="Unique identifier for this finding")
|
||||
severity: Literal["critical", "high", "medium", "low"] = Field(
|
||||
description="Issue severity level"
|
||||
)
|
||||
title: str = Field(description="Brief issue title (max 80 chars)")
|
||||
description: str = Field(description="Detailed explanation of the issue")
|
||||
file: str = Field(description="File path where issue was found")
|
||||
line: int = Field(0, description="Line number of the issue")
|
||||
suggested_fix: str | None = Field(None, description="How to fix this issue")
|
||||
fixable: bool = Field(False, description="Whether this can be auto-fixed")
|
||||
evidence: str | None = Field(
|
||||
None,
|
||||
description="DEPRECATED: Use verification.code_examined instead. Will be removed in Phase 5.",
|
||||
)
|
||||
verification: VerificationEvidence = Field(
|
||||
description="Evidence that this finding was verified against actual code"
|
||||
)
|
||||
_VALID_SEVERITIES = {"critical", "high", "medium", "low"}
|
||||
|
||||
|
||||
class SecurityFinding(BaseFinding):
|
||||
"""A security vulnerability finding."""
|
||||
|
||||
category: Literal["security"] = Field(
|
||||
default="security", description="Always 'security' for security findings"
|
||||
)
|
||||
def _normalize_severity(v: str) -> str:
|
||||
"""Normalize severity to a valid value, defaulting to 'medium'."""
|
||||
if isinstance(v, str):
|
||||
v = v.lower().strip()
|
||||
if v not in _VALID_SEVERITIES:
|
||||
return "medium"
|
||||
return v
|
||||
|
||||
|
||||
class QualityFinding(BaseFinding):
|
||||
"""A code quality or redundancy finding."""
|
||||
|
||||
category: Literal[
|
||||
"redundancy", "quality", "test", "performance", "pattern", "docs"
|
||||
] = Field(description="Issue category")
|
||||
redundant_with: str | None = Field(
|
||||
None, description="Reference to duplicate code (file:line) if redundant"
|
||||
)
|
||||
|
||||
|
||||
class DeepAnalysisFinding(BaseFinding):
|
||||
"""A finding from deep analysis with verification info."""
|
||||
|
||||
category: Literal[
|
||||
"verification_failed",
|
||||
"redundancy",
|
||||
"quality",
|
||||
"pattern",
|
||||
"performance",
|
||||
"logic",
|
||||
] = Field(description="Issue category")
|
||||
verification_note: str | None = Field(
|
||||
None, description="What evidence is missing or couldn't be verified"
|
||||
)
|
||||
|
||||
|
||||
class StructuralIssue(BaseModel):
|
||||
"""A structural issue with the PR."""
|
||||
|
||||
id: str = Field(description="Unique identifier")
|
||||
issue_type: Literal[
|
||||
"feature_creep", "scope_creep", "architecture_violation", "poor_structure"
|
||||
] = Field(description="Type of structural issue")
|
||||
severity: Literal["critical", "high", "medium", "low"] = Field(
|
||||
description="Issue severity"
|
||||
)
|
||||
title: str = Field(description="Brief issue title")
|
||||
description: str = Field(description="Detailed explanation")
|
||||
impact: str = Field(description="Why this matters")
|
||||
suggestion: str = Field(description="How to fix")
|
||||
|
||||
|
||||
class AICommentTriage(BaseModel):
|
||||
"""Triage result for an AI tool comment."""
|
||||
|
||||
comment_id: int = Field(description="GitHub comment ID")
|
||||
tool_name: str = Field(
|
||||
description="AI tool name (CodeRabbit, Cursor, Greptile, etc.)"
|
||||
)
|
||||
verdict: Literal[
|
||||
"critical",
|
||||
"important",
|
||||
"nice_to_have",
|
||||
"trivial",
|
||||
"addressed",
|
||||
"false_positive",
|
||||
] = Field(description="Verdict on the comment")
|
||||
reasoning: str = Field(description="Why this verdict was chosen")
|
||||
response_comment: str | None = Field(
|
||||
None, description="Optional comment to post in reply"
|
||||
)
|
||||
def _normalize_category(v: str, valid_set: set[str], default: str = "quality") -> str:
|
||||
"""Normalize category to a valid value, defaulting to given default."""
|
||||
if isinstance(v, str):
|
||||
v = v.lower().strip().replace("-", "_")
|
||||
if v not in valid_set:
|
||||
return default
|
||||
return v
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -163,25 +89,34 @@ class FindingResolution(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
_FOLLOWUP_CATEGORIES = {"security", "quality", "logic", "test", "docs"}
|
||||
|
||||
|
||||
class FollowupFinding(BaseModel):
|
||||
"""A new finding from follow-up review (simpler than initial review)."""
|
||||
"""A new finding from follow-up review (simpler than initial review).
|
||||
|
||||
verification is intentionally omitted — not consumed by followup_reviewer.py.
|
||||
"""
|
||||
|
||||
id: str = Field(description="Unique identifier for this finding")
|
||||
severity: Literal["critical", "high", "medium", "low"] = Field(
|
||||
description="Issue severity level"
|
||||
)
|
||||
category: Literal["security", "quality", "logic", "test", "docs"] = Field(
|
||||
description="Issue category"
|
||||
)
|
||||
severity: str = Field(description="Issue severity level")
|
||||
category: str = Field(description="Issue category")
|
||||
title: str = Field(description="Brief issue title")
|
||||
description: str = Field(description="Detailed explanation of the issue")
|
||||
file: str = Field(description="File path where issue was found")
|
||||
line: int = Field(0, description="Line number of the issue")
|
||||
suggested_fix: str | None = Field(None, description="How to fix this issue")
|
||||
fixable: bool = Field(False, description="Whether this can be auto-fixed")
|
||||
verification: VerificationEvidence = Field(
|
||||
description="Evidence that this finding was verified against actual code"
|
||||
)
|
||||
|
||||
@field_validator("severity", mode="before")
|
||||
@classmethod
|
||||
def _normalize_severity(cls, v: str) -> str:
|
||||
return _normalize_severity(v)
|
||||
|
||||
@field_validator("category", mode="before")
|
||||
@classmethod
|
||||
def _normalize_category(cls, v: str) -> str:
|
||||
return _normalize_category(v, _FOLLOWUP_CATEGORIES)
|
||||
|
||||
|
||||
class FollowupReviewResponse(BaseModel):
|
||||
@@ -203,81 +138,6 @@ class FollowupReviewResponse(BaseModel):
|
||||
verdict_reasoning: str = Field(description="Explanation for the verdict")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Initial Review Responses (Multi-Pass)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class QuickScanResult(BaseModel):
|
||||
"""Result from the quick scan pass."""
|
||||
|
||||
purpose: str = Field(description="Brief description of what the PR claims to do")
|
||||
actual_changes: str = Field(
|
||||
description="Brief description of what the code actually does"
|
||||
)
|
||||
purpose_match: bool = Field(
|
||||
description="Whether actual changes match the claimed purpose"
|
||||
)
|
||||
purpose_match_note: str | None = Field(
|
||||
None, description="Explanation if purpose doesn't match actual changes"
|
||||
)
|
||||
risk_areas: list[str] = Field(
|
||||
default_factory=list, description="Areas needing careful review"
|
||||
)
|
||||
red_flags: list[str] = Field(
|
||||
default_factory=list, description="Obvious issues or concerns"
|
||||
)
|
||||
requires_deep_verification: bool = Field(
|
||||
description="Whether deep verification is needed"
|
||||
)
|
||||
complexity: Literal["low", "medium", "high"] = Field(description="PR complexity")
|
||||
|
||||
|
||||
class SecurityPassResult(BaseModel):
|
||||
"""Result from the security pass - array of security findings."""
|
||||
|
||||
findings: list[SecurityFinding] = Field(
|
||||
default_factory=list, description="Security vulnerabilities found"
|
||||
)
|
||||
|
||||
|
||||
class QualityPassResult(BaseModel):
|
||||
"""Result from the quality pass - array of quality findings."""
|
||||
|
||||
findings: list[QualityFinding] = Field(
|
||||
default_factory=list, description="Quality and redundancy issues found"
|
||||
)
|
||||
|
||||
|
||||
class DeepAnalysisResult(BaseModel):
|
||||
"""Result from the deep analysis pass."""
|
||||
|
||||
findings: list[DeepAnalysisFinding] = Field(
|
||||
default_factory=list,
|
||||
description="Deep analysis findings with verification info",
|
||||
)
|
||||
|
||||
|
||||
class StructuralPassResult(BaseModel):
|
||||
"""Result from the structural pass."""
|
||||
|
||||
issues: list[StructuralIssue] = Field(
|
||||
default_factory=list, description="Structural issues found"
|
||||
)
|
||||
verdict: Literal[
|
||||
"READY_TO_MERGE", "MERGE_WITH_CHANGES", "NEEDS_REVISION", "BLOCKED"
|
||||
] = Field(description="Structural verdict")
|
||||
verdict_reasoning: str = Field(description="Explanation for the verdict")
|
||||
|
||||
|
||||
class AICommentTriageResult(BaseModel):
|
||||
"""Result from AI comment triage pass."""
|
||||
|
||||
triages: list[AICommentTriage] = Field(
|
||||
default_factory=list, description="Triage results for each AI comment"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Issue Triage Response
|
||||
# =============================================================================
|
||||
@@ -320,88 +180,21 @@ class IssueTriageResponse(BaseModel):
|
||||
comment: str | None = Field(None, description="Optional bot comment to post")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Orchestrator Review Response
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class OrchestratorFinding(BaseModel):
|
||||
"""A finding from the orchestrator review."""
|
||||
|
||||
file: str = Field(description="File path where issue was found")
|
||||
line: int = Field(0, description="Line number of the issue")
|
||||
title: str = Field(description="Brief issue title")
|
||||
description: str = Field(description="Detailed explanation of the issue")
|
||||
category: Literal[
|
||||
"security",
|
||||
"quality",
|
||||
"style",
|
||||
"docs",
|
||||
"redundancy",
|
||||
"verification_failed",
|
||||
"pattern",
|
||||
"performance",
|
||||
"logic",
|
||||
"test",
|
||||
] = Field(description="Issue category")
|
||||
severity: Literal["critical", "high", "medium", "low"] = Field(
|
||||
description="Issue severity level"
|
||||
)
|
||||
suggestion: str | None = Field(None, description="How to fix this issue")
|
||||
evidence: str | None = Field(
|
||||
None,
|
||||
description="DEPRECATED: Use verification.code_examined instead. Will be removed in Phase 5.",
|
||||
)
|
||||
verification: VerificationEvidence = Field(
|
||||
description="Evidence that this finding was verified against actual code"
|
||||
)
|
||||
|
||||
|
||||
class OrchestratorReviewResponse(BaseModel):
|
||||
"""Complete response schema for orchestrator PR review."""
|
||||
|
||||
verdict: Literal[
|
||||
"READY_TO_MERGE", "MERGE_WITH_CHANGES", "NEEDS_REVISION", "BLOCKED"
|
||||
] = Field(description="Overall merge verdict")
|
||||
verdict_reasoning: str = Field(description="Explanation for the verdict")
|
||||
findings: list[OrchestratorFinding] = Field(
|
||||
default_factory=list, description="Issues found during review"
|
||||
)
|
||||
summary: str = Field(description="Brief summary of the review")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Parallel Orchestrator Review Response (SDK Subagents)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class LogicFinding(BaseFinding):
|
||||
"""A logic/correctness finding from the logic review agent."""
|
||||
|
||||
category: Literal["logic"] = Field(
|
||||
default="logic", description="Always 'logic' for logic findings"
|
||||
)
|
||||
example_input: str | None = Field(
|
||||
None, description="Concrete input that triggers the bug"
|
||||
)
|
||||
actual_output: str | None = Field(None, description="What the buggy code produces")
|
||||
expected_output: str | None = Field(
|
||||
None, description="What the code should produce"
|
||||
)
|
||||
|
||||
|
||||
class CodebaseFitFinding(BaseFinding):
|
||||
"""A codebase fit finding from the codebase fit review agent."""
|
||||
|
||||
category: Literal["codebase_fit"] = Field(
|
||||
default="codebase_fit", description="Always 'codebase_fit' for fit findings"
|
||||
)
|
||||
existing_code: str | None = Field(
|
||||
None, description="Reference to existing code that should be used instead"
|
||||
)
|
||||
codebase_pattern: str | None = Field(
|
||||
None, description="Description of the established pattern being violated"
|
||||
)
|
||||
_ORCHESTRATOR_CATEGORIES = {
|
||||
"security",
|
||||
"quality",
|
||||
"logic",
|
||||
"codebase_fit",
|
||||
"test",
|
||||
"docs",
|
||||
"redundancy",
|
||||
"pattern",
|
||||
"performance",
|
||||
}
|
||||
|
||||
|
||||
class ParallelOrchestratorFinding(BaseModel):
|
||||
@@ -413,26 +206,11 @@ class ParallelOrchestratorFinding(BaseModel):
|
||||
end_line: int | None = Field(None, description="End line for multi-line issues")
|
||||
title: str = Field(description="Brief issue title (max 80 chars)")
|
||||
description: str = Field(description="Detailed explanation of the issue")
|
||||
category: Literal[
|
||||
"security",
|
||||
"quality",
|
||||
"logic",
|
||||
"codebase_fit",
|
||||
"test",
|
||||
"docs",
|
||||
"redundancy",
|
||||
"pattern",
|
||||
"performance",
|
||||
] = Field(description="Issue category")
|
||||
severity: Literal["critical", "high", "medium", "low"] = Field(
|
||||
description="Issue severity level"
|
||||
)
|
||||
evidence: str | None = Field(
|
||||
category: str = Field(description="Issue category")
|
||||
severity: str = Field(description="Issue severity level")
|
||||
verification: VerificationEvidence | None = Field(
|
||||
None,
|
||||
description="DEPRECATED: Use verification.code_examined instead. Will be removed in Phase 5.",
|
||||
)
|
||||
verification: VerificationEvidence = Field(
|
||||
description="Evidence that this finding was verified against actual code"
|
||||
description="Evidence that this finding was verified against actual code",
|
||||
)
|
||||
is_impact_finding: bool = Field(
|
||||
False,
|
||||
@@ -459,6 +237,16 @@ class ParallelOrchestratorFinding(BaseModel):
|
||||
False, description="Whether multiple agents agreed on this finding"
|
||||
)
|
||||
|
||||
@field_validator("severity", mode="before")
|
||||
@classmethod
|
||||
def _normalize_severity(cls, v: str) -> str:
|
||||
return _normalize_severity(v)
|
||||
|
||||
@field_validator("category", mode="before")
|
||||
@classmethod
|
||||
def _normalize_category(cls, v: str) -> str:
|
||||
return _normalize_category(v, _ORCHESTRATOR_CATEGORIES)
|
||||
|
||||
|
||||
class AgentAgreement(BaseModel):
|
||||
"""Tracks agreement between agents on findings."""
|
||||
@@ -514,15 +302,22 @@ class ValidationSummary(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
_SPECIALIST_CATEGORIES = {
|
||||
"security",
|
||||
"quality",
|
||||
"logic",
|
||||
"performance",
|
||||
"pattern",
|
||||
"test",
|
||||
"docs",
|
||||
}
|
||||
|
||||
|
||||
class SpecialistFinding(BaseModel):
|
||||
"""A finding from a specialist agent (used in parallel SDK sessions)."""
|
||||
|
||||
severity: Literal["critical", "high", "medium", "low"] = Field(
|
||||
description="Issue severity level"
|
||||
)
|
||||
category: Literal[
|
||||
"security", "quality", "logic", "performance", "pattern", "test", "docs"
|
||||
] = Field(description="Issue category")
|
||||
severity: str = Field(description="Issue severity level")
|
||||
category: str = Field(description="Issue category")
|
||||
title: str = Field(description="Brief issue title (max 80 chars)")
|
||||
description: str = Field(description="Detailed explanation of the issue")
|
||||
file: str = Field(description="File path where issue was found")
|
||||
@@ -530,14 +325,24 @@ class SpecialistFinding(BaseModel):
|
||||
end_line: int | None = Field(None, description="End line number if multi-line")
|
||||
suggested_fix: str | None = Field(None, description="How to fix this issue")
|
||||
evidence: str = Field(
|
||||
min_length=1,
|
||||
description="Actual code snippet examined that shows the issue. Required.",
|
||||
default="",
|
||||
description="Actual code snippet examined that shows the issue.",
|
||||
)
|
||||
is_impact_finding: bool = Field(
|
||||
False,
|
||||
description="True if this is about affected code outside the PR (callers, dependencies)",
|
||||
)
|
||||
|
||||
@field_validator("severity", mode="before")
|
||||
@classmethod
|
||||
def _normalize_severity(cls, v: str) -> str:
|
||||
return _normalize_severity(v)
|
||||
|
||||
@field_validator("category", mode="before")
|
||||
@classmethod
|
||||
def _normalize_category(cls, v: str) -> str:
|
||||
return _normalize_category(v, _SPECIALIST_CATEGORIES)
|
||||
|
||||
|
||||
class SpecialistResponse(BaseModel):
|
||||
"""Response schema for individual specialist agent (parallel SDK sessions).
|
||||
@@ -611,6 +416,17 @@ class ResolutionVerification(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
_PARALLEL_FOLLOWUP_CATEGORIES = {
|
||||
"security",
|
||||
"quality",
|
||||
"logic",
|
||||
"test",
|
||||
"docs",
|
||||
"regression",
|
||||
"incomplete_fix",
|
||||
}
|
||||
|
||||
|
||||
class ParallelFollowupFinding(BaseModel):
|
||||
"""A finding from parallel follow-up review."""
|
||||
|
||||
@@ -619,18 +435,8 @@ class ParallelFollowupFinding(BaseModel):
|
||||
line: int = Field(0, description="Line number of the issue")
|
||||
title: str = Field(description="Brief issue title")
|
||||
description: str = Field(description="Detailed explanation of the issue")
|
||||
category: Literal[
|
||||
"security",
|
||||
"quality",
|
||||
"logic",
|
||||
"test",
|
||||
"docs",
|
||||
"regression",
|
||||
"incomplete_fix",
|
||||
] = Field(description="Issue category")
|
||||
severity: Literal["critical", "high", "medium", "low"] = Field(
|
||||
description="Issue severity level"
|
||||
)
|
||||
category: str = Field(description="Issue category")
|
||||
severity: str = Field(description="Issue severity level")
|
||||
suggested_fix: str | None = Field(None, description="How to fix this issue")
|
||||
fixable: bool = Field(False, description="Whether this can be auto-fixed")
|
||||
is_impact_finding: bool = Field(
|
||||
@@ -638,6 +444,16 @@ class ParallelFollowupFinding(BaseModel):
|
||||
description="True if this finding is about impact on OTHER files outside the PR diff",
|
||||
)
|
||||
|
||||
@field_validator("severity", mode="before")
|
||||
@classmethod
|
||||
def _normalize_severity(cls, v: str) -> str:
|
||||
return _normalize_severity(v)
|
||||
|
||||
@field_validator("category", mode="before")
|
||||
@classmethod
|
||||
def _normalize_category(cls, v: str) -> str:
|
||||
return _normalize_category(v, _PARALLEL_FOLLOWUP_CATEGORIES)
|
||||
|
||||
|
||||
class ParallelFollowupResponse(BaseModel):
|
||||
"""Complete response schema for parallel follow-up PR review.
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
Recovery Utilities for PR Review
|
||||
=================================
|
||||
|
||||
Shared helpers for extraction recovery in followup and parallel followup reviewers.
|
||||
|
||||
These utilities consolidate duplicated logic for:
|
||||
- Parsing "SEVERITY: description" patterns from extraction summaries
|
||||
- Generating consistent, traceable finding IDs with prefixes
|
||||
- Creating PRReviewFinding objects from extraction data
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
|
||||
try:
|
||||
from ..models import (
|
||||
PRReviewFinding,
|
||||
ReviewCategory,
|
||||
ReviewSeverity,
|
||||
)
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from models import (
|
||||
PRReviewFinding,
|
||||
ReviewCategory,
|
||||
ReviewSeverity,
|
||||
)
|
||||
|
||||
# Severity mapping for parsing "SEVERITY: description" patterns
|
||||
_EXTRACTION_SEVERITY_MAP: list[tuple[str, ReviewSeverity]] = [
|
||||
("CRITICAL:", ReviewSeverity.CRITICAL),
|
||||
("HIGH:", ReviewSeverity.HIGH),
|
||||
("MEDIUM:", ReviewSeverity.MEDIUM),
|
||||
("LOW:", ReviewSeverity.LOW),
|
||||
]
|
||||
|
||||
|
||||
def parse_severity_from_summary(
|
||||
summary: str,
|
||||
) -> tuple[ReviewSeverity, str]:
|
||||
"""Parse a "SEVERITY: description" pattern from an extraction summary.
|
||||
|
||||
Args:
|
||||
summary: Raw summary string, e.g. "HIGH: Missing null check in parser.py"
|
||||
|
||||
Returns:
|
||||
Tuple of (severity, cleaned_description).
|
||||
Defaults to MEDIUM severity if no prefix is found.
|
||||
"""
|
||||
upper_summary = summary.upper()
|
||||
for sev_name, sev_val in _EXTRACTION_SEVERITY_MAP:
|
||||
if upper_summary.startswith(sev_name):
|
||||
return sev_val, summary[len(sev_name) :].strip()
|
||||
return ReviewSeverity.MEDIUM, summary
|
||||
|
||||
|
||||
def generate_recovery_finding_id(
|
||||
index: int, description: str, prefix: str = "FR"
|
||||
) -> str:
|
||||
"""Generate a consistent, traceable finding ID for recovery findings.
|
||||
|
||||
Args:
|
||||
index: The index of the finding in the extraction list.
|
||||
description: The finding description (used for hash uniqueness).
|
||||
prefix: ID prefix for traceability. Default "FR" (Followup Recovery).
|
||||
Use "FU" for parallel followup findings.
|
||||
|
||||
Returns:
|
||||
A prefixed finding ID like "FR-A1B2C3D4" or "FU-A1B2C3D4".
|
||||
"""
|
||||
content = f"extraction-{index}-{description}"
|
||||
hex_hash = (
|
||||
hashlib.md5(content.encode(), usedforsecurity=False).hexdigest()[:8].upper()
|
||||
)
|
||||
return f"{prefix}-{hex_hash}"
|
||||
|
||||
|
||||
def create_finding_from_summary(
|
||||
summary: str,
|
||||
index: int,
|
||||
id_prefix: str = "FR",
|
||||
) -> PRReviewFinding:
|
||||
"""Create a PRReviewFinding from an extraction summary string.
|
||||
|
||||
Parses "SEVERITY: description" patterns, generates a traceable finding ID,
|
||||
and returns a fully constructed PRReviewFinding.
|
||||
|
||||
Args:
|
||||
summary: Raw summary string, e.g. "HIGH: Missing null check in parser.py"
|
||||
index: The index of the finding in the extraction list.
|
||||
id_prefix: ID prefix for traceability. Default "FR" (Followup Recovery).
|
||||
|
||||
Returns:
|
||||
A PRReviewFinding with parsed severity, generated ID, and description.
|
||||
"""
|
||||
severity, description = parse_severity_from_summary(summary)
|
||||
finding_id = generate_recovery_finding_id(index, description, prefix=id_prefix)
|
||||
|
||||
return PRReviewFinding(
|
||||
id=finding_id,
|
||||
severity=severity,
|
||||
category=ReviewCategory.QUALITY,
|
||||
title=description[:80],
|
||||
description=f"[Recovered via extraction] {description}",
|
||||
file="unknown",
|
||||
line=0,
|
||||
)
|
||||
@@ -27,8 +27,6 @@ export default defineConfig({
|
||||
plugins: [externalizeDepsPlugin({
|
||||
// Bundle these packages into the main process (they won't be in node_modules in packaged app)
|
||||
exclude: [
|
||||
// Workspace packages — must be bundled, not externalized
|
||||
'@auto-claude/types',
|
||||
'uuid',
|
||||
'chokidar',
|
||||
'dotenv',
|
||||
@@ -92,10 +90,7 @@ export default defineConfig({
|
||||
'@features': resolve(__dirname, 'src/renderer/features'),
|
||||
'@components': resolve(__dirname, 'src/renderer/shared/components'),
|
||||
'@hooks': resolve(__dirname, 'src/renderer/shared/hooks'),
|
||||
'@lib': resolve(__dirname, 'src/renderer/shared/lib'),
|
||||
// Workspace packages — resolve to source for HMR support
|
||||
'@auto-claude/types': resolve(__dirname, '../../packages/types/src'),
|
||||
'@auto-claude/ui': resolve(__dirname, '../../packages/ui/src')
|
||||
'@lib': resolve(__dirname, 'src/renderer/shared/lib')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "auto-claude-ui",
|
||||
"version": "2.7.7",
|
||||
"version": "2.7.6-beta.5",
|
||||
"type": "module",
|
||||
"description": "Desktop UI for Auto Claude autonomous coding framework",
|
||||
"homepage": "https://github.com/AndyMik90/Auto-Claude",
|
||||
@@ -51,8 +51,6 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auto-claude/types": "*",
|
||||
"@auto-claude/ui": "*",
|
||||
"@anthropic-ai/sdk": "^0.71.2",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
|
||||
@@ -1012,3 +1012,115 @@ Please add credits to continue.`;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureCleanProfileEnv', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('with CLAUDE_CONFIG_DIR set', () => {
|
||||
it('should preserve CLAUDE_CONFIG_DIR while clearing CLAUDE_CODE_OAUTH_TOKEN', async () => {
|
||||
const { ensureCleanProfileEnv } = await import('../rate-limit-detector');
|
||||
|
||||
const env = {
|
||||
CLAUDE_CONFIG_DIR: '/tmp/profile-1',
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-123',
|
||||
ANTHROPIC_API_KEY: 'sk-ant-key-456'
|
||||
};
|
||||
const result = ensureCleanProfileEnv(env);
|
||||
|
||||
expect(result.CLAUDE_CONFIG_DIR).toBe('/tmp/profile-1');
|
||||
expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBe('');
|
||||
expect(result.ANTHROPIC_API_KEY).toBe('');
|
||||
});
|
||||
|
||||
it('should preserve other environment variables', async () => {
|
||||
const { ensureCleanProfileEnv } = await import('../rate-limit-detector');
|
||||
|
||||
const env = {
|
||||
CLAUDE_CONFIG_DIR: '/tmp/profile-1',
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'token',
|
||||
ANTHROPIC_API_KEY: 'key',
|
||||
SOME_OTHER_VAR: 'value'
|
||||
};
|
||||
const result = ensureCleanProfileEnv(env);
|
||||
|
||||
expect(result.CLAUDE_CONFIG_DIR).toBe('/tmp/profile-1');
|
||||
expect(result.SOME_OTHER_VAR).toBe('value');
|
||||
expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBe('');
|
||||
expect(result.ANTHROPIC_API_KEY).toBe('');
|
||||
});
|
||||
|
||||
it('should clear tokens even if they are not present in input', async () => {
|
||||
const { ensureCleanProfileEnv } = await import('../rate-limit-detector');
|
||||
|
||||
const env = {
|
||||
CLAUDE_CONFIG_DIR: '/tmp/profile-1'
|
||||
};
|
||||
const result = ensureCleanProfileEnv(env);
|
||||
|
||||
expect(result.CLAUDE_CONFIG_DIR).toBe('/tmp/profile-1');
|
||||
expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBe('');
|
||||
expect(result.ANTHROPIC_API_KEY).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('without CLAUDE_CONFIG_DIR', () => {
|
||||
it('should return env unchanged when CLAUDE_CONFIG_DIR is not set', async () => {
|
||||
const { ensureCleanProfileEnv } = await import('../rate-limit-detector');
|
||||
|
||||
const env = {
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-123',
|
||||
ANTHROPIC_API_KEY: 'sk-ant-key-456'
|
||||
};
|
||||
const result = ensureCleanProfileEnv(env);
|
||||
|
||||
expect(result).toEqual(env);
|
||||
expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBe('oauth-token-123');
|
||||
expect(result.ANTHROPIC_API_KEY).toBe('sk-ant-key-456');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty profile env', async () => {
|
||||
const { ensureCleanProfileEnv } = await import('../rate-limit-detector');
|
||||
|
||||
const result = ensureCleanProfileEnv({});
|
||||
|
||||
// Empty env has no CLAUDE_CONFIG_DIR, so should return as-is
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle env with empty string CLAUDE_CONFIG_DIR', async () => {
|
||||
const { ensureCleanProfileEnv } = await import('../rate-limit-detector');
|
||||
|
||||
const env = {
|
||||
CLAUDE_CONFIG_DIR: '',
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'token'
|
||||
};
|
||||
const result = ensureCleanProfileEnv(env);
|
||||
|
||||
// Empty string is falsy, so should not trigger clearing
|
||||
expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBe('token');
|
||||
});
|
||||
|
||||
it('should return a new object when clearing (not mutate input)', async () => {
|
||||
const { ensureCleanProfileEnv } = await import('../rate-limit-detector');
|
||||
|
||||
const env = {
|
||||
CLAUDE_CONFIG_DIR: '/tmp/profile-1',
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'token'
|
||||
};
|
||||
const result = ensureCleanProfileEnv(env);
|
||||
|
||||
// Original should not be mutated
|
||||
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe('token');
|
||||
expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBe('');
|
||||
expect(result).not.toBe(env);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -799,4 +799,127 @@ describe('AgentProcessManager - API Profile Env Injection (Story 2.3)', () => {
|
||||
expect(envArg.GITHUB_CLI_PATH).toBe('/opt/homebrew/bin/gh');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CLAUDE_CONFIG_DIR Propagation', () => {
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = { ...process.env };
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('should propagate CLAUDE_CONFIG_DIR from profile env in OAuth mode', async () => {
|
||||
// OAuth mode - no active API profile
|
||||
vi.mocked(profileService.getAPIProfileEnv).mockResolvedValue({});
|
||||
|
||||
// Profile provides CLAUDE_CONFIG_DIR (OAuth subscription profile)
|
||||
vi.mocked(rateLimitDetector.getBestAvailableProfileEnv).mockReturnValue({
|
||||
env: {
|
||||
CLAUDE_CONFIG_DIR: '/home/user/.config/claude-profile-1',
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-abc'
|
||||
},
|
||||
profileId: 'profile-1',
|
||||
profileName: 'Profile 1',
|
||||
wasSwapped: false
|
||||
});
|
||||
|
||||
await processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], {}, 'task-execution');
|
||||
|
||||
expect(spawnCalls).toHaveLength(1);
|
||||
const envArg = spawnCalls[0].options.env as Record<string, unknown>;
|
||||
|
||||
// CLAUDE_CONFIG_DIR should be present in spawn env
|
||||
expect(envArg.CLAUDE_CONFIG_DIR).toBe('/home/user/.config/claude-profile-1');
|
||||
});
|
||||
|
||||
it('should clear ANTHROPIC_API_KEY in OAuth mode with CLAUDE_CONFIG_DIR', async () => {
|
||||
// Simulate stale ANTHROPIC_API_KEY in process.env
|
||||
process.env.ANTHROPIC_API_KEY = 'sk-stale-key';
|
||||
|
||||
// OAuth mode - no active API profile
|
||||
vi.mocked(profileService.getAPIProfileEnv).mockResolvedValue({});
|
||||
|
||||
// Profile provides CLAUDE_CONFIG_DIR
|
||||
vi.mocked(rateLimitDetector.getBestAvailableProfileEnv).mockReturnValue({
|
||||
env: {
|
||||
CLAUDE_CONFIG_DIR: '/home/user/.config/claude-profile-2',
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-def'
|
||||
},
|
||||
profileId: 'profile-2',
|
||||
profileName: 'Profile 2',
|
||||
wasSwapped: false
|
||||
});
|
||||
|
||||
await processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], {}, 'task-execution');
|
||||
|
||||
expect(spawnCalls).toHaveLength(1);
|
||||
const envArg = spawnCalls[0].options.env as Record<string, unknown>;
|
||||
|
||||
// ANTHROPIC_API_KEY should be cleared (empty string) in OAuth mode
|
||||
expect(envArg.ANTHROPIC_API_KEY).toBe('');
|
||||
// CLAUDE_CONFIG_DIR should still be set
|
||||
expect(envArg.CLAUDE_CONFIG_DIR).toBe('/home/user/.config/claude-profile-2');
|
||||
});
|
||||
|
||||
it('should pass ANTHROPIC_* vars without CLAUDE_CONFIG_DIR interference in API profile mode', async () => {
|
||||
// API Profile mode - active profile with custom endpoint
|
||||
const mockApiProfileEnv = {
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-api-profile-key',
|
||||
ANTHROPIC_BASE_URL: 'https://custom-api.example.com',
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-5-20250929'
|
||||
};
|
||||
vi.mocked(profileService.getAPIProfileEnv).mockResolvedValue(mockApiProfileEnv);
|
||||
|
||||
// Profile env without CLAUDE_CONFIG_DIR (API profile mode)
|
||||
vi.mocked(rateLimitDetector.getBestAvailableProfileEnv).mockReturnValue({
|
||||
env: {},
|
||||
profileId: 'api-profile-1',
|
||||
profileName: 'Custom API',
|
||||
wasSwapped: false
|
||||
});
|
||||
|
||||
await processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], {}, 'task-execution');
|
||||
|
||||
expect(spawnCalls).toHaveLength(1);
|
||||
const envArg = spawnCalls[0].options.env as Record<string, unknown>;
|
||||
|
||||
// ANTHROPIC_* vars from API profile should be passed through
|
||||
expect(envArg.ANTHROPIC_AUTH_TOKEN).toBe('sk-api-profile-key');
|
||||
expect(envArg.ANTHROPIC_BASE_URL).toBe('https://custom-api.example.com');
|
||||
expect(envArg.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-20250929');
|
||||
|
||||
// CLAUDE_CONFIG_DIR should NOT be present since profile didn't provide it
|
||||
expect(envArg.CLAUDE_CONFIG_DIR).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should clear CLAUDE_CODE_OAUTH_TOKEN when CLAUDE_CONFIG_DIR is provided by profile', async () => {
|
||||
// OAuth mode
|
||||
vi.mocked(profileService.getAPIProfileEnv).mockResolvedValue({});
|
||||
|
||||
// Profile provides CLAUDE_CONFIG_DIR - agent should use config dir for auth
|
||||
vi.mocked(rateLimitDetector.getBestAvailableProfileEnv).mockReturnValue({
|
||||
env: {
|
||||
CLAUDE_CONFIG_DIR: '/home/user/.config/claude-profile-3',
|
||||
CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-ghi'
|
||||
},
|
||||
profileId: 'profile-3',
|
||||
profileName: 'Profile 3',
|
||||
wasSwapped: false
|
||||
});
|
||||
|
||||
await processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], {}, 'task-execution');
|
||||
|
||||
expect(spawnCalls).toHaveLength(1);
|
||||
const envArg = spawnCalls[0].options.env as Record<string, unknown>;
|
||||
|
||||
// When CLAUDE_CONFIG_DIR is present, CLAUDE_CODE_OAUTH_TOKEN should be cleared
|
||||
// because Claude Code resolves auth from the config dir instead
|
||||
expect(envArg.CLAUDE_CONFIG_DIR).toBe('/home/user/.config/claude-profile-3');
|
||||
expect(envArg.CLAUDE_CODE_OAUTH_TOKEN).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ import { getOAuthModeClearVars } from './env-utils';
|
||||
import { getAugmentedEnv } from '../env-utils';
|
||||
import { getToolInfo, getClaudeCliPathForSdk } from '../cli-tool-manager';
|
||||
import { killProcessGracefully, isWindows } from '../platform';
|
||||
import { debugLog } from '../../shared/utils/debug-logger';
|
||||
|
||||
/**
|
||||
* Type for supported CLI tools
|
||||
@@ -178,6 +179,29 @@ export class AgentProcessManager {
|
||||
// Get best available Claude profile environment (automatically handles rate limits)
|
||||
const profileResult = getBestAvailableProfileEnv();
|
||||
const profileEnv = profileResult.env;
|
||||
|
||||
debugLog('[AgentProcess:setupEnv] Profile result:', {
|
||||
profileId: profileResult.profileId,
|
||||
hasOAuthToken: !!profileEnv.CLAUDE_CODE_OAUTH_TOKEN,
|
||||
hasApiKey: !!profileEnv.ANTHROPIC_API_KEY,
|
||||
hasConfigDir: !!profileEnv.CLAUDE_CONFIG_DIR,
|
||||
configDir: profileEnv.CLAUDE_CONFIG_DIR || '(not set)',
|
||||
oauthTokenPrefix: profileEnv.CLAUDE_CODE_OAUTH_TOKEN?.substring(0, 8) || '(not set)',
|
||||
apiKeyPrefix: profileEnv.ANTHROPIC_API_KEY?.substring(0, 8) || '(not set)',
|
||||
});
|
||||
|
||||
// Warn if profile lacks CLAUDE_CONFIG_DIR - this means the profile has no configDir
|
||||
// and subscription metadata may not propagate correctly to the agent subprocess
|
||||
if (!profileEnv.CLAUDE_CONFIG_DIR) {
|
||||
console.warn('[AgentProcess:setupEnv] WARNING: Profile env lacks CLAUDE_CONFIG_DIR - profile may not have a configDir set. Subscription metadata may not reach agent subprocess.');
|
||||
}
|
||||
|
||||
debugLog('[AgentProcess:setupEnv] extraEnv auth keys:', {
|
||||
hasOAuthToken: !!extraEnv.CLAUDE_CODE_OAUTH_TOKEN,
|
||||
hasApiKey: !!extraEnv.ANTHROPIC_API_KEY,
|
||||
hasConfigDir: !!extraEnv.CLAUDE_CONFIG_DIR,
|
||||
});
|
||||
|
||||
// Use getAugmentedEnv() to ensure common tool paths (dotnet, homebrew, etc.)
|
||||
// are available even when app is launched from Finder/Dock
|
||||
const augmentedEnv = getAugmentedEnv();
|
||||
@@ -205,7 +229,9 @@ export class AgentProcessManager {
|
||||
const ghCliEnv = this.detectAndSetCliPath('gh');
|
||||
const glabCliEnv = this.detectAndSetCliPath('glab');
|
||||
|
||||
return {
|
||||
// Profile env is spread last to ensure CLAUDE_CONFIG_DIR and auth vars
|
||||
// from the active profile always win over extraEnv or augmentedEnv.
|
||||
const mergedEnv = {
|
||||
...augmentedEnv,
|
||||
...gitBashEnv,
|
||||
...claudeCliEnv,
|
||||
@@ -217,6 +243,29 @@ export class AgentProcessManager {
|
||||
PYTHONIOENCODING: 'utf-8',
|
||||
PYTHONUTF8: '1'
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
// When the active profile provides CLAUDE_CONFIG_DIR, clear CLAUDE_CODE_OAUTH_TOKEN
|
||||
// from the spawn environment. CLAUDE_CONFIG_DIR lets Claude Code resolve its own
|
||||
// OAuth tokens from the config directory, making an explicit token unnecessary.
|
||||
// This matches the terminal pattern in claude-integration-handler.ts where
|
||||
// configDir is preferred over direct token injection.
|
||||
// We check profileEnv specifically (not mergedEnv) to avoid clearing the token
|
||||
// when CLAUDE_CONFIG_DIR comes from the shell environment rather than the profile.
|
||||
if (profileEnv.CLAUDE_CONFIG_DIR) {
|
||||
mergedEnv.CLAUDE_CODE_OAUTH_TOKEN = '';
|
||||
debugLog('[AgentProcess:setupEnv] Profile provides CLAUDE_CONFIG_DIR, cleared CLAUDE_CODE_OAUTH_TOKEN from spawn env');
|
||||
}
|
||||
|
||||
debugLog('[AgentProcess:setupEnv] Final merged env auth state:', {
|
||||
hasOAuthToken: !!mergedEnv.CLAUDE_CODE_OAUTH_TOKEN,
|
||||
hasApiKey: !!mergedEnv.ANTHROPIC_API_KEY,
|
||||
hasConfigDir: !!mergedEnv.CLAUDE_CONFIG_DIR,
|
||||
configDir: mergedEnv.CLAUDE_CONFIG_DIR || '(not set)',
|
||||
oauthTokenPrefix: mergedEnv.CLAUDE_CODE_OAUTH_TOKEN?.substring(0, 8) || '(not set)',
|
||||
apiKeyPrefix: mergedEnv.ANTHROPIC_API_KEY?.substring(0, 8) || '(not set)',
|
||||
});
|
||||
|
||||
return mergedEnv;
|
||||
}
|
||||
|
||||
private handleProcessFailure(
|
||||
@@ -615,6 +664,21 @@ export class AgentProcessManager {
|
||||
// Get OAuth mode clearing vars (clears stale ANTHROPIC_* vars when in OAuth mode)
|
||||
const oauthModeClearVars = getOAuthModeClearVars(apiProfileEnv);
|
||||
|
||||
debugLog('[AgentProcess:spawnProcess] Environment merge chain for task:', taskId, {
|
||||
baseEnv: {
|
||||
hasOAuthToken: !!env.CLAUDE_CODE_OAUTH_TOKEN,
|
||||
hasApiKey: !!env.ANTHROPIC_API_KEY,
|
||||
hasConfigDir: !!env.CLAUDE_CONFIG_DIR,
|
||||
configDir: env.CLAUDE_CONFIG_DIR || '(not set)',
|
||||
},
|
||||
oauthModeClearVars: Object.keys(oauthModeClearVars),
|
||||
apiProfileEnv: {
|
||||
hasApiKey: !!apiProfileEnv.ANTHROPIC_API_KEY,
|
||||
hasBaseUrl: !!apiProfileEnv.ANTHROPIC_BASE_URL,
|
||||
apiKeyPrefix: apiProfileEnv.ANTHROPIC_API_KEY?.substring(0, 8) || '(not set)',
|
||||
},
|
||||
});
|
||||
|
||||
// Parse Python commandto handle space-separated commands like "py -3"
|
||||
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(this.getPythonPath());
|
||||
let childProcess;
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
expandHomePath,
|
||||
getEmailFromConfigDir
|
||||
} from './claude-profile/profile-utils';
|
||||
import { debugLog } from '../shared/utils/debug-logger';
|
||||
|
||||
/**
|
||||
* Manages Claude Code profiles for multi-account support.
|
||||
@@ -86,6 +87,8 @@ export class ClaudeProfileManager {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[ClaudeProfileManager] Starting initialization...');
|
||||
|
||||
// Ensure directory exists (async) - mkdir with recursive:true is idempotent
|
||||
await mkdir(this.configDir, { recursive: true });
|
||||
|
||||
@@ -93,6 +96,9 @@ export class ClaudeProfileManager {
|
||||
const loadedData = await loadProfileStoreAsync(this.storePath);
|
||||
if (loadedData) {
|
||||
this.data = loadedData;
|
||||
debugLog('[ClaudeProfileManager] Loaded profile store with', this.data.profiles.length, 'profiles');
|
||||
} else {
|
||||
debugLog('[ClaudeProfileManager] No existing profile store found, using defaults');
|
||||
}
|
||||
|
||||
// Run one-time migration to fix corrupted emails
|
||||
@@ -104,6 +110,7 @@ export class ClaudeProfileManager {
|
||||
this.populateSubscriptionMetadata();
|
||||
|
||||
this.initialized = true;
|
||||
console.log('[ClaudeProfileManager] Initialization complete');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,13 +156,20 @@ export class ClaudeProfileManager {
|
||||
private populateSubscriptionMetadata(): void {
|
||||
let needsSave = false;
|
||||
|
||||
debugLog('[ClaudeProfileManager] populateSubscriptionMetadata: checking', this.data.profiles.length, 'profiles');
|
||||
|
||||
for (const profile of this.data.profiles) {
|
||||
if (!profile.configDir) {
|
||||
debugLog('[ClaudeProfileManager] populateSubscriptionMetadata: skipping profile', profile.id, '(no configDir)');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if profile already has subscription metadata
|
||||
if (profile.subscriptionType && profile.rateLimitTier) {
|
||||
debugLog('[ClaudeProfileManager] populateSubscriptionMetadata: profile', profile.id, 'already has metadata:', {
|
||||
subscriptionType: profile.subscriptionType,
|
||||
rateLimitTier: profile.rateLimitTier
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -542,8 +556,27 @@ export class ClaudeProfileManager {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('[ClaudeProfileManager] Using CLAUDE_CONFIG_DIR for profile:', profile.name, expandedConfigDir);
|
||||
}
|
||||
} else {
|
||||
console.warn('[ClaudeProfileManager] Profile has no configDir configured:', profile?.name);
|
||||
} else if (profile) {
|
||||
// Fallback: retrieve OAuth token directly from Keychain when configDir is missing.
|
||||
// Without configDir, Claude CLI cannot resolve credentials automatically,
|
||||
// so we inject CLAUDE_CODE_OAUTH_TOKEN as a direct override.
|
||||
debugLog(
|
||||
'[ClaudeProfileManager] Profile has no configDir configured:',
|
||||
profile.name,
|
||||
'- falling back to Keychain token lookup. Subscription display may be degraded.'
|
||||
);
|
||||
|
||||
const credentials = getCredentialsFromKeychain(undefined, true);
|
||||
if (credentials.token) {
|
||||
env.CLAUDE_CODE_OAUTH_TOKEN = credentials.token;
|
||||
debugLog('[ClaudeProfileManager] Injected CLAUDE_CODE_OAUTH_TOKEN from Keychain for profile:', profile.name);
|
||||
} else {
|
||||
debugLog(
|
||||
'[ClaudeProfileManager] No token found in Keychain for profile without configDir:',
|
||||
profile.name,
|
||||
credentials.error ? `(error: ${credentials.error})` : ''
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return env;
|
||||
@@ -801,8 +834,26 @@ export class ClaudeProfileManager {
|
||||
return {};
|
||||
}
|
||||
|
||||
// If no configDir is defined, fall back to default
|
||||
if (!profile.configDir) {
|
||||
// Fallback: retrieve OAuth token directly from Keychain when configDir is missing.
|
||||
// Without configDir, Claude CLI cannot resolve credentials automatically,
|
||||
// so we inject CLAUDE_CODE_OAUTH_TOKEN as a direct override.
|
||||
// This mirrors the fallback in getActiveProfileEnv().
|
||||
debugLog(
|
||||
'[ClaudeProfileManager] getProfileEnv: profile has no configDir:',
|
||||
profile.name,
|
||||
'- falling back to Keychain token lookup.'
|
||||
);
|
||||
|
||||
const credentials = getCredentialsFromKeychain(undefined, true);
|
||||
if (credentials.token) {
|
||||
debugLog('[ClaudeProfileManager] getProfileEnv: injected CLAUDE_CODE_OAUTH_TOKEN from Keychain for profile:', profile.name);
|
||||
return { CLAUDE_CODE_OAUTH_TOKEN: credentials.token };
|
||||
}
|
||||
debugLog(
|
||||
'[ClaudeProfileManager] getProfileEnv: no token found in Keychain for profile without configDir:',
|
||||
profile.name
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
|
||||
@@ -278,7 +278,7 @@ export interface PRReviewResult {
|
||||
success: boolean;
|
||||
findings: PRReviewFinding[];
|
||||
summary: string;
|
||||
overallStatus: "approve" | "request_changes" | "comment";
|
||||
overallStatus: "approve" | "request_changes" | "comment" | "in_progress";
|
||||
reviewId?: number;
|
||||
reviewedAt: string;
|
||||
error?: string;
|
||||
@@ -294,6 +294,8 @@ export interface PRReviewResult {
|
||||
hasPostedFindings?: boolean;
|
||||
postedFindingIds?: string[];
|
||||
postedAt?: string;
|
||||
// In-progress review tracking
|
||||
inProgressSince?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1356,6 +1358,8 @@ function getReviewResult(project: Project, prNumber: number): PRReviewResult | n
|
||||
hasPostedFindings: data.has_posted_findings ?? false,
|
||||
postedFindingIds: data.posted_finding_ids ?? [],
|
||||
postedAt: data.posted_at,
|
||||
// In-progress review tracking
|
||||
inProgressSince: data.in_progress_since,
|
||||
};
|
||||
} catch {
|
||||
// File doesn't exist or couldn't be read
|
||||
@@ -1514,7 +1518,32 @@ async function runPRReview(
|
||||
debugLog("Auth failure detected in PR review", authFailureInfo);
|
||||
mainWindow.webContents.send(IPC_CHANNELS.CLAUDE_AUTH_FAILURE, authFailureInfo);
|
||||
},
|
||||
onComplete: () => {
|
||||
onComplete: (stdout: string) => {
|
||||
// Check stdout for in_progress JSON marker (not saved to disk by backend)
|
||||
const inProgressMarker = "__RESULT_JSON__:";
|
||||
for (const line of stdout.split("\n")) {
|
||||
if (line.startsWith(inProgressMarker)) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(inProgressMarker.length));
|
||||
if (data.overall_status === "in_progress") {
|
||||
debugLog("In-progress result parsed from stdout", { prNumber });
|
||||
return {
|
||||
prNumber: data.pr_number,
|
||||
repo: data.repo,
|
||||
success: data.success,
|
||||
findings: [],
|
||||
summary: data.summary ?? "",
|
||||
overallStatus: "in_progress" as const,
|
||||
reviewedAt: data.reviewed_at ?? new Date().toISOString(),
|
||||
inProgressSince: data.in_progress_since,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
debugLog("Failed to parse __RESULT_JSON__ line", { line });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load the result from disk
|
||||
const reviewResult = getReviewResult(project, prNumber);
|
||||
if (!reviewResult) {
|
||||
@@ -1864,9 +1893,15 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
|
||||
projectId
|
||||
);
|
||||
|
||||
// Check if already running
|
||||
// Check if already running — notify renderer so it can display ongoing logs
|
||||
if (runningReviews.has(reviewKey)) {
|
||||
debugLog("Review already running", { reviewKey });
|
||||
debugLog("Review already running, notifying renderer", { reviewKey });
|
||||
sendProgress({
|
||||
phase: "analyzing",
|
||||
prNumber,
|
||||
progress: 50,
|
||||
message: "Review is already in progress. Reconnecting to ongoing review...",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1936,6 +1971,20 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
|
||||
|
||||
const result = await runPRReview(project, prNumber, mainWindow);
|
||||
|
||||
if (result.overallStatus === "in_progress") {
|
||||
// Review is already running externally (detected by BotDetector).
|
||||
// Send the result as-is so the renderer can activate external review polling.
|
||||
debugLog("PR review already in progress externally", { prNumber });
|
||||
sendProgress({
|
||||
phase: "complete",
|
||||
prNumber,
|
||||
progress: 100,
|
||||
message: "Review already in progress",
|
||||
});
|
||||
sendComplete(result);
|
||||
return;
|
||||
}
|
||||
|
||||
debugLog("PR review completed", { prNumber, findingsCount: result.findings.length });
|
||||
sendProgress({
|
||||
phase: "complete",
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
OtherWorktreeInfo,
|
||||
} from '../../../shared/types';
|
||||
import path from 'path';
|
||||
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync, symlinkSync, lstatSync } from 'fs';
|
||||
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync, symlinkSync, lstatSync, copyFileSync, cpSync, statSync } from 'fs';
|
||||
import { execFileSync, execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { minimatch } from 'minimatch';
|
||||
@@ -226,87 +226,405 @@ function getDefaultBranch(projectPath: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Symlink node_modules from project root to worktree for TypeScript and tooling support.
|
||||
* This allows pre-commit hooks and IDE features to work without npm install in the worktree.
|
||||
* Configuration for a single dependency to be shared in a worktree.
|
||||
*/
|
||||
interface DependencyConfig {
|
||||
/** Dependency type identifier (e.g., 'node_modules', 'venv') */
|
||||
depType: string;
|
||||
/** Strategy for sharing this dependency in worktrees */
|
||||
strategy: 'symlink' | 'recreate' | 'copy' | 'skip';
|
||||
/** Relative path from project root to the dependency directory */
|
||||
sourceRelPath: string;
|
||||
/** Path to requirements file for recreate strategy (e.g., 'requirements.txt') */
|
||||
requirementsFile?: string;
|
||||
/** Package manager used (e.g., 'npm', 'pip', 'uv') */
|
||||
packageManager?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default mapping from dependency type to sharing strategy.
|
||||
*
|
||||
* Data-driven — add new entries here rather than writing if/else branches.
|
||||
* Mirrors the Python implementation in apps/backend/core/workspace/dependency_strategy.py.
|
||||
*/
|
||||
const DEFAULT_STRATEGY_MAP: Record<string, 'symlink' | 'recreate' | 'copy' | 'skip'> = {
|
||||
// JavaScript / Node.js — symlink is safe and fast
|
||||
node_modules: 'symlink',
|
||||
// Python — venvs MUST be recreated, not symlinked.
|
||||
// CPython bug #106045: pyvenv.cfg discovery does not resolve symlinks,
|
||||
// so a symlinked venv resolves paths relative to the target, not the worktree.
|
||||
venv: 'recreate',
|
||||
'.venv': 'recreate',
|
||||
// PHP — Composer vendor dir is safe to symlink
|
||||
vendor_php: 'symlink',
|
||||
// Ruby — Bundler vendor/bundle is safe to symlink
|
||||
vendor_bundle: 'symlink',
|
||||
// Rust — build output dir, skip (rebuilt per-worktree)
|
||||
cargo_target: 'skip',
|
||||
// Go — global module cache, nothing in-tree to share
|
||||
go_modules: 'skip',
|
||||
};
|
||||
|
||||
/**
|
||||
* Load dependency configs from the project index, or fall back to hardcoded
|
||||
* node_modules-only behavior for backward compatibility.
|
||||
*/
|
||||
function loadDependencyConfigs(projectPath: string): DependencyConfig[] {
|
||||
const indexPath = path.join(projectPath, '.auto-claude', 'project_index.json');
|
||||
|
||||
if (existsSync(indexPath)) {
|
||||
try {
|
||||
const index = JSON.parse(readFileSync(indexPath, 'utf-8'));
|
||||
// Use the aggregated top-level dependency_locations which already
|
||||
// contain project-relative paths (e.g. "apps/backend/.venv" instead
|
||||
// of just ".venv"), avoiding a monorepo path resolution bug.
|
||||
const depLocations = index?.dependency_locations;
|
||||
if (Array.isArray(depLocations)) {
|
||||
const configs: DependencyConfig[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const dep of depLocations) {
|
||||
if (!dep || typeof dep !== 'object') continue;
|
||||
const depObj = dep as Record<string, unknown>;
|
||||
const depType = String(depObj.type || '');
|
||||
const relPath = String(depObj.path || '');
|
||||
if (!depType || !relPath || seen.has(relPath)) continue;
|
||||
|
||||
// Path containment: reject absolute paths and traversals
|
||||
if (path.isAbsolute(relPath)) continue;
|
||||
if (relPath.split('/').includes('..') || relPath.split('\\').includes('..')) continue;
|
||||
|
||||
// Defense-in-depth: verify resolved path stays within project
|
||||
const resolved = path.resolve(projectPath, relPath);
|
||||
if (!resolved.startsWith(path.resolve(projectPath) + path.sep)) continue;
|
||||
|
||||
seen.add(relPath);
|
||||
|
||||
const strategy = DEFAULT_STRATEGY_MAP[depType] ?? 'skip';
|
||||
|
||||
// Validate requirementsFile path containment
|
||||
let reqFile: string | undefined;
|
||||
if (depObj.requirements_file) {
|
||||
const rf = String(depObj.requirements_file);
|
||||
const rfParts = rf.split('/');
|
||||
const rfPartsWin = rf.split('\\');
|
||||
if (!path.isAbsolute(rf) && !rfParts.includes('..') && !rfPartsWin.includes('..')) {
|
||||
// Defense-in-depth: resolved-path containment (matches relPath check)
|
||||
const resolvedReq = path.resolve(projectPath, rf);
|
||||
if (resolvedReq.startsWith(path.resolve(projectPath) + path.sep)) {
|
||||
reqFile = rf;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
configs.push({
|
||||
depType,
|
||||
strategy,
|
||||
sourceRelPath: relPath,
|
||||
requirementsFile: reqFile,
|
||||
packageManager: depObj.package_manager ? String(depObj.package_manager) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (configs.length > 0) {
|
||||
return configs;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
debugError('[TerminalWorktree] Failed to read project index:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: hardcoded node_modules-only behavior (same as legacy)
|
||||
return [
|
||||
{ depType: 'node_modules', strategy: 'symlink', sourceRelPath: 'node_modules' },
|
||||
{ depType: 'node_modules', strategy: 'symlink', sourceRelPath: 'apps/frontend/node_modules' },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up dependencies in a worktree using strategy-based dispatch.
|
||||
*
|
||||
* Reads dependency configs from the project index and applies the correct
|
||||
* strategy for each: symlink, recreate, copy, or skip.
|
||||
*
|
||||
* All operations are non-blocking on failure — errors are logged but never thrown.
|
||||
*
|
||||
* @param projectPath - The main project directory
|
||||
* @param worktreePath - Path to the worktree
|
||||
* @returns Array of symlinked paths (relative to worktree)
|
||||
* @returns Array of successfully processed dependency relative paths
|
||||
*/
|
||||
function symlinkNodeModulesToWorktree(projectPath: string, worktreePath: string): string[] {
|
||||
async function setupWorktreeDependencies(projectPath: string, worktreePath: string): Promise<string[]> {
|
||||
const configs = loadDependencyConfigs(projectPath);
|
||||
const processed: string[] = [];
|
||||
|
||||
for (const config of configs) {
|
||||
try {
|
||||
let performed = false;
|
||||
switch (config.strategy) {
|
||||
case 'symlink':
|
||||
performed = applySymlinkStrategy(projectPath, worktreePath, config);
|
||||
break;
|
||||
case 'recreate':
|
||||
performed = await applyRecreateStrategy(projectPath, worktreePath, config);
|
||||
break;
|
||||
case 'copy':
|
||||
performed = applyCopyStrategy(projectPath, worktreePath, config);
|
||||
break;
|
||||
case 'skip':
|
||||
debugLog('[TerminalWorktree] Skipping', config.depType, `(${config.sourceRelPath}) - skip strategy`);
|
||||
continue; // Don't record skipped entries in processed list
|
||||
}
|
||||
if (performed) processed.push(config.sourceRelPath);
|
||||
} catch (error) {
|
||||
debugError('[TerminalWorktree] Failed to apply', config.strategy, 'strategy for', config.sourceRelPath, ':', error);
|
||||
console.warn(`[TerminalWorktree] Warning: Failed to set up ${config.sourceRelPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply symlink strategy: create a symlink (or Windows junction) from worktree to project source.
|
||||
* Reuses the existing platform-specific symlink creation pattern.
|
||||
*/
|
||||
function applySymlinkStrategy(projectPath: string, worktreePath: string, config: DependencyConfig): boolean {
|
||||
const sourcePath = path.join(projectPath, config.sourceRelPath);
|
||||
const targetPath = path.join(worktreePath, config.sourceRelPath);
|
||||
|
||||
if (!existsSync(sourcePath)) {
|
||||
debugLog('[TerminalWorktree] Skipping symlink', config.sourceRelPath, '- source missing');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (existsSync(targetPath)) {
|
||||
debugLog('[TerminalWorktree] Skipping symlink', config.sourceRelPath, '- target exists');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for broken symlinks
|
||||
try {
|
||||
lstatSync(targetPath);
|
||||
debugLog('[TerminalWorktree] Skipping symlink', config.sourceRelPath, '- target exists (possibly broken symlink)');
|
||||
return false;
|
||||
} catch {
|
||||
// Target doesn't exist at all — good, we can create symlink
|
||||
}
|
||||
|
||||
const targetDir = path.dirname(targetPath);
|
||||
if (!existsSync(targetDir)) {
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
try {
|
||||
if (isWindows()) {
|
||||
symlinkSync(sourcePath, targetPath, 'junction');
|
||||
debugLog('[TerminalWorktree] Created junction (Windows):', config.sourceRelPath, '->', sourcePath);
|
||||
} else {
|
||||
const relativePath = path.relative(path.dirname(targetPath), sourcePath);
|
||||
symlinkSync(relativePath, targetPath);
|
||||
debugLog('[TerminalWorktree] Created symlink (Unix):', config.sourceRelPath, '->', relativePath);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
debugError('[TerminalWorktree] Could not create symlink for', config.sourceRelPath, ':', error);
|
||||
console.warn(`[TerminalWorktree] Warning: Failed to link ${config.sourceRelPath}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply recreate strategy: create a fresh virtual environment in the worktree.
|
||||
*
|
||||
* Python venvs cannot be symlinked due to CPython bug #106045 — pyvenv.cfg
|
||||
* discovery does not resolve symlinks, so paths resolve relative to the
|
||||
* symlink target instead of the worktree.
|
||||
*/
|
||||
async function applyRecreateStrategy(projectPath: string, worktreePath: string, config: DependencyConfig): Promise<boolean> {
|
||||
const venvPath = path.join(worktreePath, config.sourceRelPath);
|
||||
|
||||
if (existsSync(venvPath)) {
|
||||
debugLog('[TerminalWorktree] Skipping recreate', config.sourceRelPath, '- already exists');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Detect Python executable from the source venv or fall back to system Python
|
||||
const sourceVenv = path.join(projectPath, config.sourceRelPath);
|
||||
let pythonExec = isWindows() ? 'python' : 'python3';
|
||||
|
||||
if (existsSync(sourceVenv)) {
|
||||
const unixCandidate = path.join(sourceVenv, 'bin', 'python');
|
||||
const winCandidate = path.join(sourceVenv, 'Scripts', 'python.exe');
|
||||
if (existsSync(unixCandidate)) {
|
||||
pythonExec = unixCandidate;
|
||||
} else if (existsSync(winCandidate)) {
|
||||
pythonExec = winCandidate;
|
||||
}
|
||||
}
|
||||
|
||||
// Create the venv
|
||||
try {
|
||||
debugLog('[TerminalWorktree] Creating venv at', config.sourceRelPath);
|
||||
await execFileAsync(pythonExec, ['-m', 'venv', venvPath], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 120000,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isTimeoutError(error)) {
|
||||
debugError('[TerminalWorktree] venv creation timed out for', config.sourceRelPath);
|
||||
console.warn(`[TerminalWorktree] Warning: venv creation timed out for ${config.sourceRelPath}`);
|
||||
} else {
|
||||
debugError('[TerminalWorktree] venv creation failed for', config.sourceRelPath, ':', error);
|
||||
console.warn(`[TerminalWorktree] Warning: Could not create venv at ${config.sourceRelPath}`);
|
||||
}
|
||||
// Clean up partial venv so retries aren't blocked
|
||||
if (existsSync(venvPath)) {
|
||||
try { rmSync(venvPath, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Install from requirements file if specified
|
||||
if (config.requirementsFile) {
|
||||
const reqPath = path.join(projectPath, config.requirementsFile);
|
||||
if (existsSync(reqPath)) {
|
||||
const pipExec = isWindows()
|
||||
? path.join(venvPath, 'Scripts', 'pip.exe')
|
||||
: path.join(venvPath, 'bin', 'pip');
|
||||
|
||||
// Build install command based on file type
|
||||
const reqBasename = path.basename(config.requirementsFile);
|
||||
let installArgs: string[] | null;
|
||||
if (reqBasename === 'pyproject.toml') {
|
||||
// Snapshot-install from worktree copy (non-editable to avoid
|
||||
// symlinking back to the main project source tree).
|
||||
const worktreeReq = path.join(worktreePath, config.requirementsFile!);
|
||||
const installDir = existsSync(worktreeReq) ? path.dirname(worktreeReq) : path.dirname(reqPath);
|
||||
installArgs = ['install', installDir];
|
||||
} else if (reqBasename === 'Pipfile') {
|
||||
debugLog('[TerminalWorktree] Skipping Pipfile-based install (use pipenv in worktree)');
|
||||
installArgs = null;
|
||||
} else {
|
||||
installArgs = ['install', '-r', reqPath];
|
||||
}
|
||||
|
||||
if (installArgs) {
|
||||
try {
|
||||
debugLog('[TerminalWorktree] Installing deps from', config.requirementsFile);
|
||||
await execFileAsync(pipExec, installArgs, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 120000,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isTimeoutError(error)) {
|
||||
debugError('[TerminalWorktree] pip install timed out for', config.requirementsFile);
|
||||
console.warn(`[TerminalWorktree] Warning: Dependency install timed out for ${config.requirementsFile}`);
|
||||
} else {
|
||||
debugError('[TerminalWorktree] pip install failed:', error);
|
||||
}
|
||||
// Clean up broken venv so retries aren't blocked
|
||||
if (existsSync(venvPath)) {
|
||||
try { rmSync(venvPath, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debugLog('[TerminalWorktree] Recreated venv at', config.sourceRelPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply copy strategy: copy a file or directory from project to worktree.
|
||||
*/
|
||||
function applyCopyStrategy(projectPath: string, worktreePath: string, config: DependencyConfig): boolean {
|
||||
const sourcePath = path.join(projectPath, config.sourceRelPath);
|
||||
const targetPath = path.join(worktreePath, config.sourceRelPath);
|
||||
|
||||
if (!existsSync(sourcePath)) {
|
||||
debugLog('[TerminalWorktree] Skipping copy', config.sourceRelPath, '- source missing');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (existsSync(targetPath)) {
|
||||
debugLog('[TerminalWorktree] Skipping copy', config.sourceRelPath, '- target exists');
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetDir = path.dirname(targetPath);
|
||||
if (!existsSync(targetDir)) {
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
try {
|
||||
if (statSync(sourcePath).isDirectory()) {
|
||||
cpSync(sourcePath, targetPath, { recursive: true });
|
||||
} else {
|
||||
copyFileSync(sourcePath, targetPath);
|
||||
}
|
||||
debugLog('[TerminalWorktree] Copied', config.sourceRelPath, 'to worktree');
|
||||
return true;
|
||||
} catch (error) {
|
||||
debugError('[TerminalWorktree] Could not copy', config.sourceRelPath, ':', error);
|
||||
console.warn(`[TerminalWorktree] Warning: Could not copy ${config.sourceRelPath}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Symlink the project root's .claude/ directory into a terminal worktree.
|
||||
* This enables Claude Code features (settings, commands, memory) in worktree terminals.
|
||||
* Follows the same pattern as setupWorktreeDependencies().
|
||||
*/
|
||||
function symlinkClaudeConfigToWorktree(projectPath: string, worktreePath: string): string[] {
|
||||
const symlinked: string[] = [];
|
||||
|
||||
// Node modules locations to symlink for TypeScript and tooling support.
|
||||
// These are the standard locations for this monorepo structure.
|
||||
//
|
||||
// Design rationale:
|
||||
// - Hardcoded paths are intentional for simplicity and reliability
|
||||
// - Dynamic discovery (reading workspaces from package.json) would add complexity
|
||||
// and potential failure points without significant benefit
|
||||
// - This monorepo uses npm workspaces with hoisting, so dependencies are primarily
|
||||
// in root node_modules with workspace-specific deps in apps/frontend/node_modules
|
||||
//
|
||||
// To add new workspace locations:
|
||||
// 1. Add [sourceRelPath, targetRelPath] tuple below
|
||||
// 2. Update the parallel Python implementation in apps/backend/core/workspace/setup.py
|
||||
// 3. Update the pre-commit hook check in .husky/pre-commit if needed
|
||||
const nodeModulesLocations = [
|
||||
['node_modules', 'node_modules'],
|
||||
['apps/frontend/node_modules', 'apps/frontend/node_modules'],
|
||||
];
|
||||
const sourceRel = '.claude';
|
||||
const sourcePath = path.join(projectPath, sourceRel);
|
||||
const targetPath = path.join(worktreePath, sourceRel);
|
||||
|
||||
for (const [sourceRel, targetRel] of nodeModulesLocations) {
|
||||
const sourcePath = path.join(projectPath, sourceRel);
|
||||
const targetPath = path.join(worktreePath, targetRel);
|
||||
// Skip if source doesn't exist
|
||||
if (!existsSync(sourcePath)) {
|
||||
debugLog('[TerminalWorktree] Skipping .claude symlink - source does not exist:', sourcePath);
|
||||
return symlinked;
|
||||
}
|
||||
|
||||
// Skip if source doesn't exist
|
||||
if (!existsSync(sourcePath)) {
|
||||
debugLog('[TerminalWorktree] Skipping symlink - source does not exist:', sourceRel);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if target already exists (don't overwrite existing node_modules)
|
||||
if (existsSync(targetPath)) {
|
||||
debugLog('[TerminalWorktree] Skipping symlink - target already exists:', targetRel);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Also skip if target is a symlink (even if broken)
|
||||
try {
|
||||
lstatSync(targetPath);
|
||||
debugLog('[TerminalWorktree] Skipping symlink - target exists (possibly broken symlink):', targetRel);
|
||||
continue;
|
||||
} catch {
|
||||
// Target doesn't exist at all - good, we can create symlink
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
const targetDir = path.dirname(targetPath);
|
||||
if (!existsSync(targetDir)) {
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
try {
|
||||
// Platform-specific symlink creation:
|
||||
// - Windows: Use 'junction' type which requires absolute paths (no admin rights required)
|
||||
// - Unix (macOS/Linux): Use relative paths for portability (worktree can be moved)
|
||||
if (isWindows()) {
|
||||
symlinkSync(sourcePath, targetPath, 'junction');
|
||||
debugLog('[TerminalWorktree] Created junction (Windows):', targetRel, '->', sourcePath);
|
||||
} else {
|
||||
// On Unix, use relative symlinks for portability (matches Python implementation)
|
||||
const relativePath = path.relative(path.dirname(targetPath), sourcePath);
|
||||
symlinkSync(relativePath, targetPath);
|
||||
debugLog('[TerminalWorktree] Created symlink (Unix):', targetRel, '->', relativePath);
|
||||
}
|
||||
symlinked.push(targetRel);
|
||||
} catch (error) {
|
||||
// Symlink creation can fail on some systems (e.g., FAT32 filesystem, or permission issues)
|
||||
// Log warning but don't fail - worktree is still usable, just without TypeScript checking
|
||||
// Note: This warning appears in dev console. Users may see TypeScript errors in pre-commit hooks.
|
||||
debugError('[TerminalWorktree] Could not create symlink for', targetRel, ':', error);
|
||||
console.warn(`[TerminalWorktree] Warning: Failed to link ${targetRel} - TypeScript checks may fail in this worktree`);
|
||||
// Skip if target already exists
|
||||
if (existsSync(targetPath)) {
|
||||
debugLog('[TerminalWorktree] Skipping .claude symlink - target already exists:', targetPath);
|
||||
return symlinked;
|
||||
}
|
||||
|
||||
// Also skip if target is a symlink (even if broken)
|
||||
try {
|
||||
lstatSync(targetPath);
|
||||
debugLog('[TerminalWorktree] Skipping .claude symlink - target exists (possibly broken symlink):', targetPath);
|
||||
return symlinked;
|
||||
} catch {
|
||||
// Target doesn't exist at all - good, we can create symlink
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
const targetDir = path.dirname(targetPath);
|
||||
if (!existsSync(targetDir)) {
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
try {
|
||||
if (isWindows()) {
|
||||
symlinkSync(sourcePath, targetPath, 'junction');
|
||||
debugLog('[TerminalWorktree] Created .claude junction (Windows):', sourceRel, '->', sourcePath);
|
||||
} else {
|
||||
const relativePath = path.relative(path.dirname(targetPath), sourcePath);
|
||||
symlinkSync(relativePath, targetPath);
|
||||
debugLog('[TerminalWorktree] Created .claude symlink (Unix):', sourceRel, '->', relativePath);
|
||||
}
|
||||
symlinked.push(sourceRel);
|
||||
} catch (error) {
|
||||
debugError('[TerminalWorktree] Could not create symlink for .claude:', error);
|
||||
}
|
||||
|
||||
return symlinked;
|
||||
@@ -516,11 +834,17 @@ async function createTerminalWorktree(
|
||||
debugLog('[TerminalWorktree] Created worktree in detached HEAD mode from', baseRef);
|
||||
}
|
||||
|
||||
// Symlink node_modules for TypeScript and tooling support
|
||||
// Set up dependencies (node_modules, venvs, etc.) for tooling support
|
||||
// This allows pre-commit hooks to run typecheck without npm install in worktree
|
||||
const symlinkedModules = symlinkNodeModulesToWorktree(projectPath, worktreePath);
|
||||
if (symlinkedModules.length > 0) {
|
||||
debugLog('[TerminalWorktree] Symlinked dependencies:', symlinkedModules.join(', '));
|
||||
const setupDeps = await setupWorktreeDependencies(projectPath, worktreePath);
|
||||
if (setupDeps.length > 0) {
|
||||
debugLog('[TerminalWorktree] Set up worktree dependencies:', setupDeps.join(', '));
|
||||
}
|
||||
|
||||
// Symlink .claude/ config for Claude Code features (settings, commands, memory)
|
||||
const symlinkedClaude = symlinkClaudeConfigToWorktree(projectPath, worktreePath);
|
||||
if (symlinkedClaude.length > 0) {
|
||||
debugLog('[TerminalWorktree] Symlinked Claude config:', symlinkedClaude.join(', '));
|
||||
}
|
||||
|
||||
const config: TerminalWorktreeConfig = {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { getClaudeProfileManager } from './claude-profile-manager';
|
||||
import { getUsageMonitor } from './claude-profile/usage-monitor';
|
||||
import { debugLog } from '../shared/utils/debug-logger';
|
||||
|
||||
/**
|
||||
* Regex pattern to detect Claude Code rate limit messages
|
||||
@@ -476,6 +477,14 @@ export function getBestAvailableProfileEnv(): BestProfileEnvResult {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const activeProfile = profileManager.getActiveProfile();
|
||||
|
||||
debugLog('[RateLimitDetector] getBestAvailableProfileEnv() called:', {
|
||||
activeProfileId: activeProfile.id,
|
||||
activeProfileName: activeProfile.name,
|
||||
hasConfigDir: !!activeProfile.configDir,
|
||||
configDir: activeProfile.configDir,
|
||||
weeklyUsagePercent: activeProfile.usage?.weeklyUsagePercent,
|
||||
});
|
||||
|
||||
// Check for explicit rate limit (from previous API errors)
|
||||
const rateLimitStatus = profileManager.isProfileRateLimited(activeProfile.id);
|
||||
|
||||
@@ -492,28 +501,24 @@ export function getBestAvailableProfileEnv(): BestProfileEnvResult {
|
||||
: undefined;
|
||||
|
||||
if (needsSwap) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('[RateLimitDetector] Active profile needs swap:', {
|
||||
activeProfile: activeProfile.name,
|
||||
isRateLimited: rateLimitStatus.limited,
|
||||
isAtCapacity,
|
||||
weeklyUsage: activeProfile.usage?.weeklyUsagePercent,
|
||||
limitType: rateLimitStatus.type,
|
||||
resetAt: rateLimitStatus.resetAt
|
||||
});
|
||||
}
|
||||
debugLog('[RateLimitDetector] Active profile needs swap:', {
|
||||
activeProfile: activeProfile.name,
|
||||
isRateLimited: rateLimitStatus.limited,
|
||||
isAtCapacity,
|
||||
weeklyUsage: activeProfile.usage?.weeklyUsagePercent,
|
||||
limitType: rateLimitStatus.type,
|
||||
resetAt: rateLimitStatus.resetAt
|
||||
});
|
||||
|
||||
// Try to find a better profile
|
||||
const bestProfile = profileManager.getBestAvailableProfile(activeProfile.id);
|
||||
|
||||
if (bestProfile) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('[RateLimitDetector] Using alternative profile:', {
|
||||
originalProfile: activeProfile.name,
|
||||
alternativeProfile: bestProfile.name,
|
||||
reason: swapReason
|
||||
});
|
||||
}
|
||||
debugLog('[RateLimitDetector] Using alternative profile:', {
|
||||
originalProfile: activeProfile.name,
|
||||
alternativeProfile: bestProfile.name,
|
||||
reason: swapReason
|
||||
});
|
||||
|
||||
// Persist the swap by updating the active profile
|
||||
// This ensures the UI reflects which account is actually being used
|
||||
@@ -564,6 +569,14 @@ export function getBestAvailableProfileEnv(): BestProfileEnvResult {
|
||||
|
||||
const profileEnv = profileManager.getProfileEnv(bestProfile.id);
|
||||
|
||||
debugLog('[RateLimitDetector] Profile env for swapped profile:', {
|
||||
profileId: bestProfile.id,
|
||||
hasClaudeConfigDir: !!profileEnv.CLAUDE_CONFIG_DIR,
|
||||
claudeConfigDir: profileEnv.CLAUDE_CONFIG_DIR,
|
||||
hasOAuthToken: !!profileEnv.CLAUDE_CODE_OAUTH_TOKEN,
|
||||
envKeys: Object.keys(profileEnv),
|
||||
});
|
||||
|
||||
return {
|
||||
env: ensureCleanProfileEnv(profileEnv),
|
||||
profileId: bestProfile.id,
|
||||
@@ -576,14 +589,21 @@ export function getBestAvailableProfileEnv(): BestProfileEnvResult {
|
||||
}
|
||||
};
|
||||
} else {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('[RateLimitDetector] No alternative profile available, using rate-limited/at-capacity profile');
|
||||
}
|
||||
debugLog('[RateLimitDetector] No alternative profile available, using rate-limited/at-capacity profile');
|
||||
}
|
||||
}
|
||||
|
||||
// Use active profile (either it's fine, or no better alternative exists)
|
||||
const activeEnv = profileManager.getActiveProfileEnv();
|
||||
|
||||
debugLog('[RateLimitDetector] Using active profile env (no swap):', {
|
||||
profileId: activeProfile.id,
|
||||
hasClaudeConfigDir: !!activeEnv.CLAUDE_CONFIG_DIR,
|
||||
claudeConfigDir: activeEnv.CLAUDE_CONFIG_DIR,
|
||||
hasOAuthToken: !!activeEnv.CLAUDE_CODE_OAUTH_TOKEN,
|
||||
envKeys: Object.keys(activeEnv),
|
||||
});
|
||||
|
||||
return {
|
||||
env: ensureCleanProfileEnv(activeEnv),
|
||||
profileId: activeProfile.id,
|
||||
@@ -595,23 +615,55 @@ export function getBestAvailableProfileEnv(): BestProfileEnvResult {
|
||||
/**
|
||||
* Ensure the profile environment is clean for subprocess invocation.
|
||||
*
|
||||
* When CLAUDE_CONFIG_DIR is set, we MUST clear CLAUDE_CODE_OAUTH_TOKEN to prevent
|
||||
* the Claude Agent SDK from using a hardcoded/cached token (e.g., from .env file)
|
||||
* instead of reading fresh credentials from the specified config directory.
|
||||
* When CLAUDE_CONFIG_DIR is set, we MUST clear both CLAUDE_CODE_OAUTH_TOKEN and
|
||||
* ANTHROPIC_API_KEY to prevent the Claude Agent SDK from using hardcoded/cached
|
||||
* tokens or API keys (e.g., from .env file or shell environment) instead of reading
|
||||
* fresh credentials from the specified config directory.
|
||||
*
|
||||
* ANTHROPIC_API_KEY is cleared to prevent Claude Code from using API keys present
|
||||
* in the shell environment, which would cause it to show "Claude API" instead of
|
||||
* "Claude Max" and bypass the intended config dir credentials.
|
||||
*
|
||||
* This is critical for multi-account switching: when switching from a rate-limited
|
||||
* account to an available one, the subprocess must use the new account's credentials.
|
||||
*
|
||||
* Also warns if the profile env is empty, which indicates a misconfigured profile.
|
||||
*
|
||||
* @param env - Profile environment from getProfileEnv() or getActiveProfileEnv()
|
||||
* @returns Environment with CLAUDE_CODE_OAUTH_TOKEN cleared if CLAUDE_CONFIG_DIR is set
|
||||
* @returns Environment with CLAUDE_CODE_OAUTH_TOKEN and ANTHROPIC_API_KEY cleared if CLAUDE_CONFIG_DIR is set
|
||||
*/
|
||||
function ensureCleanProfileEnv(env: Record<string, string>): Record<string, string> {
|
||||
export function ensureCleanProfileEnv(env: Record<string, string>): Record<string, string> {
|
||||
debugLog('[RateLimitDetector] ensureCleanProfileEnv() input:', {
|
||||
hasClaudeConfigDir: !!env.CLAUDE_CONFIG_DIR,
|
||||
claudeConfigDir: env.CLAUDE_CONFIG_DIR,
|
||||
hasOAuthToken: !!env.CLAUDE_CODE_OAUTH_TOKEN,
|
||||
willClearOAuthToken: !!env.CLAUDE_CONFIG_DIR,
|
||||
willClearApiKey: !!env.CLAUDE_CONFIG_DIR,
|
||||
});
|
||||
|
||||
// Warn if the profile environment is empty — this likely indicates a misconfigured profile
|
||||
if (Object.keys(env).length === 0) {
|
||||
console.warn('[RateLimitDetector] ensureCleanProfileEnv() received empty profile env — profile may be misconfigured');
|
||||
}
|
||||
|
||||
if (env.CLAUDE_CONFIG_DIR) {
|
||||
// Clear CLAUDE_CODE_OAUTH_TOKEN to ensure SDK uses credentials from CLAUDE_CONFIG_DIR
|
||||
return {
|
||||
// Clear CLAUDE_CODE_OAUTH_TOKEN and ANTHROPIC_API_KEY to ensure SDK uses credentials from CLAUDE_CONFIG_DIR
|
||||
// ANTHROPIC_API_KEY must also be cleared to prevent Claude Code from using
|
||||
// API keys that may be present in the shell environment instead of the config dir credentials.
|
||||
const cleanedEnv = {
|
||||
...env,
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ''
|
||||
CLAUDE_CODE_OAUTH_TOKEN: '',
|
||||
ANTHROPIC_API_KEY: ''
|
||||
};
|
||||
|
||||
debugLog('[RateLimitDetector] ensureCleanProfileEnv() output:', {
|
||||
claudeConfigDirPreserved: 'CLAUDE_CONFIG_DIR' in cleanedEnv,
|
||||
claudeConfigDir: (cleanedEnv as Record<string, string>).CLAUDE_CONFIG_DIR,
|
||||
oauthTokenCleared: cleanedEnv.CLAUDE_CODE_OAUTH_TOKEN === '',
|
||||
envKeys: Object.keys(cleanedEnv),
|
||||
});
|
||||
|
||||
return cleanedEnv;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
@@ -387,7 +387,7 @@ export interface PRReviewResult {
|
||||
success: boolean;
|
||||
findings: PRReviewFinding[];
|
||||
summary: string;
|
||||
overallStatus: 'approve' | 'request_changes' | 'comment';
|
||||
overallStatus: 'approve' | 'request_changes' | 'comment' | 'in_progress';
|
||||
reviewId?: number;
|
||||
reviewedAt: string;
|
||||
error?: string;
|
||||
@@ -403,6 +403,8 @@ export interface PRReviewResult {
|
||||
hasPostedFindings?: boolean;
|
||||
postedFindingIds?: string[];
|
||||
postedAt?: string;
|
||||
// In-progress review tracking
|
||||
inProgressSince?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { ipcRenderer } from 'electron';
|
||||
import { IPC_CHANNELS } from '../../../shared/constants/ipc';
|
||||
import type { IPCResult } from '@auto-claude/types';
|
||||
import type { IPCResult } from '../../../shared/types/common';
|
||||
import type { CustomMcpServer, McpHealthCheckResult, McpTestConnectionResult } from '../../../shared/types/project';
|
||||
|
||||
export interface McpAPI {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Sparkles,
|
||||
GitBranch,
|
||||
HelpCircle,
|
||||
Heart,
|
||||
Wrench,
|
||||
PanelLeft,
|
||||
PanelLeftClose
|
||||
@@ -452,6 +453,26 @@ export function Sidebar({
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Sponsor link */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => window.open('https://github.com/sponsors/AndyMik90', '_blank')}
|
||||
className={cn(
|
||||
'flex w-full items-center text-xs transition-colors',
|
||||
'text-amber-500/70 hover:text-amber-400',
|
||||
isCollapsed ? 'justify-center' : 'gap-1.5 px-3'
|
||||
)}
|
||||
>
|
||||
<Heart className="h-3.5 w-3.5" />
|
||||
{!isCollapsed && <span>{t('actions.sponsor')}</span>}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
{isCollapsed && (
|
||||
<TooltipContent side="right">{t('actions.sponsor')}</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
|
||||
{/* New Task button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -66,6 +66,7 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps)
|
||||
reviewProgress,
|
||||
startedAt,
|
||||
isReviewing,
|
||||
isExternalReview,
|
||||
previousReviewResult,
|
||||
hasMore,
|
||||
selectPR,
|
||||
@@ -269,6 +270,7 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps)
|
||||
reviewProgress={reviewProgress}
|
||||
startedAt={startedAt}
|
||||
isReviewing={isReviewing}
|
||||
isExternalReview={isExternalReview}
|
||||
initialNewCommitsCheck={storedNewCommitsCheck}
|
||||
isActive={isActive}
|
||||
isLoadingFiles={isLoadingPRDetails}
|
||||
|
||||
@@ -35,6 +35,7 @@ import { PRLogs } from './PRLogs';
|
||||
|
||||
import type { PRData, PRReviewResult, PRReviewProgress } from '../hooks/useGitHubPRs';
|
||||
import type { NewCommitsCheck, MergeReadiness, PRLogs as PRLogsType, WorkflowsAwaitingApprovalResult } from '../../../../preload/api/modules/github-api';
|
||||
import { usePRReviewStore } from '../../../stores/github';
|
||||
|
||||
interface PRDetailProps {
|
||||
pr: PRData;
|
||||
@@ -44,6 +45,7 @@ interface PRDetailProps {
|
||||
reviewProgress: PRReviewProgress | null;
|
||||
startedAt: string | null;
|
||||
isReviewing: boolean;
|
||||
isExternalReview?: boolean;
|
||||
initialNewCommitsCheck?: NewCommitsCheck | null;
|
||||
isActive?: boolean;
|
||||
isLoadingFiles?: boolean;
|
||||
@@ -78,6 +80,7 @@ export function PRDetail({
|
||||
reviewProgress,
|
||||
startedAt,
|
||||
isReviewing,
|
||||
isExternalReview = false,
|
||||
initialNewCommitsCheck,
|
||||
isActive: _isActive = false,
|
||||
isLoadingFiles = false,
|
||||
@@ -398,6 +401,59 @@ export function PRDetail({
|
||||
};
|
||||
}, [isReviewing, onGetLogs]);
|
||||
|
||||
/**
|
||||
* Completion detection for external (in-progress) reviews
|
||||
*
|
||||
* When the backend reports overallStatus === 'in_progress', the store sets
|
||||
* isExternalReview = true and isReviewing = true. This effect polls the
|
||||
* review result file every 3 seconds to detect when the external review
|
||||
* finishes. Once a completed result is found (overallStatus !== 'in_progress'),
|
||||
* we update the store which will set isReviewing = false and display the result.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isReviewing || !isExternalReview) return;
|
||||
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
const MAX_POLL_DURATION_MS = 30 * 60 * 1000; // 30 minutes
|
||||
const pollStart = Date.now();
|
||||
|
||||
const pollForCompletion = async () => {
|
||||
// Timeout: stop polling after 30 minutes to avoid indefinite polling
|
||||
if (Date.now() - pollStart > MAX_POLL_DURATION_MS) {
|
||||
usePRReviewStore.getState().setPRReviewResult(projectId, {
|
||||
prNumber: pr.number,
|
||||
repo: '',
|
||||
success: false,
|
||||
findings: [],
|
||||
summary: '',
|
||||
overallStatus: 'comment',
|
||||
reviewedAt: new Date().toISOString(),
|
||||
error: 'External review polling timed out after 30 minutes',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.github.getPRReview(projectId, pr.number);
|
||||
if (result && result.overallStatus !== 'in_progress') {
|
||||
// Only accept results that were produced AFTER we detected the external review.
|
||||
// Otherwise this is a stale result from a previous review still on disk
|
||||
// (in-progress results are intentionally NOT saved to disk).
|
||||
if (startedAt && result.reviewedAt && new Date(result.reviewedAt) > new Date(startedAt)) {
|
||||
usePRReviewStore.getState().setPRReviewResult(projectId, result);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors — transient file read failures shouldn't stop polling
|
||||
}
|
||||
};
|
||||
|
||||
// Poll immediately, then every 3 seconds
|
||||
pollForCompletion();
|
||||
const interval = setInterval(pollForCompletion, POLL_INTERVAL_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, [isReviewing, isExternalReview, projectId, pr.number, startedAt]);
|
||||
|
||||
/**
|
||||
* Fallback mechanism: Load logs after review completes if not already loaded
|
||||
*
|
||||
@@ -1067,6 +1123,7 @@ ${t('prReview.blockedStatusMessageFooter')}`;
|
||||
<ReviewStatusTree
|
||||
status={prStatus.status}
|
||||
isReviewing={isReviewing}
|
||||
isExternalReview={isExternalReview}
|
||||
startedAt={startedAt}
|
||||
reviewResult={reviewResult}
|
||||
previousReviewResult={previousReviewResult}
|
||||
|
||||
@@ -21,6 +21,7 @@ export type ReviewStatus =
|
||||
export interface ReviewStatusTreeProps {
|
||||
status: ReviewStatus;
|
||||
isReviewing: boolean;
|
||||
isExternalReview?: boolean;
|
||||
startedAt: string | null;
|
||||
reviewResult: PRReviewResult | null;
|
||||
previousReviewResult: PRReviewResult | null;
|
||||
@@ -39,6 +40,7 @@ export interface ReviewStatusTreeProps {
|
||||
export function ReviewStatusTree({
|
||||
status,
|
||||
isReviewing,
|
||||
isExternalReview = false,
|
||||
startedAt,
|
||||
reviewResult,
|
||||
previousReviewResult,
|
||||
@@ -137,7 +139,9 @@ export function ReviewStatusTree({
|
||||
if (isReviewing) {
|
||||
steps.push({
|
||||
id: 'analysis',
|
||||
label: t('prReview.analysisInProgress'),
|
||||
label: isExternalReview
|
||||
? t('prReview.reviewStartedExternally')
|
||||
: t('prReview.analysisInProgress'),
|
||||
status: 'current',
|
||||
date: null
|
||||
});
|
||||
@@ -255,7 +259,7 @@ export function ReviewStatusTree({
|
||||
|
||||
// Status label - explicitly handle all statuses
|
||||
const getStatusLabel = (): string => {
|
||||
if (isReviewing) return t('prReview.aiReviewInProgress');
|
||||
if (isReviewing) return isExternalReview ? t('prReview.externalReviewDetected') : t('prReview.aiReviewInProgress');
|
||||
switch (status) {
|
||||
case 'ready_to_merge':
|
||||
return t('prReview.readyToMerge');
|
||||
@@ -279,7 +283,7 @@ export function ReviewStatusTree({
|
||||
<CollapsibleCard
|
||||
title={statusLabel}
|
||||
icon={<div className={statusDotColor} />}
|
||||
headerAction={isReviewing ? (
|
||||
headerAction={isReviewing && !isExternalReview ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -32,6 +32,7 @@ interface UseGitHubPRsResult {
|
||||
reviewProgress: PRReviewProgress | null;
|
||||
startedAt: string | null;
|
||||
isReviewing: boolean;
|
||||
isExternalReview: boolean;
|
||||
previousReviewResult: PRReviewResult | null;
|
||||
isConnected: boolean;
|
||||
repoFullName: string | null;
|
||||
@@ -113,6 +114,7 @@ export function useGitHubPRs(
|
||||
const reviewResult = selectedPRReviewState?.result ?? null;
|
||||
const reviewProgress = selectedPRReviewState?.progress ?? null;
|
||||
const isReviewing = selectedPRReviewState?.isReviewing ?? false;
|
||||
const isExternalReview = selectedPRReviewState?.isExternalReview ?? false;
|
||||
const previousReviewResult = selectedPRReviewState?.previousResult ?? null;
|
||||
const startedAt = selectedPRReviewState?.startedAt ?? null;
|
||||
|
||||
@@ -727,6 +729,7 @@ export function useGitHubPRs(
|
||||
reviewProgress,
|
||||
startedAt,
|
||||
isReviewing,
|
||||
isExternalReview,
|
||||
previousReviewResult,
|
||||
isConnected,
|
||||
repoFullName,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { CheckCircle2, Circle, ExternalLink, Play, TrendingUp } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { CheckCircle2, ChevronDown, ChevronUp, Circle, ExternalLink, Play, TrendingUp } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TaskOutcomeBadge } from './TaskOutcomeBadge';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
@@ -7,6 +9,8 @@ import { Progress } from '../ui/progress';
|
||||
import { ROADMAP_PRIORITY_COLORS } from '../../../shared/constants';
|
||||
import type { PhaseCardProps } from './types';
|
||||
|
||||
const INITIAL_VISIBLE_COUNT = 5;
|
||||
|
||||
export function PhaseCard({
|
||||
phase,
|
||||
features,
|
||||
@@ -15,8 +19,13 @@ export function PhaseCard({
|
||||
onConvertToSpec,
|
||||
onGoToTask,
|
||||
}: PhaseCardProps) {
|
||||
const { t } = useTranslation('common');
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const completedCount = features.filter((f) => f.status === 'done').length;
|
||||
const progress = features.length > 0 ? (completedCount / features.length) * 100 : 0;
|
||||
const visibleFeatures = isExpanded ? features : features.slice(0, INITIAL_VISIBLE_COUNT);
|
||||
const hiddenCount = features.length - INITIAL_VISIBLE_COUNT;
|
||||
const hasMoreFeatures = hiddenCount > 0;
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
@@ -87,13 +96,16 @@ export function PhaseCard({
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Features ({features.length})</h4>
|
||||
<div className="grid gap-2">
|
||||
{features.slice(0, 5).map((feature) => (
|
||||
{visibleFeatures.map((feature) => (
|
||||
<div
|
||||
key={feature.id}
|
||||
className="flex items-center justify-between p-2 rounded-md bg-muted/50 hover:bg-muted cursor-pointer transition-colors"
|
||||
onClick={() => onFeatureSelect(feature)}
|
||||
className="flex items-center justify-between p-2 rounded-md bg-muted/50 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
onClick={() => onFeatureSelect(feature)}
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs ${ROADMAP_PRIORITY_COLORS[feature.priority]}`}
|
||||
@@ -104,7 +116,7 @@ export function PhaseCard({
|
||||
{feature.competitorInsightIds && feature.competitorInsightIds.length > 0 && (
|
||||
<TrendingUp className="h-3 w-3 text-primary flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{feature.taskOutcome ? (
|
||||
<span className="flex-shrink-0">
|
||||
<TaskOutcomeBadge outcome={feature.taskOutcome} size="lg" showLabel={false} />
|
||||
@@ -140,10 +152,26 @@ export function PhaseCard({
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{features.length > 5 && (
|
||||
<div className="text-sm text-muted-foreground text-center py-1">
|
||||
+{features.length - 5} more features
|
||||
</div>
|
||||
{hasMoreFeatures && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setIsExpanded((prev) => !prev)}
|
||||
aria-expanded={isExpanded}
|
||||
className="flex items-center justify-center gap-1 text-sm text-muted-foreground hover:text-foreground w-full"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
{t('roadmap.showLessFeatures')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
{t('roadmap.showMoreFeatures', { count: hiddenCount })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1 +1,149 @@
|
||||
export * from '@auto-claude/ui/primitives/alert-dialog';
|
||||
import * as React from 'react';
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { buttonVariants } from './button';
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root;
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 backdrop-blur-sm',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 w-full max-w-lg max-h-[90vh]',
|
||||
'translate-x-[-50%] translate-y-[-50%]',
|
||||
'bg-card border border-border rounded-2xl p-6',
|
||||
'shadow-xl',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]',
|
||||
'data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
|
||||
'duration-200',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col space-y-2 text-center sm:text-left',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogHeader.displayName = 'AlertDialogHeader';
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-3 mt-6',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogFooter.displayName = 'AlertDialogFooter';
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold text-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName;
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'mt-2 sm:mt-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
||||
|
||||
@@ -1 +1,37 @@
|
||||
export * from '@auto-claude/ui/primitives/badge';
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground',
|
||||
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
||||
destructive: 'border-transparent bg-destructive text-destructive-foreground',
|
||||
outline: 'text-foreground',
|
||||
success: 'border-transparent bg-success/10 text-success',
|
||||
warning: 'border-transparent bg-warning/10 text-warning',
|
||||
info: 'border-transparent bg-info/10 text-info',
|
||||
purple: 'border-transparent bg-purple-500/10 text-purple-400',
|
||||
muted: 'border-transparent bg-muted text-muted-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
|
||||
@@ -1 +1,64 @@
|
||||
export * from '@auto-claude/ui/primitives/button';
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.98]',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90 active:scale-[0.98]',
|
||||
outline:
|
||||
'border border-border bg-transparent hover:bg-accent hover:text-accent-foreground active:scale-[0.98]',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80 active:scale-[0.98]',
|
||||
ghost:
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
link:
|
||||
'text-primary underline-offset-4 hover:underline',
|
||||
success:
|
||||
'bg-[var(--success)] text-[var(--success-foreground)] hover:bg-[var(--success)]/90 active:scale-[0.98]',
|
||||
warning:
|
||||
'bg-warning text-warning-foreground hover:bg-warning/90 active:scale-[0.98]',
|
||||
info:
|
||||
'bg-info text-info-foreground hover:bg-info/90 active:scale-[0.98]',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2 text-sm rounded-lg',
|
||||
sm: 'h-8 px-3 text-xs rounded-md',
|
||||
lg: 'h-12 px-6 text-base rounded-lg',
|
||||
icon: 'h-10 w-10 rounded-lg',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants };
|
||||
|
||||
@@ -1 +1,58 @@
|
||||
export * from '@auto-claude/ui/primitives/card';
|
||||
import * as React from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-xl border border-border bg-card text-card-foreground transition-all duration-200',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn('text-xl font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
));
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
|
||||
@@ -1 +1,36 @@
|
||||
export * from '@auto-claude/ui/primitives/checkbox';
|
||||
import * as React from 'react';
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import { Check, Minus } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, checked, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
checked={checked}
|
||||
className={cn(
|
||||
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||
'data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn('flex items-center justify-center text-current')}
|
||||
>
|
||||
{checked === 'indeterminate' ? (
|
||||
<Minus className="h-3 w-3" />
|
||||
) : (
|
||||
<Check className="h-3 w-3" />
|
||||
)}
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox };
|
||||
|
||||
@@ -1 +1,9 @@
|
||||
export * from '@auto-claude/ui/primitives/collapsible';
|
||||
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root;
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
|
||||
@@ -1 +1,301 @@
|
||||
export * from '@auto-claude/ui/primitives/combobox';
|
||||
import * as React from 'react';
|
||||
import { Check, ChevronDown, Search } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
||||
import { ScrollArea } from './scroll-area';
|
||||
|
||||
export interface ComboboxOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
/** Optional group name for grouping options (e.g., "Local Branches", "Remote Branches") */
|
||||
group?: string;
|
||||
/** Optional icon to display before the label */
|
||||
icon?: React.ReactNode;
|
||||
/** Optional badge to display after the label */
|
||||
badge?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface ComboboxProps {
|
||||
/** Currently selected value */
|
||||
value: string;
|
||||
/** Callback when value changes */
|
||||
onValueChange: (value: string) => void;
|
||||
/** Available options */
|
||||
options: ComboboxOption[];
|
||||
/** Placeholder text for the trigger button */
|
||||
placeholder?: string;
|
||||
/** Placeholder text for the search input */
|
||||
searchPlaceholder?: string;
|
||||
/** Message shown when no results match the search */
|
||||
emptyMessage?: string;
|
||||
/** Whether the combobox is disabled */
|
||||
disabled?: boolean;
|
||||
/** Additional class names for the trigger */
|
||||
className?: string;
|
||||
/** ID for the trigger element */
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const Combobox = React.forwardRef<HTMLButtonElement, ComboboxProps>(
|
||||
(
|
||||
{
|
||||
value,
|
||||
onValueChange,
|
||||
options,
|
||||
placeholder = 'Select...',
|
||||
searchPlaceholder = 'Search...',
|
||||
emptyMessage = 'No results found',
|
||||
disabled = false,
|
||||
className,
|
||||
id,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [focusedIndex, setFocusedIndex] = React.useState(-1);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const optionRefs = React.useRef<Map<number, HTMLButtonElement>>(new Map());
|
||||
const listboxId = React.useId();
|
||||
|
||||
// Find the selected option's label
|
||||
const selectedOption = options.find((opt) => opt.value === value);
|
||||
const displayValue = selectedOption?.label || placeholder;
|
||||
|
||||
// Filter options based on search
|
||||
const filteredOptions = React.useMemo(() => {
|
||||
if (!search.trim()) return options;
|
||||
const searchLower = search.toLowerCase();
|
||||
return options.filter(
|
||||
(opt) =>
|
||||
opt.label.toLowerCase().includes(searchLower) ||
|
||||
opt.description?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}, [options, search]);
|
||||
|
||||
// Get option ID for aria-activedescendant
|
||||
const getOptionId = (index: number) => `${listboxId}-option-${index}`;
|
||||
|
||||
// Get the currently focused option ID
|
||||
const activeDescendant =
|
||||
focusedIndex >= 0 && focusedIndex < filteredOptions.length
|
||||
? getOptionId(focusedIndex)
|
||||
: undefined;
|
||||
|
||||
// Focus input when popover opens, reset focused index
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
// Small delay to ensure the popover is rendered
|
||||
const timer = setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 0);
|
||||
// Reset focused index when opening
|
||||
setFocusedIndex(-1);
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
// Clear search when closing
|
||||
setSearch('');
|
||||
setFocusedIndex(-1);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Reset focused index when filtered options change
|
||||
React.useEffect(() => {
|
||||
setFocusedIndex(-1);
|
||||
}, []);
|
||||
|
||||
// Scroll focused option into view
|
||||
React.useEffect(() => {
|
||||
if (focusedIndex >= 0) {
|
||||
const optionEl = optionRefs.current.get(focusedIndex);
|
||||
optionEl?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [focusedIndex]);
|
||||
|
||||
const handleSelect = (optionValue: string) => {
|
||||
onValueChange(optionValue);
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
setFocusedIndex(-1);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!open) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setFocusedIndex((prev) =>
|
||||
prev < filteredOptions.length - 1 ? prev + 1 : 0
|
||||
);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setFocusedIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : filteredOptions.length - 1
|
||||
);
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (focusedIndex >= 0 && focusedIndex < filteredOptions.length) {
|
||||
handleSelect(filteredOptions[focusedIndex].value);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
break;
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
if (filteredOptions.length > 0) {
|
||||
setFocusedIndex(0);
|
||||
}
|
||||
break;
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
if (filteredOptions.length > 0) {
|
||||
setFocusedIndex(filteredOptions.length - 1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild disabled={disabled}>
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
aria-controls={open ? listboxId : undefined}
|
||||
id={id}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-lg',
|
||||
'border border-border bg-card px-3 py-2 text-sm',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:border-primary',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'transition-colors duration-200',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className={cn('flex items-center gap-2 truncate', !selectedOption && 'text-muted-foreground')}>
|
||||
{selectedOption?.icon && (
|
||||
<span className="shrink-0 text-muted-foreground">{selectedOption.icon}</span>
|
||||
)}
|
||||
<span className="truncate">{displayValue}</span>
|
||||
{selectedOption?.badge && (
|
||||
<span className="shrink-0">{selectedOption.badge}</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center border-b border-border px-3">
|
||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
role="searchbox"
|
||||
aria-controls={listboxId}
|
||||
aria-activedescendant={activeDescendant}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
className={cn(
|
||||
'flex h-10 w-full bg-transparent py-3 px-2 text-sm',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:outline-none',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Options list */}
|
||||
<ScrollArea className="max-h-[300px]">
|
||||
<div id={listboxId} role="listbox" aria-label={searchPlaceholder || placeholder} className="p-1">
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
) : (
|
||||
filteredOptions.map((option, index) => {
|
||||
// Check if we need to render a group header
|
||||
const prevOption = index > 0 ? filteredOptions[index - 1] : null;
|
||||
const showGroupHeader = option.group && option.group !== prevOption?.group;
|
||||
|
||||
return (
|
||||
<React.Fragment key={option.value}>
|
||||
{/* Group header */}
|
||||
{showGroupHeader && (
|
||||
<div
|
||||
role="presentation"
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-xs font-semibold text-muted-foreground',
|
||||
index > 0 && 'mt-1 border-t border-border pt-2'
|
||||
)}
|
||||
>
|
||||
{option.group}
|
||||
</div>
|
||||
)}
|
||||
{/* Option item */}
|
||||
<button
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
optionRefs.current.set(index, el);
|
||||
} else {
|
||||
optionRefs.current.delete(index);
|
||||
}
|
||||
}}
|
||||
id={getOptionId(index)}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={value === option.value}
|
||||
onClick={() => handleSelect(option.value)}
|
||||
onMouseEnter={() => setFocusedIndex(index)}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default select-none items-center',
|
||||
'rounded-md py-2 pl-8 pr-2 text-sm outline-none',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
'transition-colors duration-150',
|
||||
focusedIndex === index && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
{value === option.value && <Check className="h-4 w-4 text-primary" />}
|
||||
</span>
|
||||
<span className="flex flex-1 items-center gap-2 truncate">
|
||||
{option.icon && (
|
||||
<span className="shrink-0 text-muted-foreground">{option.icon}</span>
|
||||
)}
|
||||
<span className="truncate">{option.label}</span>
|
||||
{option.badge && (
|
||||
<span className="shrink-0">{option.badge}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Combobox.displayName = 'Combobox';
|
||||
|
||||
export { Combobox };
|
||||
|
||||
@@ -1 +1,126 @@
|
||||
export * from '@auto-claude/ui/primitives/dialog';
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 backdrop-blur-sm',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
interface DialogContentProps extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
|
||||
hideCloseButton?: boolean;
|
||||
}
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
DialogContentProps
|
||||
>(({ className, children, hideCloseButton, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 w-full max-w-lg max-h-[90vh]',
|
||||
'translate-x-[-50%] translate-y-[-50%]',
|
||||
'bg-card border border-border rounded-2xl p-6',
|
||||
'shadow-xl',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]',
|
||||
'data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
|
||||
'duration-200 overflow-hidden flex flex-col',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{!hideCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
className={cn(
|
||||
'absolute right-4 top-4 rounded-lg p-1 z-10',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:bg-accent transition-colors',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background',
|
||||
'disabled:pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
|
||||
);
|
||||
DialogHeader.displayName = 'DialogHeader';
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-3 mt-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = 'DialogFooter';
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight text-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
|
||||
@@ -1 +1,201 @@
|
||||
export * from '@auto-claude/ui/primitives/dropdown-menu';
|
||||
import * as React from 'react';
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, collisionPadding = 8, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
collisionPadding={collisionPadding}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
|
||||
'max-h-[var(--radix-dropdown-menu-content-available-height,400px)]',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-sm font-semibold',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
|
||||
@@ -1,22 +1,80 @@
|
||||
import type React from 'react';
|
||||
import { ErrorBoundary as BaseErrorBoundary } from '@auto-claude/ui/primitives/error-boundary';
|
||||
import React from 'react';
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
import { Button } from './button';
|
||||
import { Card, CardContent } from './card';
|
||||
import { captureException } from '../../lib/sentry';
|
||||
|
||||
type BaseErrorBoundaryProps = React.ComponentProps<typeof BaseErrorBoundary>;
|
||||
interface ErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
fallback?: React.ReactNode;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* App-level ErrorBoundary that automatically reports errors to Sentry.
|
||||
* Wraps the shared @auto-claude/ui ErrorBoundary with Sentry integration.
|
||||
* Error boundary component to gracefully handle render errors.
|
||||
* Prevents the entire page from crashing when a component fails.
|
||||
*/
|
||||
export function ErrorBoundary(props: BaseErrorBoundaryProps) {
|
||||
const handleError = (error: Error, errorInfo: React.ErrorInfo): void => {
|
||||
captureException(error, { componentStack: errorInfo.componentStack });
|
||||
props.onError?.(error, errorInfo);
|
||||
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
|
||||
// Report to Sentry with React component stack
|
||||
captureException(error, {
|
||||
componentStack: errorInfo.componentStack,
|
||||
});
|
||||
}
|
||||
|
||||
handleReset = (): void => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
this.props.onReset?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseErrorBoundary {...props} onError={handleError}>
|
||||
{props.children}
|
||||
</BaseErrorBoundary>
|
||||
);
|
||||
render(): React.ReactNode {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-destructive m-4">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<AlertTriangle className="h-10 w-10 text-destructive" />
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-lg">Something went wrong</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
An error occurred while rendering this content.
|
||||
</p>
|
||||
{this.state.error && (
|
||||
<p className="text-xs text-muted-foreground font-mono bg-muted p-2 rounded max-w-md overflow-auto">
|
||||
{this.state.error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={this.handleReset} variant="outline" size="sm">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,144 @@
|
||||
export * from '@auto-claude/ui/primitives/full-screen-dialog';
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const FullScreenDialog = DialogPrimitive.Root;
|
||||
|
||||
const FullScreenDialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const FullScreenDialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const FullScreenDialogClose = DialogPrimitive.Close;
|
||||
|
||||
const FullScreenDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-background/95 backdrop-blur-sm',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
FullScreenDialogOverlay.displayName = 'FullScreenDialogOverlay';
|
||||
|
||||
const FullScreenDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<FullScreenDialogPortal>
|
||||
<FullScreenDialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-4 z-50 flex flex-col',
|
||||
'bg-card border border-border rounded-2xl',
|
||||
'shadow-2xl overflow-hidden',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-98 data-[state=open]:zoom-in-98',
|
||||
'duration-200',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close
|
||||
className={cn(
|
||||
'absolute right-4 top-4 rounded-lg p-2',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'hover:bg-accent transition-colors',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background',
|
||||
'disabled:pointer-events-none z-10'
|
||||
)}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</FullScreenDialogPortal>
|
||||
));
|
||||
FullScreenDialogContent.displayName = 'FullScreenDialogContent';
|
||||
|
||||
const FullScreenDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col space-y-1.5 p-6 pb-4 border-b border-border',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
FullScreenDialogHeader.displayName = 'FullScreenDialogHeader';
|
||||
|
||||
const FullScreenDialogBody = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex-1 overflow-hidden', className)} {...props} />
|
||||
);
|
||||
FullScreenDialogBody.displayName = 'FullScreenDialogBody';
|
||||
|
||||
const FullScreenDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-end gap-3 p-6 pt-4 border-t border-border',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
FullScreenDialogFooter.displayName = 'FullScreenDialogFooter';
|
||||
|
||||
const FullScreenDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-xl font-semibold leading-none tracking-tight text-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
FullScreenDialogTitle.displayName = 'FullScreenDialogTitle';
|
||||
|
||||
const FullScreenDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
FullScreenDialogDescription.displayName = 'FullScreenDialogDescription';
|
||||
|
||||
export {
|
||||
FullScreenDialog,
|
||||
FullScreenDialogPortal,
|
||||
FullScreenDialogOverlay,
|
||||
FullScreenDialogClose,
|
||||
FullScreenDialogTrigger,
|
||||
FullScreenDialogContent,
|
||||
FullScreenDialogHeader,
|
||||
FullScreenDialogBody,
|
||||
FullScreenDialogFooter,
|
||||
FullScreenDialogTitle,
|
||||
FullScreenDialogDescription,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
// Re-export all UI primitives from shared @auto-claude/ui package
|
||||
export * from '@auto-claude/ui/primitives';
|
||||
|
||||
// Override base ErrorBoundary with Sentry-integrated version
|
||||
export { ErrorBoundary } from './error-boundary';
|
||||
// Re-export all UI components
|
||||
export * from './badge';
|
||||
export * from './button';
|
||||
export * from './card';
|
||||
export * from './combobox';
|
||||
export * from './dialog';
|
||||
export * from './input';
|
||||
export * from './label';
|
||||
export * from './progress';
|
||||
export * from './scroll-area';
|
||||
export * from './select';
|
||||
export * from './separator';
|
||||
export * from './switch';
|
||||
export * from './tabs';
|
||||
export * from './textarea';
|
||||
export * from './tooltip';
|
||||
|
||||
@@ -1 +1,33 @@
|
||||
export * from '@auto-claude/ui/primitives/input';
|
||||
import * as React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, spellCheck, lang, ...props }, ref) => {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
spellCheck={spellCheck ?? true}
|
||||
lang={lang ?? i18n.language}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-primary',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'transition-colors duration-200',
|
||||
'file:border-0 file:bg-transparent file:text-sm file:font-medium',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
|
||||
@@ -1 +1,20 @@
|
||||
export * from '@auto-claude/ui/primitives/label';
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const labelVariants = cva(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||
);
|
||||
|
||||
export interface LabelProps
|
||||
extends React.LabelHTMLAttributes<HTMLLabelElement>,
|
||||
VariantProps<typeof labelVariants> {}
|
||||
|
||||
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<label ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
)
|
||||
);
|
||||
Label.displayName = 'Label';
|
||||
|
||||
export { Label };
|
||||
|
||||
@@ -1 +1,36 @@
|
||||
export * from '@auto-claude/ui/primitives/popover';
|
||||
import * as React from 'react';
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor;
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
|
||||
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
|
||||
@@ -1 +1,32 @@
|
||||
export * from '@auto-claude/ui/primitives/progress';
|
||||
import * as React from 'react';
|
||||
import * as ProgressPrimitive from '@radix-ui/react-progress';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface ProgressProps extends React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> {
|
||||
animated?: boolean;
|
||||
}
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
ProgressProps
|
||||
>(({ className, value, animated, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative h-2 w-full overflow-hidden rounded-full bg-border',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className={cn(
|
||||
'h-full w-full flex-1 bg-primary transition-all duration-300 ease-out',
|
||||
animated && 'progress-working'
|
||||
)}
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
));
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||
|
||||
export { Progress };
|
||||
|
||||
@@ -1 +1,43 @@
|
||||
export * from '@auto-claude/ui/primitives/radio-group';
|
||||
import * as React from 'react';
|
||||
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
|
||||
import { Circle } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn('grid gap-2', className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
});
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
|
||||
|
||||
export { RadioGroup, RadioGroupItem };
|
||||
|
||||
@@ -1 +1,165 @@
|
||||
export * from '@auto-claude/ui/primitives/resizable-panels';
|
||||
/**
|
||||
* ResizablePanels - A split panel layout with a draggable divider
|
||||
*
|
||||
* Features:
|
||||
* - Smooth drag-to-resize functionality
|
||||
* - Min/max width constraints
|
||||
* - Persists width to localStorage
|
||||
* - Visual feedback on hover and drag
|
||||
* - Touch support for mobile devices
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect, useCallback, type ReactNode } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface ResizablePanelsProps {
|
||||
leftPanel: ReactNode;
|
||||
rightPanel: ReactNode;
|
||||
defaultLeftWidth?: number; // percentage, default 50
|
||||
minLeftWidth?: number; // percentage, default 30
|
||||
maxLeftWidth?: number; // percentage, default 70
|
||||
storageKey?: string; // localStorage key for persistence
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ResizablePanels({
|
||||
leftPanel,
|
||||
rightPanel,
|
||||
defaultLeftWidth = 50,
|
||||
minLeftWidth = 30,
|
||||
maxLeftWidth = 70,
|
||||
storageKey,
|
||||
className,
|
||||
}: ResizablePanelsProps) {
|
||||
// Load initial width from storage or use default
|
||||
const [leftWidth, setLeftWidth] = useState(() => {
|
||||
if (storageKey) {
|
||||
try {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
if (stored) {
|
||||
const parsed = parseFloat(stored);
|
||||
if (!Number.isNaN(parsed) && parsed >= minLeftWidth && parsed <= maxLeftWidth) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// localStorage may be unavailable (e.g., private browsing)
|
||||
}
|
||||
}
|
||||
return defaultLeftWidth;
|
||||
});
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Save to storage when width changes (debounced by only saving when not dragging)
|
||||
useEffect(() => {
|
||||
if (storageKey && !isDragging) {
|
||||
try {
|
||||
localStorage.setItem(storageKey, leftWidth.toString());
|
||||
} catch {
|
||||
// localStorage may be unavailable (e.g., private browsing, quota exceeded)
|
||||
}
|
||||
}
|
||||
}, [leftWidth, storageKey, isDragging]);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
// Guard against division by zero when container has no width
|
||||
if (rect.width <= 0) return;
|
||||
const newWidth = ((e.clientX - rect.left) / rect.width) * 100;
|
||||
const clampedWidth = Math.max(minLeftWidth, Math.min(maxLeftWidth, newWidth));
|
||||
setLeftWidth(clampedWidth);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (!containerRef.current || e.touches.length === 0) return;
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
if (rect.width <= 0) return;
|
||||
const touch = e.touches[0];
|
||||
const newWidth = ((touch.clientX - rect.left) / rect.width) * 100;
|
||||
const clampedWidth = Math.max(minLeftWidth, Math.min(maxLeftWidth, newWidth));
|
||||
setLeftWidth(clampedWidth);
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
// Add user-select: none to body during drag to prevent text selection
|
||||
document.body.style.userSelect = 'none';
|
||||
document.body.style.cursor = 'col-resize';
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', handleTouchEnd);
|
||||
|
||||
return () => {
|
||||
document.body.style.userSelect = '';
|
||||
document.body.style.cursor = '';
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
};
|
||||
}, [isDragging, minLeftWidth, maxLeftWidth]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn("flex-1 flex min-h-0", className)}
|
||||
>
|
||||
{/* Left panel */}
|
||||
<div
|
||||
className="flex flex-col min-w-0 overflow-hidden"
|
||||
style={{ width: `${leftWidth}%` }}
|
||||
>
|
||||
{leftPanel}
|
||||
</div>
|
||||
|
||||
{/* Resizable divider */}
|
||||
<div
|
||||
className={cn(
|
||||
"w-1 flex-shrink-0 relative cursor-col-resize touch-none",
|
||||
"bg-border transition-colors duration-150",
|
||||
"hover:bg-primary/40",
|
||||
isDragging && "bg-primary/60"
|
||||
)}
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleTouchStart}
|
||||
>
|
||||
{/* Wider invisible hit area for easier grabbing */}
|
||||
<div className="absolute inset-y-0 -left-1 -right-1 z-10" />
|
||||
</div>
|
||||
|
||||
{/* Right panel */}
|
||||
<div
|
||||
className="flex flex-col min-w-0 overflow-hidden"
|
||||
style={{ width: `${100 - leftWidth}%` }}
|
||||
>
|
||||
{rightPanel}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +1,58 @@
|
||||
export * from '@auto-claude/ui/primitives/scroll-area';
|
||||
import * as React from 'react';
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & {
|
||||
viewportClassName?: string;
|
||||
onViewportRef?: (element: HTMLDivElement | null) => void;
|
||||
}
|
||||
>(({ className, children, viewportClassName, onViewportRef, ...props }, ref) => {
|
||||
const viewportRef = React.useCallback(
|
||||
(element: HTMLDivElement | null) => {
|
||||
onViewportRef?.(element);
|
||||
},
|
||||
[onViewportRef]
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative overflow-hidden', className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
ref={viewportRef}
|
||||
className={cn('h-full w-full rounded-[inherit]', viewportClassName)}
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
);
|
||||
});
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = 'vertical', ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none select-none transition-colors',
|
||||
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
|
||||
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
));
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
|
||||
@@ -1 +1,166 @@
|
||||
export * from '@auto-claude/ui/primitives/select';
|
||||
import * as React from 'react';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-lg',
|
||||
'border border-border bg-card px-3 py-2 text-sm',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:border-primary',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'transition-colors duration-200',
|
||||
'[&>span]:line-clamp-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden',
|
||||
'rounded-lg border border-border bg-card text-foreground shadow-lg',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
|
||||
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1 max-h-[300px] overflow-y-auto',
|
||||
position === 'popper' &&
|
||||
'w-full min-w-(--radix-select-trigger-width)'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default select-none items-center',
|
||||
'rounded-md py-2 pl-8 pr-2 text-sm outline-none',
|
||||
'focus:bg-accent focus:text-accent-foreground',
|
||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'transition-colors duration-150',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4 text-primary" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
|
||||
@@ -1 +1,23 @@
|
||||
export * from '@auto-claude/ui/primitives/separator';
|
||||
import * as React from 'react';
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-border',
|
||||
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
|
||||
@@ -1 +1,33 @@
|
||||
export * from '@auto-claude/ui/primitives/switch';
|
||||
import * as React from 'react';
|
||||
import * as SwitchPrimitives from '@radix-ui/react-switch';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full',
|
||||
'border-2 border-transparent transition-all duration-200',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'data-[state=checked]:bg-primary data-[state=unchecked]:bg-border',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-5 w-5 rounded-full shadow-sm ring-0 transition-transform duration-200',
|
||||
'bg-white dark:bg-foreground',
|
||||
'data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0',
|
||||
'data-[state=checked]:bg-primary-foreground'
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
|
||||
@@ -1 +1,57 @@
|
||||
export * from '@auto-claude/ui/primitives/tabs';
|
||||
import * as React from 'react';
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-10 items-center justify-center rounded-lg bg-secondary p-1 text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium',
|
||||
'transition-all duration-200',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
'data-[state=active]:bg-card data-[state=active]:text-foreground',
|
||||
'data-[state=inactive]:hover:text-foreground/80',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
|
||||
@@ -1 +1,32 @@
|
||||
export * from '@auto-claude/ui/primitives/textarea';
|
||||
import * as React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, spellCheck, lang, ...props }, ref) => {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
return (
|
||||
<textarea
|
||||
spellCheck={spellCheck ?? true}
|
||||
lang={lang ?? i18n.language}
|
||||
className={cn(
|
||||
'flex min-h-[80px] w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-primary',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'transition-colors duration-200',
|
||||
'resize-none',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Textarea.displayName = 'Textarea';
|
||||
|
||||
export { Textarea };
|
||||
|
||||
@@ -1 +1,130 @@
|
||||
export * from '@auto-claude/ui/primitives/toast';
|
||||
/**
|
||||
* Toast UI Components
|
||||
*
|
||||
* Based on Radix UI Toast for non-intrusive notifications.
|
||||
*/
|
||||
import * as React from 'react';
|
||||
import * as ToastPrimitives from '@radix-ui/react-toast';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider;
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||
|
||||
const toastVariants = cva(
|
||||
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border bg-card text-foreground',
|
||||
destructive: 'destructive group border-destructive bg-destructive text-destructive-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
));
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn('text-sm font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm opacity-90', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>;
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
};
|
||||
|
||||
@@ -1 +1,37 @@
|
||||
export * from '@auto-claude/ui/primitives/toaster';
|
||||
/**
|
||||
* Toaster Component
|
||||
*
|
||||
* Renders the toast viewport where toasts are displayed.
|
||||
* Should be included once in the app root.
|
||||
*/
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from './toast';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast();
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(({ id, title, description, action, ...props }) => (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
))}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +1,31 @@
|
||||
export * from '@auto-claude/ui/primitives/tooltip';
|
||||
import * as React from 'react';
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-lg border border-border bg-card px-3 py-1.5 text-sm text-foreground shadow-lg',
|
||||
'animate-in fade-in-0 zoom-in-95',
|
||||
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
|
||||
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
|
||||
@@ -1 +1,192 @@
|
||||
export { useToast, toast, reducer } from '@auto-claude/ui/primitives/use-toast';
|
||||
/**
|
||||
* Toast Hook
|
||||
*
|
||||
* Manages toast state for displaying notifications.
|
||||
*/
|
||||
import * as React from 'react';
|
||||
|
||||
import type { ToastActionElement, ToastProps } from '../components/ui/toast';
|
||||
|
||||
const TOAST_LIMIT = 1;
|
||||
const TOAST_REMOVE_DELAY = 1000000;
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string;
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
action?: ToastActionElement;
|
||||
};
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: 'ADD_TOAST',
|
||||
UPDATE_TOAST: 'UPDATE_TOAST',
|
||||
DISMISS_TOAST: 'DISMISS_TOAST',
|
||||
REMOVE_TOAST: 'REMOVE_TOAST',
|
||||
} as const;
|
||||
|
||||
let count = 0;
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes;
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType['ADD_TOAST'];
|
||||
toast: ToasterToast;
|
||||
}
|
||||
| {
|
||||
type: ActionType['UPDATE_TOAST'];
|
||||
toast: Partial<ToasterToast>;
|
||||
}
|
||||
| {
|
||||
type: ActionType['DISMISS_TOAST'];
|
||||
toastId?: ToasterToast['id'];
|
||||
}
|
||||
| {
|
||||
type: ActionType['REMOVE_TOAST'];
|
||||
toastId?: ToasterToast['id'];
|
||||
};
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[];
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId);
|
||||
dispatch({
|
||||
type: 'REMOVE_TOAST',
|
||||
toastId: toastId,
|
||||
});
|
||||
}, TOAST_REMOVE_DELAY);
|
||||
|
||||
toastTimeouts.set(toastId, timeout);
|
||||
};
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case 'ADD_TOAST':
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
};
|
||||
|
||||
case 'UPDATE_TOAST':
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
};
|
||||
|
||||
case 'DISMISS_TOAST': {
|
||||
const { toastId } = action;
|
||||
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId);
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
};
|
||||
}
|
||||
case 'REMOVE_TOAST':
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const listeners: Array<(state: State) => void> = [];
|
||||
|
||||
let memoryState: State = { toasts: [] };
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action);
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState);
|
||||
});
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, 'id'>;
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId();
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: 'UPDATE_TOAST',
|
||||
toast: { ...props, id },
|
||||
});
|
||||
|
||||
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
|
||||
|
||||
dispatch({
|
||||
type: 'ADD_TOAST',
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
};
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState);
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState);
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState);
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
|
||||
};
|
||||
}
|
||||
|
||||
export { useToast, toast };
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
// Re-export cn() from the shared UI package
|
||||
export { cn } from '@auto-claude/ui';
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
/**
|
||||
* Utility function to merge Tailwind CSS classes
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate progress percentage from subtasks
|
||||
|
||||
@@ -35,6 +35,8 @@ interface PRReviewState {
|
||||
mergeableState: MergeableState | null;
|
||||
/** Timestamp of last status poll (ISO 8601 string) */
|
||||
lastPolled: string | null;
|
||||
/** Whether this review was initiated externally (e.g., from PR list) rather than from detail view */
|
||||
isExternalReview: boolean;
|
||||
}
|
||||
|
||||
interface PRReviewStoreState {
|
||||
@@ -59,6 +61,8 @@ interface PRReviewStoreState {
|
||||
}) => void;
|
||||
/** Clear PR status fields for a specific PR */
|
||||
clearPRStatus: (projectId: string, prNumber: number) => void;
|
||||
/** Start an external review (from PR list) - sets isReviewing and isExternalReview */
|
||||
setExternalReviewInProgress: (projectId: string, prNumber: number, inProgressSince?: string) => void;
|
||||
|
||||
// Selectors
|
||||
getPRReviewState: (projectId: string, prNumber: number) => PRReviewState | null;
|
||||
@@ -96,7 +100,8 @@ export const usePRReviewStore = create<PRReviewStoreState>((set, get) => ({
|
||||
checksStatus: existing?.checksStatus ?? null,
|
||||
reviewsStatus: existing?.reviewsStatus ?? null,
|
||||
mergeableState: existing?.mergeableState ?? null,
|
||||
lastPolled: existing?.lastPolled ?? null
|
||||
lastPolled: existing?.lastPolled ?? null,
|
||||
isExternalReview: false
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -130,7 +135,8 @@ export const usePRReviewStore = create<PRReviewStoreState>((set, get) => ({
|
||||
checksStatus: existing?.checksStatus ?? null,
|
||||
reviewsStatus: existing?.reviewsStatus ?? null,
|
||||
mergeableState: existing?.mergeableState ?? null,
|
||||
lastPolled: existing?.lastPolled ?? null
|
||||
lastPolled: existing?.lastPolled ?? null,
|
||||
isExternalReview: false
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -155,7 +161,8 @@ export const usePRReviewStore = create<PRReviewStoreState>((set, get) => ({
|
||||
checksStatus: existing?.checksStatus ?? null,
|
||||
reviewsStatus: existing?.reviewsStatus ?? null,
|
||||
mergeableState: existing?.mergeableState ?? null,
|
||||
lastPolled: existing?.lastPolled ?? null
|
||||
lastPolled: existing?.lastPolled ?? null,
|
||||
isExternalReview: existing?.isExternalReview ?? false
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -182,7 +189,8 @@ export const usePRReviewStore = create<PRReviewStoreState>((set, get) => ({
|
||||
checksStatus: existing?.checksStatus ?? null,
|
||||
reviewsStatus: existing?.reviewsStatus ?? null,
|
||||
mergeableState: existing?.mergeableState ?? null,
|
||||
lastPolled: existing?.lastPolled ?? null
|
||||
lastPolled: existing?.lastPolled ?? null,
|
||||
isExternalReview: existing?.isExternalReview ?? false
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -207,7 +215,8 @@ export const usePRReviewStore = create<PRReviewStoreState>((set, get) => ({
|
||||
checksStatus: existing?.checksStatus ?? null,
|
||||
reviewsStatus: existing?.reviewsStatus ?? null,
|
||||
mergeableState: existing?.mergeableState ?? null,
|
||||
lastPolled: existing?.lastPolled ?? null
|
||||
lastPolled: existing?.lastPolled ?? null,
|
||||
isExternalReview: existing?.isExternalReview ?? false
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -234,7 +243,8 @@ export const usePRReviewStore = create<PRReviewStoreState>((set, get) => ({
|
||||
checksStatus: null,
|
||||
reviewsStatus: null,
|
||||
mergeableState: null,
|
||||
lastPolled: null
|
||||
lastPolled: null,
|
||||
isExternalReview: false
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -282,7 +292,8 @@ export const usePRReviewStore = create<PRReviewStoreState>((set, get) => ({
|
||||
checksStatus: status.checksStatus,
|
||||
reviewsStatus: status.reviewsStatus,
|
||||
mergeableState: status.mergeableState,
|
||||
lastPolled: status.lastPolled
|
||||
lastPolled: status.lastPolled,
|
||||
isExternalReview: false
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -321,6 +332,32 @@ export const usePRReviewStore = create<PRReviewStoreState>((set, get) => ({
|
||||
};
|
||||
}),
|
||||
|
||||
setExternalReviewInProgress: (projectId: string, prNumber: number, inProgressSince?: string) => set((state) => {
|
||||
const key = `${projectId}:${prNumber}`;
|
||||
const existing = state.prReviews[key];
|
||||
return {
|
||||
prReviews: {
|
||||
...state.prReviews,
|
||||
[key]: {
|
||||
prNumber,
|
||||
projectId,
|
||||
isReviewing: true,
|
||||
startedAt: inProgressSince || new Date().toISOString(),
|
||||
progress: null,
|
||||
result: existing?.result ?? null,
|
||||
previousResult: existing?.previousResult ?? null,
|
||||
error: null,
|
||||
newCommitsCheck: existing?.newCommitsCheck ?? null,
|
||||
checksStatus: existing?.checksStatus ?? null,
|
||||
reviewsStatus: existing?.reviewsStatus ?? null,
|
||||
mergeableState: existing?.mergeableState ?? null,
|
||||
lastPolled: existing?.lastPolled ?? null,
|
||||
isExternalReview: true
|
||||
}
|
||||
}
|
||||
};
|
||||
}),
|
||||
|
||||
// Selectors
|
||||
getPRReviewState: (projectId: string, prNumber: number) => {
|
||||
const { prReviews } = get();
|
||||
@@ -378,6 +415,15 @@ export function initializePRReviewListeners(): void {
|
||||
// Listen for PR review completion events
|
||||
const cleanupComplete = window.electronAPI.github.onPRReviewComplete(
|
||||
(projectId: string, result: PRReviewResult) => {
|
||||
// When the backend detects an already-running review (e.g., started from another
|
||||
// client or the PR list), it returns overallStatus === 'in_progress' instead of
|
||||
// a real result. Transition to external-review-in-progress so the log polling
|
||||
// activates and the UI shows the ongoing review.
|
||||
if (result.overallStatus === 'in_progress') {
|
||||
store.setExternalReviewInProgress(projectId, result.prNumber, result.inProgressSince);
|
||||
return;
|
||||
}
|
||||
|
||||
store.setPRReviewResult(projectId, result);
|
||||
// Trigger all registered refresh callbacks when review completes
|
||||
refreshCallbacks.forEach(callback => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -292,6 +292,8 @@
|
||||
"newIssue": "{{count}} new issue",
|
||||
"newIssue_plural": "{{count}} new issues",
|
||||
"reviewFailed": "Review Failed",
|
||||
"externalReviewDetected": "External Review Detected",
|
||||
"reviewStartedExternally": "This review was started from another session",
|
||||
"description": "Description",
|
||||
"noDescription": "No description provided.",
|
||||
"followupReviewDetails": "Follow-up Review Details",
|
||||
@@ -597,7 +599,10 @@
|
||||
"roadmap": {
|
||||
"taskCompleted": "Completed",
|
||||
"taskDeleted": "Deleted",
|
||||
"taskArchived": "Archived"
|
||||
"taskArchived": "Archived",
|
||||
"showMoreFeatures": "Show {{count}} more feature",
|
||||
"showMoreFeatures_plural": "Show {{count}} more features",
|
||||
"showLessFeatures": "Show less"
|
||||
},
|
||||
"roadmapGeneration": {
|
||||
"progress": "Progress",
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
"help": "Help & Feedback",
|
||||
"newTask": "New Task",
|
||||
"collapseSidebar": "Collapse Sidebar",
|
||||
"expandSidebar": "Expand Sidebar"
|
||||
"expandSidebar": "Expand Sidebar",
|
||||
"sponsor": "Sponsor Us"
|
||||
},
|
||||
"tooltips": {
|
||||
"settings": "Application Settings",
|
||||
|
||||
@@ -292,6 +292,8 @@
|
||||
"newIssue": "{{count}} nouveau problème",
|
||||
"newIssue_plural": "{{count}} nouveaux problèmes",
|
||||
"reviewFailed": "Révision échouée",
|
||||
"externalReviewDetected": "Révision externe détectée",
|
||||
"reviewStartedExternally": "Cette révision a été lancée depuis une autre session",
|
||||
"description": "Description",
|
||||
"noDescription": "Aucune description fournie.",
|
||||
"followupReviewDetails": "Détails de la révision de suivi",
|
||||
@@ -597,7 +599,10 @@
|
||||
"roadmap": {
|
||||
"taskCompleted": "Terminé",
|
||||
"taskDeleted": "Supprimé",
|
||||
"taskArchived": "Archivé"
|
||||
"taskArchived": "Archivé",
|
||||
"showMoreFeatures": "Afficher {{count}} fonctionnalité supplémentaire",
|
||||
"showMoreFeatures_plural": "Afficher {{count}} fonctionnalités supplémentaires",
|
||||
"showLessFeatures": "Afficher moins"
|
||||
},
|
||||
"roadmapGeneration": {
|
||||
"progress": "Progression",
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
"help": "Aide & Feedback",
|
||||
"newTask": "Nouvelle tâche",
|
||||
"collapseSidebar": "Réduire la barre latérale",
|
||||
"expandSidebar": "Développer la barre latérale"
|
||||
"expandSidebar": "Développer la barre latérale",
|
||||
"sponsor": "Nous sponsoriser"
|
||||
},
|
||||
"tooltips": {
|
||||
"settings": "Paramètres de l'application",
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
/**
|
||||
* Re-exports common types from @auto-claude/types.
|
||||
* Kept for backwards compatibility — prefer importing directly from '@auto-claude/types'.
|
||||
* Common utility types shared across the application
|
||||
*/
|
||||
export type { IPCResult } from '@auto-claude/types';
|
||||
|
||||
// IPC Types
|
||||
export interface IPCResult<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
/**
|
||||
* Central export point for all shared types
|
||||
*
|
||||
* Re-exports shared platform types from @auto-claude/types,
|
||||
* plus Electron-specific type definitions.
|
||||
*/
|
||||
|
||||
// Shared platform types
|
||||
export * from '@auto-claude/types';
|
||||
// Common types
|
||||
export * from './common';
|
||||
|
||||
// Electron-specific types
|
||||
// Domain-specific types
|
||||
export * from './project';
|
||||
export * from './task';
|
||||
export * from './kanban';
|
||||
export * from './terminal';
|
||||
export * from './terminal-session';
|
||||
export * from './screenshot';
|
||||
export * from './app-update';
|
||||
export * from './cli';
|
||||
export * from './agent';
|
||||
export * from './profile';
|
||||
export * from './unified-account';
|
||||
export * from './settings';
|
||||
export * from './changelog';
|
||||
export * from './insights';
|
||||
export * from './roadmap';
|
||||
export * from './integrations';
|
||||
export * from './app-update';
|
||||
export * from './cli';
|
||||
export * from './pr-status';
|
||||
|
||||
// IPC types (must be last to use types from other modules)
|
||||
export * from './ipc';
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
* IPC (Inter-Process Communication) types for Electron API
|
||||
*/
|
||||
|
||||
import type { IPCResult, KanbanPreferences, SupportedIDE, SupportedTerminal } from '@auto-claude/types';
|
||||
import type { IPCResult } from './common';
|
||||
import type { KanbanPreferences } from './kanban';
|
||||
import type { SupportedIDE, SupportedTerminal } from './settings';
|
||||
import type {
|
||||
Project,
|
||||
ProjectSettings,
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { ClaudeUsageData, ClaudeRateLimitEvent } from './agent';
|
||||
* Users can configure custom Anthropic-compatible API endpoints with profiles.
|
||||
* Each profile contains name, base URL, API key, and optional model mappings.
|
||||
*
|
||||
* NOTE: These types are intentionally duplicated from packages/profile-service/src/types/profile.ts
|
||||
* NOTE: These types are intentionally duplicated from libs/profile-service/src/types/profile.ts
|
||||
* because the frontend build (Electron + Vite) doesn't consume the workspace library types directly.
|
||||
* Keep these definitions in sync with the library types when making changes.
|
||||
*/
|
||||
|
||||
@@ -20,13 +20,9 @@
|
||||
"@features/*": ["src/renderer/features/*"],
|
||||
"@components/*": ["src/renderer/shared/components/*"],
|
||||
"@hooks/*": ["src/renderer/shared/hooks/*"],
|
||||
"@lib/*": ["src/renderer/shared/lib/*"],
|
||||
"@auto-claude/types": ["../../packages/types/src/index.ts"],
|
||||
"@auto-claude/types/*": ["../../packages/types/src/*"],
|
||||
"@auto-claude/ui": ["../../packages/ui/src/index.ts"],
|
||||
"@auto-claude/ui/*": ["../../packages/ui/src/*"]
|
||||
"@lib/*": ["src/renderer/shared/lib/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "../../packages/types/src/**/*", "../../packages/ui/src/**/*"],
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "out", "dist"]
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
.env*
|
||||
@@ -1,68 +0,0 @@
|
||||
# Build context should be the monorepo root:
|
||||
# docker build -t ac-web -f apps/web/Dockerfile .
|
||||
|
||||
FROM node:24-alpine AS base
|
||||
|
||||
# --- Dependencies ---
|
||||
FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Copy root workspace config and lockfile
|
||||
COPY package.json package-lock.json ./
|
||||
COPY packages/types/package.json packages/types/
|
||||
COPY packages/ui/package.json packages/ui/
|
||||
COPY apps/web/package.json apps/web/
|
||||
|
||||
RUN npm ci --workspaces --include-workspace-root
|
||||
|
||||
# --- Builder ---
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# npm workspaces hoists everything to root node_modules
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
|
||||
# Copy workspace package sources
|
||||
COPY package.json ./
|
||||
COPY packages/types/ packages/types/
|
||||
COPY packages/ui/ packages/ui/
|
||||
COPY apps/web/ apps/web/
|
||||
|
||||
# Build shared packages first
|
||||
RUN cd packages/types && npm run build
|
||||
RUN cd packages/ui && npm run build
|
||||
|
||||
ARG NEXT_PUBLIC_CONVEX_URL
|
||||
ENV NEXT_PUBLIC_CONVEX_URL=$NEXT_PUBLIC_CONVEX_URL
|
||||
|
||||
ARG NEXT_PUBLIC_CONVEX_SITE_URL
|
||||
ENV NEXT_PUBLIC_CONVEX_SITE_URL=$NEXT_PUBLIC_CONVEX_SITE_URL
|
||||
|
||||
ARG NEXT_PUBLIC_SITE_URL
|
||||
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN cd apps/web && npx next build
|
||||
|
||||
# --- Runner ---
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/apps/web/.next/standalone ./
|
||||
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "apps/web/server.js"]
|
||||
Vendored
-6
@@ -1,6 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -1,20 +0,0 @@
|
||||
// Node.js 22+ includes a built-in localStorage that breaks SSR in libraries
|
||||
// expecting browser-only localStorage. Remove it before any modules load.
|
||||
if (typeof window === "undefined" && typeof globalThis.localStorage !== "undefined") {
|
||||
delete globalThis.localStorage;
|
||||
}
|
||||
|
||||
const path = require("path");
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
// Transpile shared workspace packages
|
||||
transpilePackages: ["@auto-claude/ui", "@auto-claude/types"],
|
||||
// Set turbopack root to monorepo root so it can resolve workspaces
|
||||
turbopack: {
|
||||
root: path.resolve(__dirname, "../.."),
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -1,47 +0,0 @@
|
||||
{
|
||||
"name": "@auto-claude/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auto-claude/types": "*",
|
||||
"@auto-claude/ui": "*",
|
||||
"@convex-dev/better-auth": "0.10.10",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"better-auth": "1.4.9",
|
||||
"convex": "^1.31.0",
|
||||
"i18next": "^25.8.7",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^0.511.0",
|
||||
"next": "16.1.6",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-i18next": "^16.5.4",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@testing-library/dom": "^10.0.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/node": "^25.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"jsdom": "^27.3.0",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.9.0",
|
||||
"vitest": "^4.0.16"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Auto Claude - Dashboard",
|
||||
description: "AI-powered software development platform",
|
||||
};
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { AppShell } from "@/components/layout";
|
||||
|
||||
export default function DashboardPage() {
|
||||
return <AppShell />;
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import HomePage from '../page';
|
||||
|
||||
// Mock the hooks and dependencies
|
||||
vi.mock('@/hooks/useCloudMode', () => ({
|
||||
useCloudMode: vi.fn(() => ({ isCloud: false, apiUrl: 'http://localhost:8000' })),
|
||||
}));
|
||||
|
||||
vi.mock('@/providers/AuthGate', () => ({
|
||||
CloudAuthenticated: vi.fn(({ children }: { children: React.ReactNode }) => <>{children}</>),
|
||||
CloudUnauthenticated: vi.fn(() => null),
|
||||
CloudAuthLoading: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/convex-imports', () => ({
|
||||
getConvexReact: vi.fn(() => ({
|
||||
useQuery: vi.fn(),
|
||||
useMutation: vi.fn(),
|
||||
})),
|
||||
getConvexApi: vi.fn(() => ({
|
||||
api: {
|
||||
users: {
|
||||
me: 'users:me',
|
||||
ensureUser: 'users:ensureUser',
|
||||
},
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
|
||||
<a href={href}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock i18next - load actual English locale files for realistic rendering
|
||||
import enCommon from '@/locales/en/common.json';
|
||||
import enPages from '@/locales/en/pages.json';
|
||||
import enLayout from '@/locales/en/layout.json';
|
||||
import enKanban from '@/locales/en/kanban.json';
|
||||
import enViews from '@/locales/en/views.json';
|
||||
import enIntegrations from '@/locales/en/integrations.json';
|
||||
import enSettings from '@/locales/en/settings.json';
|
||||
|
||||
const locales: Record<string, any> = {
|
||||
common: enCommon,
|
||||
pages: enPages,
|
||||
layout: enLayout,
|
||||
kanban: enKanban,
|
||||
views: enViews,
|
||||
integrations: enIntegrations,
|
||||
settings: enSettings,
|
||||
};
|
||||
|
||||
function resolveKey(key: string, ns: string, params?: any): string {
|
||||
// Handle "ns:key" format
|
||||
let namespace = ns;
|
||||
let path = key;
|
||||
if (key.includes(':')) {
|
||||
const [nsOverride, ...rest] = key.split(':');
|
||||
namespace = nsOverride;
|
||||
path = rest.join(':');
|
||||
}
|
||||
const data = locales[namespace];
|
||||
if (!data) return key;
|
||||
const value = path.split('.').reduce((obj: any, k: string) => obj?.[k], data);
|
||||
if (typeof value !== 'string') return key;
|
||||
if (params) {
|
||||
return value.replace(/\{\{(\w+)\}\}/g, (_: string, p: string) => params[p] ?? '');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: (ns: string = 'common') => ({
|
||||
t: (key: string, params?: any) => resolveKey(key, ns, params),
|
||||
i18n: { language: 'en' },
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the data layer to prevent API calls from stores
|
||||
vi.mock('@/lib/data', () => ({
|
||||
apiClient: {
|
||||
getProjects: vi.fn(() => Promise.resolve({ projects: [] })),
|
||||
getTasks: vi.fn(() => Promise.resolve({ tasks: [] })),
|
||||
getSettings: vi.fn(() => Promise.resolve({ settings: {} })),
|
||||
updateSettings: vi.fn(() => Promise.resolve({})),
|
||||
updateTaskStatus: vi.fn(() => Promise.resolve({})),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('HomePage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('self-hosted mode', () => {
|
||||
beforeEach(async () => {
|
||||
const { useCloudMode } = await import('@/hooks/useCloudMode');
|
||||
vi.mocked(useCloudMode).mockReturnValue({
|
||||
isCloud: false,
|
||||
apiUrl: 'http://localhost:8000',
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the AppShell with sidebar and welcome screen', () => {
|
||||
render(<HomePage />);
|
||||
// Sidebar renders "Auto Claude" branding
|
||||
expect(screen.getByText('Auto Claude')).toBeInTheDocument();
|
||||
// WelcomeScreen renders when no project is selected
|
||||
expect(screen.getByText('Welcome to Auto Claude')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show the welcome screen when no project is selected', () => {
|
||||
render(<HomePage />);
|
||||
expect(screen.getByText('Welcome to Auto Claude')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Get started by connecting a project/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display the Connect a Project button', () => {
|
||||
render(<HomePage />);
|
||||
const connectButton = screen.getByRole('button', { name: /Connect a Project/i });
|
||||
expect(connectButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show sidebar navigation items', () => {
|
||||
render(<HomePage />);
|
||||
expect(screen.getByText('Tasks')).toBeInTheDocument();
|
||||
expect(screen.getByText('Insights')).toBeInTheDocument();
|
||||
expect(screen.getByText('Roadmap')).toBeInTheDocument();
|
||||
expect(screen.getByText('Ideation')).toBeInTheDocument();
|
||||
expect(screen.getByText('Changelog')).toBeInTheDocument();
|
||||
expect(screen.getByText('Context')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show Settings button in the sidebar', () => {
|
||||
render(<HomePage />);
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not disable Settings button when no project is active', () => {
|
||||
render(<HomePage />);
|
||||
const settingsButton = screen.getByText('Settings').closest('button');
|
||||
expect(settingsButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('should disable nav items when no project is active', () => {
|
||||
render(<HomePage />);
|
||||
const tasksButton = screen.getByText('Tasks').closest('button');
|
||||
expect(tasksButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should render main element', () => {
|
||||
const { container } = render(<HomePage />);
|
||||
expect(container.querySelector('main')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cloud mode - unauthenticated', () => {
|
||||
beforeEach(async () => {
|
||||
const { useCloudMode } = await import('@/hooks/useCloudMode');
|
||||
vi.mocked(useCloudMode).mockReturnValue({
|
||||
isCloud: true,
|
||||
apiUrl: 'http://localhost:8000',
|
||||
});
|
||||
|
||||
// Mock the AuthGate components to show unauthenticated state
|
||||
const { CloudAuthenticated, CloudUnauthenticated, CloudAuthLoading } = await import('@/providers/AuthGate');
|
||||
vi.mocked(CloudAuthenticated).mockImplementation(() => null as unknown as React.ReactElement);
|
||||
vi.mocked(CloudUnauthenticated).mockImplementation(({ children }) => <>{children}</>);
|
||||
vi.mocked(CloudAuthLoading).mockImplementation(() => null as unknown as React.ReactElement);
|
||||
});
|
||||
|
||||
it('should render without crashing in cloud mode', () => {
|
||||
render(<HomePage />);
|
||||
expect(screen.getByText('Auto Claude Cloud')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show landing page for unauthenticated users', () => {
|
||||
render(<HomePage />);
|
||||
expect(screen.getByText('Auto Claude Cloud')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cloud-synced specs, personas, and team collaboration')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show get started button linking to login', () => {
|
||||
render(<HomePage />);
|
||||
const getStartedLink = screen.getByText('Get Started');
|
||||
expect(getStartedLink).toBeInTheDocument();
|
||||
expect(getStartedLink.closest('a')).toHaveAttribute('href', '/login');
|
||||
});
|
||||
|
||||
it('should wrap content with auth gates', () => {
|
||||
const { container } = render(<HomePage />);
|
||||
expect(container.querySelector('main')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cloud mode - authenticated', () => {
|
||||
beforeEach(async () => {
|
||||
const { useCloudMode } = await import('@/hooks/useCloudMode');
|
||||
vi.mocked(useCloudMode).mockReturnValue({
|
||||
isCloud: true,
|
||||
apiUrl: 'http://localhost:8000',
|
||||
});
|
||||
|
||||
const { getConvexReact } = await import('@/lib/convex-imports');
|
||||
vi.mocked(getConvexReact).mockReturnValue({
|
||||
useQuery: vi.fn(() => ({
|
||||
_id: 'user123',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
tier: 'pro',
|
||||
})),
|
||||
useMutation: vi.fn(() => vi.fn()),
|
||||
} as any);
|
||||
|
||||
// Mock the AuthGate components to show authenticated state
|
||||
const { CloudAuthenticated, CloudUnauthenticated, CloudAuthLoading } = await import('@/providers/AuthGate');
|
||||
vi.mocked(CloudAuthenticated).mockImplementation(({ children }) => <>{children}</>);
|
||||
vi.mocked(CloudUnauthenticated).mockImplementation(() => null);
|
||||
vi.mocked(CloudAuthLoading).mockImplementation(() => null);
|
||||
});
|
||||
|
||||
it('should render the AppShell for authenticated cloud users', () => {
|
||||
render(<HomePage />);
|
||||
// AppShell renders Sidebar with branding
|
||||
expect(screen.getByText('Auto Claude')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show welcome screen when no project is selected', () => {
|
||||
render(<HomePage />);
|
||||
expect(screen.getByText('Welcome to Auto Claude')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Connect a Project/i })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show sidebar navigation items for authenticated users', () => {
|
||||
render(<HomePage />);
|
||||
const navLabels = ['Tasks', 'Insights', 'Roadmap', 'Ideation', 'Changelog', 'Context'];
|
||||
navLabels.forEach(label => {
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cloud mode - loading state', () => {
|
||||
beforeEach(async () => {
|
||||
const { useCloudMode } = await import('@/hooks/useCloudMode');
|
||||
vi.mocked(useCloudMode).mockReturnValue({
|
||||
isCloud: true,
|
||||
apiUrl: 'http://localhost:8000',
|
||||
});
|
||||
|
||||
const { getConvexReact } = await import('@/lib/convex-imports');
|
||||
vi.mocked(getConvexReact).mockReturnValue({
|
||||
useQuery: vi.fn(() => null),
|
||||
useMutation: vi.fn(() => vi.fn()),
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('should show loading state when user is null', () => {
|
||||
render(<HomePage />);
|
||||
// Should show loading in both CloudAuthLoading and CloudDashboard
|
||||
const loadingElements = screen.getAllByText('Loading...');
|
||||
expect(loadingElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('should have proper heading hierarchy in self-hosted mode', async () => {
|
||||
const { useCloudMode } = await import('@/hooks/useCloudMode');
|
||||
vi.mocked(useCloudMode).mockReturnValue({
|
||||
isCloud: false,
|
||||
apiUrl: 'http://localhost:8000',
|
||||
});
|
||||
|
||||
render(<HomePage />);
|
||||
const headings = screen.getAllByRole('heading');
|
||||
expect(headings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have accessible buttons in self-hosted mode', async () => {
|
||||
const { useCloudMode } = await import('@/hooks/useCloudMode');
|
||||
vi.mocked(useCloudMode).mockReturnValue({
|
||||
isCloud: false,
|
||||
apiUrl: 'http://localhost:8000',
|
||||
});
|
||||
|
||||
render(<HomePage />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
import { handler } from "@/lib/auth-server";
|
||||
|
||||
export const { GET, POST } = handler;
|
||||
@@ -1,3 +0,0 @@
|
||||
export async function GET() {
|
||||
return Response.json({ status: "ok" }, { status: 200 });
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
/* Colors */
|
||||
--color-background: oklch(1 0 0);
|
||||
--color-foreground: oklch(0.145 0 0);
|
||||
--color-card: oklch(1 0 0);
|
||||
--color-card-foreground: oklch(0.145 0 0);
|
||||
--color-popover: oklch(1 0 0);
|
||||
--color-popover-foreground: oklch(0.145 0 0);
|
||||
--color-primary: oklch(0.205 0.072 265.755);
|
||||
--color-primary-foreground: oklch(0.985 0 0);
|
||||
--color-secondary: oklch(0.97 0 0);
|
||||
--color-secondary-foreground: oklch(0.205 0 0);
|
||||
--color-muted: oklch(0.97 0 0);
|
||||
--color-muted-foreground: oklch(0.556 0 0);
|
||||
--color-accent: oklch(0.97 0 0);
|
||||
--color-accent-foreground: oklch(0.205 0 0);
|
||||
--color-destructive: oklch(0.577 0.245 27.325);
|
||||
--color-destructive-foreground: oklch(0.577 0.245 27.325);
|
||||
--color-border: oklch(0.922 0 0);
|
||||
--color-input: oklch(0.922 0 0);
|
||||
--color-ring: oklch(0.708 0.165 254.624);
|
||||
--color-sidebar: oklch(0.985 0 0);
|
||||
--color-sidebar-foreground: oklch(0.145 0 0);
|
||||
|
||||
/* Border Radius */
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
--radius-xl: 0.75rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-background: oklch(0.145 0 0);
|
||||
--color-foreground: oklch(0.985 0 0);
|
||||
--color-card: oklch(0.185 0 0);
|
||||
--color-card-foreground: oklch(0.985 0 0);
|
||||
--color-popover: oklch(0.185 0 0);
|
||||
--color-popover-foreground: oklch(0.985 0 0);
|
||||
--color-primary: oklch(0.708 0.165 254.624);
|
||||
--color-primary-foreground: oklch(0.145 0 0);
|
||||
--color-secondary: oklch(0.248 0 0);
|
||||
--color-secondary-foreground: oklch(0.985 0 0);
|
||||
--color-muted: oklch(0.248 0 0);
|
||||
--color-muted-foreground: oklch(0.646 0 0);
|
||||
--color-accent: oklch(0.248 0 0);
|
||||
--color-accent-foreground: oklch(0.985 0 0);
|
||||
--color-destructive: oklch(0.396 0.141 25.768);
|
||||
--color-destructive-foreground: oklch(0.637 0.237 25.331);
|
||||
--color-border: oklch(0.295 0 0);
|
||||
--color-input: oklch(0.295 0 0);
|
||||
--color-ring: oklch(0.551 0.177 259.082);
|
||||
--color-sidebar: oklch(0.165 0 0);
|
||||
--color-sidebar-foreground: oklch(0.985 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar styles */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.5 0 0 / 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.5 0 0 / 0.5);
|
||||
}
|
||||
|
||||
/* Line clamp utility */
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import { ConvexClientProvider } from "@/providers/ConvexClientProvider";
|
||||
import { I18nProvider } from "@/providers/I18nProvider";
|
||||
import { CLOUD_MODE } from "@/lib/cloud-mode";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Auto Claude",
|
||||
description: "AI-powered software development platform",
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
let token: string | null = null;
|
||||
|
||||
if (CLOUD_MODE) {
|
||||
try {
|
||||
const { getToken } = await import("@/lib/auth-server");
|
||||
token = (await getToken()) ?? null;
|
||||
} catch {
|
||||
// auth-server not available in self-hosted mode
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<html lang="en" className="dark" suppressHydrationWarning>
|
||||
<body className="min-h-screen bg-background font-sans antialiased">
|
||||
<I18nProvider>
|
||||
<ConvexClientProvider initialToken={token}>
|
||||
{children}
|
||||
</ConvexClientProvider>
|
||||
</I18nProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CloudAuthenticated } from "@/providers/AuthGate";
|
||||
import { useCloudMode } from "@/hooks/useCloudMode";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function LoginPage() {
|
||||
const { isCloud } = useCloudMode();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation(["pages", "common", "auth"]);
|
||||
|
||||
// Self-hosted mode: no login needed, redirect home
|
||||
if (!isCloud) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center gap-4">
|
||||
<h1 className="text-2xl font-bold">{t("pages:login.selfHosted.title")}</h1>
|
||||
<p className="text-gray-600">{t("pages:login.selfHosted.noLoginRequired")}</p>
|
||||
<a
|
||||
href="/"
|
||||
className="rounded-lg bg-blue-600 px-6 py-3 text-white hover:bg-blue-700"
|
||||
>
|
||||
{t("common:buttons.goToDashboard")}
|
||||
</a>
|
||||
<div className="mt-8 rounded-lg border border-blue-200 bg-blue-50 p-4 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
{t("pages:login.selfHosted.cloudPromo")}
|
||||
</p>
|
||||
<a href="https://autoclaude.com" className="text-sm text-blue-600 hover:underline">
|
||||
{t("pages:login.selfHosted.tryCloud")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const signIn = async () => {
|
||||
const { authClient } = await import("@/lib/auth-client");
|
||||
authClient.signIn.social({ provider: "github", callbackURL: "/" });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CloudAuthenticated>
|
||||
<RedirectHome />
|
||||
</CloudAuthenticated>
|
||||
<div className="flex h-screen flex-col items-center justify-center gap-4">
|
||||
<h1 className="text-2xl font-bold">{t("auth:signInTitle")}</h1>
|
||||
<button
|
||||
onClick={signIn}
|
||||
className="rounded-lg bg-gray-900 px-6 py-3 text-white hover:bg-gray-800"
|
||||
>
|
||||
{t("auth:signIn")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RedirectHome() {
|
||||
const router = useRouter();
|
||||
useEffect(() => { router.push("/"); }, [router]);
|
||||
return null;
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CloudAuthenticated, CloudUnauthenticated, CloudAuthLoading } from "@/providers/AuthGate";
|
||||
import { useCloudMode } from "@/hooks/useCloudMode";
|
||||
import { getConvexReact, getConvexApi } from "@/lib/convex-imports";
|
||||
import { AppShell } from "@/components/layout";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function CloudDashboard() {
|
||||
const { useQuery, useMutation } = getConvexReact();
|
||||
const { api } = getConvexApi();
|
||||
const { t } = useTranslation(["pages", "common"]);
|
||||
|
||||
const user = useQuery(api.users.me);
|
||||
const ensureUser = useMutation(api.users.ensureUser);
|
||||
|
||||
useEffect(() => {
|
||||
if (user === null) {
|
||||
ensureUser();
|
||||
}
|
||||
}, [user, ensureUser]);
|
||||
|
||||
if (!user) return <div className="flex h-screen items-center justify-center">{t("common:loading")}</div>;
|
||||
|
||||
return <AppShell />;
|
||||
}
|
||||
|
||||
function SelfHostedDashboard() {
|
||||
return <AppShell />;
|
||||
}
|
||||
|
||||
function LandingPage() {
|
||||
const { t } = useTranslation(["pages", "common"]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center gap-4">
|
||||
<h1 className="text-4xl font-bold">{t("pages:home.landing.title")}</h1>
|
||||
<p className="text-gray-600">{t("pages:home.landing.subtitle")}</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="rounded-lg bg-blue-600 px-6 py-3 text-white hover:bg-blue-700"
|
||||
>
|
||||
{t("common:buttons.getStarted")}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const { isCloud } = useCloudMode();
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
if (!isCloud) {
|
||||
return (
|
||||
<main>
|
||||
<SelfHostedDashboard />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<CloudAuthLoading>
|
||||
<div className="flex h-screen items-center justify-center">{t("loading")}</div>
|
||||
</CloudAuthLoading>
|
||||
<CloudUnauthenticated>
|
||||
<LandingPage />
|
||||
</CloudUnauthenticated>
|
||||
<CloudAuthenticated>
|
||||
<CloudDashboard />
|
||||
</CloudAuthenticated>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { usePersonas } from "@/hooks";
|
||||
import { useCurrentUser } from "@/hooks/useCurrentUser";
|
||||
import { useCloudMode } from "@/hooks/useCloudMode";
|
||||
import { CloudUpgradeCTA } from "@/components/CloudUpgradeCTA";
|
||||
import { hasTierAccess } from "@/lib/tiers";
|
||||
import { formatPrice } from "@/lib/pricing";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function CloudPersonas() {
|
||||
const user = useCurrentUser();
|
||||
const { personas, isLoading, createPersona } = usePersonas();
|
||||
const { t } = useTranslation(["pages", "common"]);
|
||||
|
||||
// Personas require Pro tier or higher
|
||||
const hasPersonaAccess = hasTierAccess(user?.tier, "pro");
|
||||
|
||||
if (!hasPersonaAccess) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center gap-4 p-8">
|
||||
<h1 className="text-2xl font-bold">{t("pages:personas.title")}</h1>
|
||||
<p className="text-gray-600">
|
||||
{t("pages:personas.description")}
|
||||
</p>
|
||||
<a
|
||||
href="/settings"
|
||||
className="rounded-lg bg-blue-600 px-6 py-3 text-white hover:bg-blue-700"
|
||||
>
|
||||
{t("common:buttons.upgrade", { tier: t("common:tiers.pro") })} - {formatPrice("pro")}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-8">{t("common:loadingData", { dataType: t("common:navigation.personas") })}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">{t("pages:personas.title")}</h1>
|
||||
<button
|
||||
onClick={() => createPersona("New Persona", "A helpful coding assistant", [])}
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
|
||||
>
|
||||
{t("pages:personas.createButton")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
{personas.length === 0 ? (
|
||||
<p className="text-gray-500">{t("pages:personas.empty")}</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{personas.map((persona: any) => (
|
||||
<li key={persona._id} className="rounded-lg border p-4">
|
||||
<h3 className="font-semibold">{persona.name}</h3>
|
||||
<p className="text-gray-600">{persona.description}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PersonasPage() {
|
||||
const { isCloud } = useCloudMode();
|
||||
const { t } = useTranslation("pages");
|
||||
|
||||
if (!isCloud) {
|
||||
return <CloudUpgradeCTA title={t("personas.title")} description={t("personas.cloudFeature")} />;
|
||||
}
|
||||
|
||||
return <CloudPersonas />;
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { usePRQueue } from "@/hooks";
|
||||
import { useCurrentUser } from "@/hooks/useCurrentUser";
|
||||
import { useCloudMode } from "@/hooks/useCloudMode";
|
||||
import { CloudUpgradeCTA } from "@/components/CloudUpgradeCTA";
|
||||
import { hasTierAccess } from "@/lib/tiers";
|
||||
import { formatPrice } from "@/lib/pricing";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function CloudPRQueue() {
|
||||
const user = useCurrentUser();
|
||||
const { t } = useTranslation(["pages", "common"]);
|
||||
|
||||
// PR Queue requires Team tier or higher
|
||||
const hasTeamAccess = hasTierAccess(user?.tier, "team");
|
||||
|
||||
if (!hasTeamAccess) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center gap-4 p-8">
|
||||
<h1 className="text-2xl font-bold">{t("pages:prQueue.title")}</h1>
|
||||
<p className="text-gray-600">
|
||||
{t("pages:prQueue.description")}
|
||||
</p>
|
||||
<a
|
||||
href="/settings"
|
||||
className="rounded-lg bg-blue-600 px-6 py-3 text-white hover:bg-blue-700"
|
||||
>
|
||||
{t("common:buttons.upgrade", { tier: t("common:tiers.team") })} - {formatPrice("team")}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Get teamId from user's active team
|
||||
const teamId = "";
|
||||
const { prs, isLoading, updateStatus, assignPR } = usePRQueue(teamId);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-8">{t("common:loadingData", { dataType: t("common:navigation.prQueue") })}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold">{t("pages:prQueue.title")}</h1>
|
||||
<div className="mt-8">
|
||||
{prs.length === 0 ? (
|
||||
<p className="text-gray-500">{t("pages:prQueue.empty")}</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{prs.map((pr: any) => (
|
||||
<li key={pr._id} className="rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold">{t("pages:prQueue.prNumber", { number: pr.prNumber })} {pr.title}</span>
|
||||
<span className="rounded bg-gray-100 px-2 py-1 text-sm">{pr.status}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PRQueuePage() {
|
||||
const { isCloud } = useCloudMode();
|
||||
const { t } = useTranslation("pages");
|
||||
|
||||
if (!isCloud) {
|
||||
return <CloudUpgradeCTA title={t("prQueue.title")} description={t("prQueue.cloudFeature")} />;
|
||||
}
|
||||
|
||||
return <CloudPRQueue />;
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCurrentUser } from "@/hooks/useCurrentUser";
|
||||
import { useCloudMode } from "@/hooks/useCloudMode";
|
||||
import { getConvexReact, getConvexApi } from "@/lib/convex-imports";
|
||||
import { useState } from "react";
|
||||
import { hasTierAccess } from "@/lib/tiers";
|
||||
import { formatPrice, PRICING } from "@/lib/pricing";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type Tier = "pro" | "team" | "enterprise";
|
||||
|
||||
function SelfHostedSettings() {
|
||||
const { t } = useTranslation(["settings", "common"]);
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-3xl">
|
||||
<h1 className="text-2xl font-bold">{t("settings:title")}</h1>
|
||||
<section className="mt-8">
|
||||
<h2 className="text-xl font-semibold">{t("settings:selfHosted.mode")}</h2>
|
||||
<p className="mt-2 text-gray-600">
|
||||
{t("settings:selfHosted.description")}
|
||||
</p>
|
||||
</section>
|
||||
<section className="mt-8 rounded-lg border border-blue-200 bg-blue-50 p-6">
|
||||
<h2 className="text-lg font-semibold">{t("settings:selfHosted.unlockFeatures")}</h2>
|
||||
<p className="mt-2 text-gray-600">
|
||||
{t("settings:selfHosted.featuresDescription")}
|
||||
</p>
|
||||
<a
|
||||
href="https://autoclaude.com"
|
||||
className="mt-4 inline-block rounded-lg bg-blue-600 px-6 py-3 text-white hover:bg-blue-700"
|
||||
>
|
||||
{t("common:buttons.learnMore")}
|
||||
</a>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CloudSettings() {
|
||||
const { useAction, useQuery } = getConvexReact();
|
||||
const { api } = getConvexApi();
|
||||
const { t } = useTranslation(["settings", "common", "auth"]);
|
||||
|
||||
const user = useCurrentUser();
|
||||
const tierInfo = useQuery(api.billing.getTierInfo);
|
||||
const createCheckout = useAction(api.stripe.createCheckoutSession);
|
||||
const createPortal = useAction(api.stripe.createPortalSession);
|
||||
const [loading, setLoading] = useState<Tier | "portal" | null>(null);
|
||||
|
||||
const logout = async () => {
|
||||
const { authClient } = await import("@/lib/auth-client");
|
||||
authClient.signOut();
|
||||
};
|
||||
|
||||
const handleUpgrade = async (tier: Tier) => {
|
||||
setLoading(tier);
|
||||
try {
|
||||
const url = await createCheckout({ tier });
|
||||
window.location.href = url;
|
||||
} catch (error) {
|
||||
console.error("Checkout error:", error);
|
||||
alert(t("common:errors.checkoutFailed"));
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleManageSubscription = async () => {
|
||||
setLoading("portal");
|
||||
try {
|
||||
const url = await createPortal();
|
||||
window.location.href = url;
|
||||
} catch (error) {
|
||||
console.error("Portal error:", error);
|
||||
alert(t("common:errors.portalFailed"));
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return <div className="p-8">{t("common:errors.loginRequired")}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-3xl">
|
||||
<h1 className="text-2xl font-bold">{t("settings:title")}</h1>
|
||||
|
||||
{/* Profile Section */}
|
||||
<section className="mt-8">
|
||||
<h2 className="text-xl font-semibold">{t("settings:profile.title")}</h2>
|
||||
<div className="mt-4 space-y-2">
|
||||
<p><strong>{t("settings:profile.name")}</strong> {user.name}</p>
|
||||
<p><strong>{t("settings:profile.email")}</strong> {user.email}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Billing Section */}
|
||||
<section className="mt-8">
|
||||
<h2 className="text-xl font-semibold">{t("settings:subscription.title")}</h2>
|
||||
<div className="mt-4">
|
||||
<p>
|
||||
<strong>{t("settings:subscription.currentPlan")}</strong>{" "}
|
||||
{tierInfo?.tierName ?? (user.tier ? user.tier.charAt(0).toUpperCase() + user.tier.slice(1) : t("common:tiers.free"))}
|
||||
{tierInfo?.price ? ` — ${t("settings:subscription.price", { price: tierInfo.price })}` : ""}
|
||||
</p>
|
||||
|
||||
{/* Usage */}
|
||||
{tierInfo && (
|
||||
<div className="mt-4 space-y-1 text-sm text-gray-600">
|
||||
<p>
|
||||
{t("settings:subscription.usage.specs", {
|
||||
current: tierInfo.usage.specs,
|
||||
limit: tierInfo.limits.specs === -1 ? t("settings:subscription.usage.unlimited") : tierInfo.limits.specs
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{t("settings:subscription.usage.personas", {
|
||||
current: tierInfo.usage.personas,
|
||||
limit: tierInfo.limits.personas === -1 ? t("settings:subscription.usage.unlimited") : tierInfo.limits.personas
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{t("settings:subscription.usage.teamMembers", {
|
||||
current: tierInfo.usage.teamMembers,
|
||||
limit: tierInfo.limits.teamMembers === -1 ? t("settings:subscription.usage.unlimited") : tierInfo.limits.teamMembers
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upgrade buttons */}
|
||||
<div className="mt-4 flex gap-4">
|
||||
{!hasTierAccess(user.tier, "pro") && (
|
||||
<button
|
||||
onClick={() => handleUpgrade("pro")}
|
||||
disabled={loading !== null}
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading === "pro" ? t("common:buttons.redirecting") : `${t("common:buttons.upgrade", { tier: t("common:tiers.pro") })} - ${formatPrice("pro")}`}
|
||||
</button>
|
||||
)}
|
||||
{!hasTierAccess(user.tier, "team") && (
|
||||
<button
|
||||
onClick={() => handleUpgrade("team")}
|
||||
disabled={loading !== null}
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading === "team" ? t("common:buttons.redirecting") : `${t("common:buttons.upgrade", { tier: t("common:tiers.team") })} - ${formatPrice("team")}`}
|
||||
</button>
|
||||
)}
|
||||
{!hasTierAccess(user.tier, "enterprise") && (
|
||||
<button
|
||||
onClick={() => handleUpgrade("enterprise")}
|
||||
disabled={loading !== null}
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading === "enterprise" ? t("common:buttons.redirecting") : `${t("common:tiers.enterprise")} - ${formatPrice("enterprise")}`}
|
||||
</button>
|
||||
)}
|
||||
{hasTierAccess(user.tier, "pro") && (
|
||||
<button
|
||||
onClick={handleManageSubscription}
|
||||
disabled={loading !== null}
|
||||
className="rounded-lg border px-4 py-2 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
{loading === "portal" ? t("common:buttons.redirecting") : t("common:buttons.manageSubscription")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Sign Out */}
|
||||
<section className="mt-8">
|
||||
<button
|
||||
onClick={logout}
|
||||
className="rounded-lg border border-red-600 px-4 py-2 text-red-600 hover:bg-red-50"
|
||||
>
|
||||
{t("auth:signOut")}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { isCloud } = useCloudMode();
|
||||
|
||||
if (!isCloud) {
|
||||
return <SelfHostedSettings />;
|
||||
}
|
||||
|
||||
return <CloudSettings />;
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useSpec } from "@/hooks";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
export default function SpecPage() {
|
||||
const params = useParams();
|
||||
const specId = params.id as string;
|
||||
const { spec, isLoading } = useSpec(specId);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-8">Loading spec...</div>;
|
||||
}
|
||||
|
||||
if (!spec) {
|
||||
return <div className="p-8">Spec not found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold">{(spec as any).name}</h1>
|
||||
<pre className="mt-4 rounded-lg bg-gray-100 p-4">
|
||||
{(spec as any).content}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useSpecs } from "@/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function SpecsPage() {
|
||||
const { specs, isLoading, createSpec } = useSpecs();
|
||||
const { t } = useTranslation(["pages", "common"]);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-8">{t("common:loadingData", { dataType: t("common:navigation.specs") })}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">{t("pages:specs.title")}</h1>
|
||||
<button
|
||||
onClick={() => createSpec("New Spec", "# New Spec\n\nDescription here...")}
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
|
||||
>
|
||||
{t("pages:specs.createButton")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
{specs.length === 0 ? (
|
||||
<p className="text-gray-500">{t("pages:specs.empty")}</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{specs.map((spec: any) => (
|
||||
<li key={spec._id}>
|
||||
<a
|
||||
href={`/specs/${spec._id}`}
|
||||
className="block rounded-lg border p-4 hover:bg-gray-50"
|
||||
>
|
||||
{spec.name}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTeam, useTeamMembers } from "@/hooks";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
export default function TeamPage() {
|
||||
const params = useParams();
|
||||
const teamId = params.id as string;
|
||||
const { team, isLoading } = useTeam(teamId);
|
||||
const { members, inviteMember, removeMember } = useTeamMembers(teamId);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-8">Loading team...</div>;
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
return <div className="p-8">Team not found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold">{(team as any).name}</h1>
|
||||
<h2 className="mt-8 text-xl font-semibold">Members</h2>
|
||||
<ul className="mt-4 space-y-2">
|
||||
{members.map((member: any) => (
|
||||
<li key={member._id} className="flex items-center justify-between rounded-lg border p-4">
|
||||
<span>{member.userId}</span>
|
||||
<span className="text-gray-500">{member.role}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTeams } from "@/hooks";
|
||||
import { useCurrentUser } from "@/hooks/useCurrentUser";
|
||||
import { useCloudMode } from "@/hooks/useCloudMode";
|
||||
import { CloudUpgradeCTA } from "@/components/CloudUpgradeCTA";
|
||||
import { hasTierAccess } from "@/lib/tiers";
|
||||
import { formatPrice } from "@/lib/pricing";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function CloudTeams() {
|
||||
const user = useCurrentUser();
|
||||
const { teams, isLoading, createTeam } = useTeams();
|
||||
const { t } = useTranslation(["pages", "common"]);
|
||||
|
||||
// Team feature requires Team tier or higher
|
||||
const hasTeamAccess = hasTierAccess(user?.tier, "team");
|
||||
|
||||
if (!hasTeamAccess) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center gap-4 p-8">
|
||||
<h1 className="text-2xl font-bold">{t("pages:teams.singleTitle")}</h1>
|
||||
<p className="text-gray-600">{t("pages:teams.description")}</p>
|
||||
<a
|
||||
href="/settings"
|
||||
className="rounded-lg bg-blue-600 px-6 py-3 text-white hover:bg-blue-700"
|
||||
>
|
||||
{t("common:buttons.upgrade", { tier: t("common:tiers.team") })} - {formatPrice("team")}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-8">{t("common:loadingData", { dataType: t("common:navigation.teams") })}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">{t("pages:teams.title")}</h1>
|
||||
<button
|
||||
onClick={() => createTeam("New Team")}
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
|
||||
>
|
||||
{t("pages:teams.createButton")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
{teams.length === 0 ? (
|
||||
<p className="text-gray-500">{t("pages:teams.empty")}</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{teams.map((team: any) => (
|
||||
<li key={team._id}>
|
||||
<a
|
||||
href={`/teams/${team._id}`}
|
||||
className="block rounded-lg border p-4 hover:bg-gray-50"
|
||||
>
|
||||
{team.name}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TeamsPage() {
|
||||
const { isCloud } = useCloudMode();
|
||||
const { t } = useTranslation("pages");
|
||||
|
||||
if (!isCloud) {
|
||||
return <CloudUpgradeCTA title={t("teams.singleTitle")} description={t("teams.cloudFeature")} />;
|
||||
}
|
||||
|
||||
return <CloudTeams />;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user