Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 854effa5c0 | |||
| 5bb504224c | |||
| 9d83faa1b6 | |||
| 98ca140803 | |||
| f6f43e8fa0 | |||
| 5bf4514979 | |||
| da04d9782e | |||
| 568bb0009a | |||
| 6ac9d2a2e1 | |||
| 41fcd1f25c | |||
| a230dd0429 | |||
| 7a1eaf583c | |||
| 6a341da572 | |||
| 4c231c0d7b | |||
| a10eb0f141 | |||
| 22ff45e122 | |||
| f37ce833c3 | |||
| 7b030e27ef | |||
| 5c042b1ad8 | |||
| 05fe6c865c | |||
| 5f6540af71 | |||
| f9d3c4586f | |||
| 7d5c9c6487 | |||
| 18e0c7f4ce | |||
| ca1074fef8 | |||
| 82c70840c9 | |||
| 3a271e2009 | |||
| b144e9209b | |||
| 42cdbda993 | |||
| 5b13103b9f | |||
| 6caab09616 | |||
| cce30f1443 | |||
| 8256859518 | |||
| 8ab82d7e54 | |||
| adb4cbaffd | |||
| d5c8ddcd82 | |||
| 3d1ba27048 | |||
| 4f2ecdf07f | |||
| 45436b1c55 | |||
| 22cbe8d125 | |||
| c18f4cb195 | |||
| e5417cf71a | |||
| 168f2e482b | |||
| 509a410d1f | |||
| 54f4519b11 | |||
| 1276cafa8e | |||
| 9d4a498637 | |||
| 6a79681ad8 | |||
| 32d83dc6a4 | |||
| b3f92ccf6c | |||
| 99db6b29d5 | |||
| e0610b555b | |||
| ab6cd0af27 | |||
| b62878c3db | |||
| 50f6e137e5 | |||
| 5cd8c3bd49 | |||
| 3b3dcc2cfe | |||
| 68f4072b47 | |||
| 2ed5170eb3 | |||
| e8fe022fe6 | |||
| 965263d2ff | |||
| 5ed320dbdb | |||
| d16c41805f | |||
| 6e3b6ed6d0 |
+12
@@ -0,0 +1,12 @@
|
||||
[run]
|
||||
parallel = true
|
||||
source = apps/backend/cli
|
||||
omit =
|
||||
*/tests/*
|
||||
*/__pycache__/*
|
||||
*/.venv/*
|
||||
|
||||
[report]
|
||||
exclude_lines =
|
||||
pragma: no cover
|
||||
if __name__ == "__main__":
|
||||
@@ -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/
|
||||
|
||||
|
||||
+12
-20
@@ -97,9 +97,8 @@ repos:
|
||||
- id: ruff-format
|
||||
files: ^apps/backend/
|
||||
|
||||
# Python tests (apps/backend/) - skip slow/integration tests for pre-commit speed
|
||||
# Python tests (apps/backend/) - run full test suite from project root
|
||||
# Tests to skip: graphiti (external deps), merge_file_tracker/service_orchestrator/worktree/workspace (Windows path/git issues)
|
||||
# NOTE: Skip this hook in worktrees (where .git is a file, not a directory)
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: pytest
|
||||
@@ -108,31 +107,24 @@ repos:
|
||||
args:
|
||||
- -c
|
||||
- |
|
||||
# Skip in worktrees - .git is a file pointing to main repo, not a directory
|
||||
# This prevents path resolution issues with ../../tests/ in worktree context
|
||||
if [ -f ".git" ]; then
|
||||
echo "Skipping pytest in worktree (path resolution would fail)"
|
||||
exit 0
|
||||
fi
|
||||
cd apps/backend
|
||||
if [ -f ".venv/bin/pytest" ]; then
|
||||
PYTEST_CMD=".venv/bin/pytest"
|
||||
elif [ -f ".venv/Scripts/pytest.exe" ]; then
|
||||
PYTEST_CMD=".venv/Scripts/pytest.exe"
|
||||
# Run pytest directly from project root
|
||||
if [ -f "apps/backend/.venv/bin/pytest" ]; then
|
||||
PYTEST_CMD="apps/backend/.venv/bin/pytest"
|
||||
elif [ -f "apps/backend/.venv/Scripts/pytest.exe" ]; then
|
||||
PYTEST_CMD="apps/backend/.venv/Scripts/pytest.exe"
|
||||
else
|
||||
PYTEST_CMD="python -m pytest"
|
||||
fi
|
||||
PYTHONPATH=. $PYTEST_CMD \
|
||||
../../tests/ \
|
||||
$PYTEST_CMD tests/ \
|
||||
-v \
|
||||
--tb=short \
|
||||
-x \
|
||||
-m "not slow and not integration" \
|
||||
--ignore=../../tests/test_graphiti.py \
|
||||
--ignore=../../tests/test_merge_file_tracker.py \
|
||||
--ignore=../../tests/test_service_orchestrator.py \
|
||||
--ignore=../../tests/test_worktree.py \
|
||||
--ignore=../../tests/test_workspace.py
|
||||
--ignore=tests/test_graphiti.py \
|
||||
--ignore=tests/test_merge_file_tracker.py \
|
||||
--ignore=tests/test_service_orchestrator.py \
|
||||
--ignore=tests/test_worktree.py \
|
||||
--ignore=tests/test_workspace.py
|
||||
language: system
|
||||
files: ^(apps/backend/.*\.py$|tests/.*\.py$)
|
||||
pass_filenames: false
|
||||
|
||||
@@ -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.3)
|
||||
<!-- 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.3-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.3/Auto-Claude-2.7.6-beta.3-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.6-beta.3-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.3/Auto-Claude-2.7.6-beta.3-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.6-beta.3-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.3/Auto-Claude-2.7.6-beta.3-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.6-beta.3-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.3/Auto-Claude-2.7.6-beta.3-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.6-beta.3-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.3/Auto-Claude-2.7.6-beta.3-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Auto-Claude-2.7.6-beta.3-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.3/Auto-Claude-2.7.6-beta.3-linux-x86_64.flatpak) |
|
||||
<!-- BETA_DOWNLOADS_END -->
|
||||
|
||||
> All releases include SHA256 checksums and VirusTotal scan results for security verification.
|
||||
|
||||
@@ -67,4 +67,10 @@ tests/
|
||||
|
||||
# Auto Claude data directory
|
||||
.auto-claude/
|
||||
|
||||
# Auto Claude generated files
|
||||
.auto-claude-security.json
|
||||
.auto-claude-status
|
||||
.security-key
|
||||
logs/security/
|
||||
coverage.json
|
||||
|
||||
@@ -19,5 +19,5 @@ Quick Start:
|
||||
See README.md for full documentation.
|
||||
"""
|
||||
|
||||
__version__ = "2.7.7"
|
||||
__version__ = "2.7.6-beta.3"
|
||||
__author__ = "Auto Claude Team"
|
||||
|
||||
@@ -292,14 +292,6 @@ AGENT_CONFIGS = {
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"pr_followup_extraction": {
|
||||
# Lightweight extraction call for recovering data when structured output fails
|
||||
# Pure structured output extraction, no tools needed
|
||||
"tools": [],
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "low",
|
||||
},
|
||||
"pr_finding_validator": {
|
||||
# Standalone validator for re-checking findings against actual code
|
||||
# Called separately from orchestrator to validate findings with fresh context
|
||||
|
||||
@@ -10,6 +10,7 @@ import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from qa.criteria import is_fixes_applied, is_qa_approved, is_qa_rejected
|
||||
from ui import highlight, print_status
|
||||
|
||||
|
||||
@@ -151,13 +152,22 @@ def handle_batch_status_command(project_dir: str) -> bool:
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Determine status
|
||||
if (spec_dir / "spec.md").exists():
|
||||
status = "spec_created"
|
||||
elif (spec_dir / "implementation_plan.json").exists():
|
||||
status = "building"
|
||||
elif (spec_dir / "qa_report.md").exists():
|
||||
# Determine status (highest priority first)
|
||||
# Use authoritative QA status check, not just file existence
|
||||
if is_qa_approved(spec_dir):
|
||||
status = "qa_approved"
|
||||
elif is_qa_rejected(spec_dir):
|
||||
status = "qa_rejected"
|
||||
elif is_fixes_applied(spec_dir):
|
||||
status = "fixes_applied"
|
||||
elif (spec_dir / "implementation_plan.json").exists():
|
||||
# Check if there's a qa_report.md but no approval yet (QA in progress)
|
||||
if (spec_dir / "qa_report.md").exists():
|
||||
status = "qa_in_progress"
|
||||
else:
|
||||
status = "building"
|
||||
elif (spec_dir / "spec.md").exists():
|
||||
status = "spec_created"
|
||||
else:
|
||||
status = "pending_spec"
|
||||
|
||||
@@ -165,7 +175,10 @@ def handle_batch_status_command(project_dir: str) -> bool:
|
||||
"pending_spec": "⏳",
|
||||
"spec_created": "📋",
|
||||
"building": "⚙️",
|
||||
"qa_in_progress": "🔍",
|
||||
"qa_approved": "✅",
|
||||
"qa_rejected": "❌",
|
||||
"fixes_applied": "🔧",
|
||||
"unknown": "❓",
|
||||
}.get(status, "❓")
|
||||
|
||||
@@ -192,10 +205,10 @@ def handle_batch_cleanup_command(project_dir: str, dry_run: bool = True) -> bool
|
||||
print_status("No specs directory found", "info")
|
||||
return True
|
||||
|
||||
# Find completed specs
|
||||
# Find completed specs (only QA-approved, matching status display logic)
|
||||
completed = []
|
||||
for spec_dir in specs_dir.iterdir():
|
||||
if spec_dir.is_dir() and (spec_dir / "qa_report.md").exists():
|
||||
if spec_dir.is_dir() and is_qa_approved(spec_dir):
|
||||
completed.append(spec_dir.name)
|
||||
|
||||
if not completed:
|
||||
|
||||
@@ -449,7 +449,7 @@ def _handle_build_interrupt(
|
||||
if choice == "skip":
|
||||
print()
|
||||
print_status("Resuming build...", "info")
|
||||
status_manager.update(state=BuildState.RUNNING)
|
||||
status_manager.update(state=BuildState.BUILDING)
|
||||
asyncio.run(
|
||||
run_autonomous_agent(
|
||||
project_dir=working_dir,
|
||||
|
||||
@@ -186,12 +186,14 @@ def _before_send(event: dict, hint: dict) -> dict | None:
|
||||
|
||||
def init_sentry(
|
||||
component: str = "backend",
|
||||
force_enable: bool = False,
|
||||
) -> bool:
|
||||
"""
|
||||
Initialize Sentry for the Python backend.
|
||||
|
||||
Args:
|
||||
component: Component name for tagging (e.g., "backend", "github-runner")
|
||||
force_enable: Force enable even without packaged app detection
|
||||
|
||||
Returns:
|
||||
True if Sentry was initialized, False otherwise
|
||||
@@ -210,11 +212,20 @@ def init_sentry(
|
||||
logger.debug("[Sentry] No SENTRY_DSN configured - error reporting disabled")
|
||||
return False
|
||||
|
||||
# DSN is present (checked above), so Sentry should be enabled.
|
||||
# The Electron main process only passes SENTRY_DSN to subprocesses in
|
||||
# production builds, so its presence is sufficient to gate activation.
|
||||
# In dev, set SENTRY_DSN in your environment to opt-in.
|
||||
# Check if we should enable Sentry
|
||||
# Enable if:
|
||||
# - Running from packaged app (detected by __compiled__ or frozen)
|
||||
# - SENTRY_DEV=true is set
|
||||
# - force_enable is True
|
||||
is_packaged = getattr(sys, "frozen", False) or hasattr(sys, "__compiled__")
|
||||
sentry_dev = os.environ.get("SENTRY_DEV", "").lower() in ("true", "1", "yes")
|
||||
should_enable = is_packaged or sentry_dev or force_enable
|
||||
|
||||
if not should_enable:
|
||||
logger.debug(
|
||||
"[Sentry] Development mode - error reporting disabled (set SENTRY_DEV=true to enable)"
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
import sentry_sdk
|
||||
|
||||
@@ -430,6 +430,7 @@ class WorktreeManager:
|
||||
if os.path.samefile(resolved_path, current_path):
|
||||
return line[len("branch refs/heads/") :]
|
||||
except OSError:
|
||||
# File system comparison errors are handled by fallback below
|
||||
pass
|
||||
# Fallback to normalized case comparison
|
||||
if os.path.normcase(str(resolved_path)) == os.path.normcase(
|
||||
@@ -510,6 +511,7 @@ class WorktreeManager:
|
||||
if os.path.samefile(resolved_path, registered_path):
|
||||
return True
|
||||
except OSError:
|
||||
# File system errors handled by fallback comparison below
|
||||
pass
|
||||
# Fallback to normalized case comparison for non-existent paths
|
||||
if os.path.normcase(str(resolved_path)) == os.path.normcase(
|
||||
|
||||
@@ -215,6 +215,7 @@ async def run_qa_validation_loop(
|
||||
"Removed QA_FIX_REQUEST.md after permanent fixer error",
|
||||
)
|
||||
except OSError:
|
||||
# File removal failure is not critical here
|
||||
pass
|
||||
return False
|
||||
|
||||
@@ -230,6 +231,7 @@ async def run_qa_validation_loop(
|
||||
fix_request_file.unlink()
|
||||
debug("qa_loop", "Removed processed QA_FIX_REQUEST.md")
|
||||
except OSError:
|
||||
# File removal failure is not critical here
|
||||
pass # Ignore if file removal fails
|
||||
|
||||
# Check for no-test projects
|
||||
|
||||
@@ -12,7 +12,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
@@ -22,11 +22,6 @@ except (ImportError, ValueError, SystemError):
|
||||
from file_lock import locked_json_update, locked_json_write
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
"""Return current UTC time as ISO 8601 string with timezone info."""
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
class ReviewSeverity(str, Enum):
|
||||
"""Severity levels for PR review findings."""
|
||||
|
||||
@@ -526,7 +521,7 @@ class PRReviewResult:
|
||||
summary: str = ""
|
||||
overall_status: str = "comment" # approve, request_changes, comment
|
||||
review_id: int | None = None
|
||||
reviewed_at: str = field(default_factory=lambda: _utc_now_iso())
|
||||
reviewed_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||
error: str | None = None
|
||||
|
||||
# NEW: Enhanced verdict system
|
||||
@@ -615,7 +610,7 @@ class PRReviewResult:
|
||||
summary=data.get("summary", ""),
|
||||
overall_status=data.get("overall_status", "comment"),
|
||||
review_id=data.get("review_id"),
|
||||
reviewed_at=data.get("reviewed_at", _utc_now_iso()),
|
||||
reviewed_at=data.get("reviewed_at", datetime.now().isoformat()),
|
||||
error=data.get("error"),
|
||||
# NEW fields
|
||||
verdict=MergeVerdict(data.get("verdict", "ready_to_merge")),
|
||||
@@ -696,7 +691,7 @@ class PRReviewResult:
|
||||
reviews.append(entry)
|
||||
|
||||
current_data["reviews"] = reviews
|
||||
current_data["last_updated"] = _utc_now_iso()
|
||||
current_data["last_updated"] = datetime.now().isoformat()
|
||||
|
||||
return current_data
|
||||
|
||||
@@ -767,7 +762,7 @@ class TriageResult:
|
||||
suggested_breakdown: list[str] = field(default_factory=list)
|
||||
priority: str = "medium" # high, medium, low
|
||||
comment: str | None = None
|
||||
triaged_at: str = field(default_factory=lambda: _utc_now_iso())
|
||||
triaged_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -803,7 +798,7 @@ class TriageResult:
|
||||
suggested_breakdown=data.get("suggested_breakdown", []),
|
||||
priority=data.get("priority", "medium"),
|
||||
comment=data.get("comment"),
|
||||
triaged_at=data.get("triaged_at", _utc_now_iso()),
|
||||
triaged_at=data.get("triaged_at", datetime.now().isoformat()),
|
||||
)
|
||||
|
||||
async def save(self, github_dir: Path) -> None:
|
||||
@@ -841,8 +836,8 @@ class AutoFixState:
|
||||
pr_url: str | None = None
|
||||
bot_comments: list[str] = field(default_factory=list)
|
||||
error: str | None = None
|
||||
created_at: str = field(default_factory=lambda: _utc_now_iso())
|
||||
updated_at: str = field(default_factory=lambda: _utc_now_iso())
|
||||
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||
updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -880,8 +875,8 @@ class AutoFixState:
|
||||
pr_url=data.get("pr_url"),
|
||||
bot_comments=data.get("bot_comments", []),
|
||||
error=data.get("error"),
|
||||
created_at=data.get("created_at", _utc_now_iso()),
|
||||
updated_at=data.get("updated_at", _utc_now_iso()),
|
||||
created_at=data.get("created_at", datetime.now().isoformat()),
|
||||
updated_at=data.get("updated_at", datetime.now().isoformat()),
|
||||
)
|
||||
|
||||
def update_status(self, status: AutoFixStatus) -> None:
|
||||
@@ -891,7 +886,7 @@ class AutoFixState:
|
||||
f"Invalid state transition: {self.status.value} -> {status.value}"
|
||||
)
|
||||
self.status = status
|
||||
self.updated_at = _utc_now_iso()
|
||||
self.updated_at = datetime.now().isoformat()
|
||||
|
||||
async def save(self, github_dir: Path) -> None:
|
||||
"""Save auto-fix state to .auto-claude/github/issues/ with file locking."""
|
||||
@@ -943,7 +938,7 @@ class AutoFixState:
|
||||
queue.append(entry)
|
||||
|
||||
current_data["auto_fix_queue"] = queue
|
||||
current_data["last_updated"] = _utc_now_iso()
|
||||
current_data["last_updated"] = datetime.now().isoformat()
|
||||
|
||||
return current_data
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -32,7 +33,6 @@ try:
|
||||
PRReviewResult,
|
||||
ReviewCategory,
|
||||
ReviewSeverity,
|
||||
_utc_now_iso,
|
||||
)
|
||||
from .category_utils import map_category
|
||||
from .io_utils import safe_print
|
||||
@@ -46,7 +46,6 @@ except (ImportError, ValueError, SystemError):
|
||||
PRReviewResult,
|
||||
ReviewCategory,
|
||||
ReviewSeverity,
|
||||
_utc_now_iso,
|
||||
)
|
||||
from services.category_utils import map_category
|
||||
from services.io_utils import safe_print
|
||||
@@ -266,7 +265,7 @@ class FollowupReviewer:
|
||||
verdict=verdict,
|
||||
verdict_reasoning=verdict_reasoning,
|
||||
blockers=blockers,
|
||||
reviewed_at=_utc_now_iso(),
|
||||
reviewed_at=datetime.now().isoformat(),
|
||||
# Follow-up specific fields
|
||||
reviewed_commit_sha=context.current_commit_sha,
|
||||
reviewed_file_blobs=file_blobs,
|
||||
|
||||
@@ -51,7 +51,7 @@ try:
|
||||
from .category_utils import map_category
|
||||
from .io_utils import safe_print
|
||||
from .pr_worktree_manager import PRWorktreeManager
|
||||
from .pydantic_models import FollowupExtractionResponse, ParallelFollowupResponse
|
||||
from .pydantic_models import ParallelFollowupResponse
|
||||
from .sdk_utils import process_sdk_stream
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from context_gatherer import _validate_git_ref
|
||||
@@ -75,10 +75,7 @@ except (ImportError, ValueError, SystemError):
|
||||
from services.category_utils import map_category
|
||||
from services.io_utils import safe_print
|
||||
from services.pr_worktree_manager import PRWorktreeManager
|
||||
from services.pydantic_models import (
|
||||
FollowupExtractionResponse,
|
||||
ParallelFollowupResponse,
|
||||
)
|
||||
from services.pydantic_models import ParallelFollowupResponse
|
||||
from services.sdk_utils import process_sdk_stream
|
||||
|
||||
|
||||
@@ -579,36 +576,16 @@ The SDK will run invoked agents in parallel automatically.
|
||||
)
|
||||
|
||||
# Check for stream processing errors
|
||||
stream_error = stream_result.get("error")
|
||||
if stream_error:
|
||||
if stream_result.get("error_recoverable"):
|
||||
# Recoverable error — attempt extraction call fallback
|
||||
logger.warning(
|
||||
f"[ParallelFollowup] Recoverable error: {stream_error}. "
|
||||
f"Attempting extraction call fallback."
|
||||
)
|
||||
safe_print(
|
||||
f"[ParallelFollowup] WARNING: {stream_error} — "
|
||||
f"attempting recovery with minimal extraction...",
|
||||
flush=True,
|
||||
)
|
||||
else:
|
||||
# Fatal error — raise as before
|
||||
logger.error(
|
||||
f"[ParallelFollowup] SDK stream failed: {stream_error}"
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"SDK stream processing failed: {stream_error}"
|
||||
)
|
||||
if stream_result.get("error"):
|
||||
logger.error(
|
||||
f"[ParallelFollowup] SDK stream failed: {stream_result['error']}"
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"SDK stream processing failed: {stream_result['error']}"
|
||||
)
|
||||
|
||||
result_text = stream_result["result_text"]
|
||||
last_assistant_text = stream_result.get("last_assistant_text", "")
|
||||
# Nullify structured output on recoverable errors to force Tier 2 fallback
|
||||
structured_output = (
|
||||
None
|
||||
if (stream_error and stream_result.get("error_recoverable"))
|
||||
else stream_result["structured_output"]
|
||||
)
|
||||
structured_output = stream_result["structured_output"]
|
||||
agents_invoked = stream_result["agents_invoked"]
|
||||
msg_count = stream_result["msg_count"]
|
||||
|
||||
@@ -619,28 +596,22 @@ The SDK will run invoked agents in parallel automatically.
|
||||
pr_number=context.pr_number,
|
||||
)
|
||||
|
||||
# Parse findings from output (three-tier recovery cascade)
|
||||
# Parse findings from output
|
||||
if structured_output:
|
||||
result_data = self._parse_structured_output(structured_output, context)
|
||||
else:
|
||||
# Structured output missing or validation failed.
|
||||
# Tier 2: Attempt extraction call with minimal schema
|
||||
# Log when structured output is missing - this shouldn't happen normally
|
||||
# when output_format is configured, so it indicates a problem
|
||||
logger.warning(
|
||||
"[ParallelFollowup] No structured output — attempting extraction call"
|
||||
"[ParallelFollowup] No structured output received from SDK - "
|
||||
"falling back to text parsing. Resolution data may be incomplete."
|
||||
)
|
||||
# Use last_assistant_text (cleaner) if available, fall back to full transcript
|
||||
fallback_text = last_assistant_text or result_text
|
||||
result_data = await self._attempt_extraction_call(
|
||||
fallback_text, context
|
||||
safe_print(
|
||||
"[ParallelFollowup] WARNING: Structured output not captured, "
|
||||
"using text fallback (resolution tracking may be incomplete)",
|
||||
flush=True,
|
||||
)
|
||||
if result_data is None:
|
||||
# Tier 3: Fall back to basic text parsing
|
||||
safe_print(
|
||||
"[ParallelFollowup] WARNING: Extraction call failed, "
|
||||
"using text fallback (resolution tracking may be incomplete)",
|
||||
flush=True,
|
||||
)
|
||||
result_data = self._parse_text_output(result_text, context)
|
||||
result_data = self._parse_text_output(result_text, context)
|
||||
|
||||
# Extract data
|
||||
findings = result_data.get("findings", [])
|
||||
@@ -759,9 +730,7 @@ The SDK will run invoked agents in parallel automatically.
|
||||
blockers.append(f"{finding.category.value}: {finding.title}")
|
||||
|
||||
# Extract validation counts
|
||||
dismissed_count = len(
|
||||
result_data.get("dismissed_false_positive_ids", [])
|
||||
) or result_data.get("dismissed_finding_count", 0)
|
||||
dismissed_count = len(result_data.get("dismissed_false_positive_ids", []))
|
||||
confirmed_count = result_data.get("confirmed_valid_count", 0)
|
||||
needs_human_count = result_data.get("needs_human_review_count", 0)
|
||||
|
||||
@@ -1105,129 +1074,17 @@ The SDK will run invoked agents in parallel automatically.
|
||||
elif "needs revision" in text_lower or "request changes" in text_lower:
|
||||
verdict = MergeVerdict.NEEDS_REVISION
|
||||
else:
|
||||
verdict = MergeVerdict.NEEDS_REVISION
|
||||
verdict = MergeVerdict.MERGE_WITH_CHANGES
|
||||
|
||||
return {
|
||||
"findings": findings,
|
||||
"resolved_ids": [],
|
||||
"unresolved_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": text[:500] if text else "Unable to parse response",
|
||||
"agents_invoked": [],
|
||||
}
|
||||
|
||||
async def _attempt_extraction_call(
|
||||
self, text: str, context: FollowupReviewContext
|
||||
) -> dict | None:
|
||||
"""Attempt a short SDK call with a minimal schema to recover review data.
|
||||
|
||||
This is the Tier 2 recovery step when full structured output validation fails.
|
||||
Uses FollowupExtractionResponse (~6 flat fields) which has near-100% success rate.
|
||||
|
||||
Returns parsed result dict on success, None on failure.
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
logger.warning("[ParallelFollowup] No text available for extraction call")
|
||||
return None
|
||||
|
||||
try:
|
||||
safe_print(
|
||||
"[ParallelFollowup] Attempting recovery with minimal extraction schema...",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
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",
|
||||
fast_mode=self.config.fast_mode,
|
||||
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"[ParallelFollowup] Extraction call also failed: {stream_result['error']}"
|
||||
)
|
||||
return None
|
||||
|
||||
extraction_output = stream_result.get("structured_output")
|
||||
if not extraction_output:
|
||||
logger.warning(
|
||||
"[ParallelFollowup] Extraction call returned no structured output"
|
||||
)
|
||||
return None
|
||||
|
||||
# Parse the minimal extraction response
|
||||
extracted = FollowupExtractionResponse.model_validate(extraction_output)
|
||||
|
||||
# Map verdict string to MergeVerdict enum
|
||||
verdict_map = {
|
||||
"READY_TO_MERGE": MergeVerdict.READY_TO_MERGE,
|
||||
"MERGE_WITH_CHANGES": MergeVerdict.MERGE_WITH_CHANGES,
|
||||
"NEEDS_REVISION": MergeVerdict.NEEDS_REVISION,
|
||||
"BLOCKED": MergeVerdict.BLOCKED,
|
||||
}
|
||||
verdict = verdict_map.get(extracted.verdict, MergeVerdict.NEEDS_REVISION)
|
||||
|
||||
safe_print(
|
||||
f"[ParallelFollowup] Extraction recovered: verdict={extracted.verdict}, "
|
||||
f"{len(extracted.resolved_finding_ids)} resolved, "
|
||||
f"{len(extracted.new_finding_summaries)} new findings",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
return {
|
||||
"findings": [], # Full findings not recoverable via extraction
|
||||
"resolved_ids": extracted.resolved_finding_ids,
|
||||
"unresolved_ids": extracted.unresolved_finding_ids,
|
||||
"new_finding_ids": [],
|
||||
"dismissed_false_positive_ids": [],
|
||||
"confirmed_valid_count": extracted.confirmed_finding_count,
|
||||
"dismissed_finding_count": extracted.dismissed_finding_count,
|
||||
"needs_human_review_count": 0,
|
||||
"verdict": verdict,
|
||||
"verdict_reasoning": f"[Recovered via extraction] {extracted.verdict_reasoning}",
|
||||
"agents_invoked": [],
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[ParallelFollowup] Extraction call failed: {e}")
|
||||
safe_print(
|
||||
f"[ParallelFollowup] Extraction call failed: {e}",
|
||||
flush=True,
|
||||
)
|
||||
return None
|
||||
|
||||
def _create_empty_result(self) -> dict:
|
||||
"""Create empty result structure."""
|
||||
return {
|
||||
@@ -1235,13 +1092,8 @@ The SDK will run invoked agents in parallel automatically.
|
||||
"resolved_ids": [],
|
||||
"unresolved_ids": [],
|
||||
"new_finding_ids": [],
|
||||
"dismissed_false_positive_ids": [],
|
||||
"confirmed_valid_count": 0,
|
||||
"dismissed_finding_count": 0,
|
||||
"needs_human_review_count": 0,
|
||||
"verdict": MergeVerdict.NEEDS_REVISION,
|
||||
"verdict_reasoning": "Unable to parse review results",
|
||||
"agents_invoked": [],
|
||||
}
|
||||
|
||||
def _extract_partial_data(self, data: dict) -> dict | None:
|
||||
|
||||
@@ -1785,7 +1785,6 @@ For EACH finding above:
|
||||
or "concurrency" in error_str
|
||||
or "circuit breaker" in error_str
|
||||
or "tool_use" in error_str
|
||||
or "structured_output" in error_str
|
||||
)
|
||||
|
||||
if is_retryable and attempt < MAX_VALIDATION_RETRIES:
|
||||
@@ -1806,6 +1805,7 @@ For EACH finding above:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
# Part of retry loop structure - handles retryable errors
|
||||
error_str = str(e).lower()
|
||||
is_retryable = (
|
||||
"400" in error_str
|
||||
|
||||
@@ -710,39 +710,3 @@ class FindingValidationResponse(BaseModel):
|
||||
"how many dismissed, how many need human review"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Minimal Extraction Schema (Fallback for structured output validation failure)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class FollowupExtractionResponse(BaseModel):
|
||||
"""Minimal extraction schema for recovering data when full structured output fails.
|
||||
|
||||
Deliberately kept small (~6 fields, no nesting) for near-100% validation success.
|
||||
Used as an intermediate recovery step before falling back to raw text parsing.
|
||||
"""
|
||||
|
||||
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")
|
||||
resolved_finding_ids: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="IDs of previous findings that are now resolved",
|
||||
)
|
||||
unresolved_finding_ids: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="IDs of previous findings that remain unresolved",
|
||||
)
|
||||
new_finding_summaries: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="One-line summary of each new finding (e.g. 'HIGH: cleanup deletes QA-rejected specs in batch_commands.py')",
|
||||
)
|
||||
confirmed_finding_count: int = Field(
|
||||
0, description="Number of findings confirmed as valid"
|
||||
)
|
||||
dismissed_finding_count: int = Field(
|
||||
0, description="Number of findings dismissed as false positives"
|
||||
)
|
||||
|
||||
@@ -133,13 +133,6 @@ def _get_tool_detail(tool_name: str, tool_input: dict[str, Any]) -> str:
|
||||
# Prevents runaway retry loops from consuming unbounded resources
|
||||
MAX_MESSAGE_COUNT = 500
|
||||
|
||||
# Errors that are recoverable (callers can fall back to text parsing or retry)
|
||||
# vs fatal errors (auth failures, circuit breaker) that should propagate
|
||||
RECOVERABLE_ERRORS = {
|
||||
"structured_output_validation_failed",
|
||||
"tool_use_concurrency_error",
|
||||
}
|
||||
|
||||
# Abort after 1 consecutive repeat (2 total identical responses).
|
||||
# Low threshold catches error loops quickly (e.g., auth errors returned as AI text).
|
||||
# Normal AI responses never produce the exact same text block twice in a row.
|
||||
@@ -268,11 +261,8 @@ async def process_sdk_stream(
|
||||
- msg_count: Total message count
|
||||
- subagent_tool_ids: Mapping of tool_id -> agent_name
|
||||
- error: Error message if stream processing failed (None on success)
|
||||
- error_recoverable: Boolean indicating if the error is recoverable (fallback possible) vs fatal
|
||||
- last_assistant_text: Last non-empty assistant text block (for cleaner fallback parsing)
|
||||
"""
|
||||
result_text = ""
|
||||
last_assistant_text = "" # Last assistant text block (for cleaner fallback parsing)
|
||||
structured_output = None
|
||||
agents_invoked = []
|
||||
msg_count = 0
|
||||
@@ -491,9 +481,6 @@ async def process_sdk_stream(
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
result_text += block.text
|
||||
# Track last non-empty text for fallback parsing
|
||||
if block.text.strip():
|
||||
last_assistant_text = block.text
|
||||
# Check for auth/access error returned as AI response text.
|
||||
# Note: break exits this inner for-loop over msg.content;
|
||||
# the outer message loop exits via `if stream_error: break`.
|
||||
@@ -660,16 +647,11 @@ async def process_sdk_stream(
|
||||
f"[{context_name}] Tool use concurrency error detected - caller should retry"
|
||||
)
|
||||
|
||||
# Categorize error as recoverable (fallback possible) vs fatal
|
||||
error_recoverable = stream_error in RECOVERABLE_ERRORS if stream_error else False
|
||||
|
||||
return {
|
||||
"result_text": result_text,
|
||||
"last_assistant_text": last_assistant_text,
|
||||
"structured_output": structured_output,
|
||||
"agents_invoked": agents_invoked,
|
||||
"msg_count": msg_count,
|
||||
"subagent_tool_ids": subagent_tool_ids,
|
||||
"error": stream_error,
|
||||
"error_recoverable": error_recoverable,
|
||||
}
|
||||
|
||||
@@ -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.3",
|
||||
"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",
|
||||
|
||||
@@ -1825,6 +1825,7 @@ function updateLinuxFileCredentials(
|
||||
}
|
||||
|
||||
// Write to file with secure permissions (0600)
|
||||
// lgtm[js/http-to-file-access] - credentialsPath is from controlled configDir
|
||||
writeFileSync(credentialsPath, credentialsJson, { mode: 0o600, encoding: 'utf-8' });
|
||||
|
||||
if (isDebug) {
|
||||
@@ -2086,6 +2087,7 @@ function updateWindowsFileCredentials(
|
||||
const tempPath = `${credentialsPath}.${Date.now()}.tmp`;
|
||||
try {
|
||||
// Write to temp file
|
||||
// lgtm[js/http-to-file-access] - credentialsPath is from controlled configDir
|
||||
writeFileSync(tempPath, credentialsJson, { encoding: 'utf-8' });
|
||||
|
||||
// Restrict temp file permissions to current user only (mimics Unix 0600)
|
||||
|
||||
@@ -36,8 +36,6 @@ import {
|
||||
buildRunnerArgs,
|
||||
} from "./utils/subprocess-runner";
|
||||
import { getPRStatusPoller } from "../../services/pr-status-poller";
|
||||
import { safeBreadcrumb, safeCaptureException } from "../../sentry";
|
||||
import { sanitizeForSentry } from "../../../shared/utils/sentry-privacy";
|
||||
import type {
|
||||
StartPollingRequest,
|
||||
StopPollingRequest,
|
||||
@@ -112,6 +110,7 @@ async function githubGraphQL<T>(
|
||||
query: string,
|
||||
variables: Record<string, unknown> = {}
|
||||
): Promise<T> {
|
||||
// lgtm[js/file-access-to-http] - Official GitHub GraphQL API endpoint
|
||||
const response = await fetch("https://api.github.com/graphql", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -1464,20 +1463,6 @@ async function runPRReview(
|
||||
|
||||
debugLog("Spawning PR review process", { args, model, thinkingLevel });
|
||||
|
||||
safeBreadcrumb({
|
||||
category: 'pr-review',
|
||||
message: 'Spawning PR review subprocess',
|
||||
level: 'info',
|
||||
data: {
|
||||
pythonPath: getPythonPath(backendPath),
|
||||
runnerPath: getRunnerPath(backendPath),
|
||||
cwd: backendPath,
|
||||
model,
|
||||
thinkingLevel,
|
||||
prNumber,
|
||||
},
|
||||
});
|
||||
|
||||
// Create log collector for this review
|
||||
const config = getGitHubConfig(project);
|
||||
const repo = config?.repo || project.name || "unknown";
|
||||
@@ -1541,22 +1526,9 @@ async function runPRReview(
|
||||
// Wait for the process to complete
|
||||
const result = await promise;
|
||||
|
||||
safeBreadcrumb({
|
||||
category: 'pr-review',
|
||||
message: `PR review subprocess exited`,
|
||||
level: result.success ? 'info' : 'error',
|
||||
data: { exitCode: result.exitCode, success: result.success, prNumber },
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
// Finalize logs with failure
|
||||
logCollector.finalize(false);
|
||||
|
||||
safeCaptureException(
|
||||
new Error(`PR review subprocess failed: ${result.error ?? 'unknown error'}`),
|
||||
{ extra: { exitCode: result.exitCode, prNumber, stderr: sanitizeForSentry(result.stderr.slice(0, 500)) } }
|
||||
);
|
||||
|
||||
throw new Error(result.error ?? "Review failed");
|
||||
}
|
||||
|
||||
@@ -2936,20 +2908,6 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
|
||||
|
||||
debugLog("Spawning follow-up review process", { args, model, thinkingLevel });
|
||||
|
||||
safeBreadcrumb({
|
||||
category: 'pr-review',
|
||||
message: 'Spawning follow-up PR review subprocess',
|
||||
level: 'info',
|
||||
data: {
|
||||
pythonPath: getPythonPath(backendPath),
|
||||
runnerPath: getRunnerPath(backendPath),
|
||||
cwd: backendPath,
|
||||
model,
|
||||
thinkingLevel,
|
||||
prNumber,
|
||||
},
|
||||
});
|
||||
|
||||
// Create log collector for this follow-up review (config already declared above)
|
||||
const repo = config?.repo || project.name || "unknown";
|
||||
const logCollector = new PRLogCollector(project, prNumber, repo, true, mainWindow);
|
||||
@@ -3007,22 +2965,9 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
|
||||
|
||||
const result = await promise;
|
||||
|
||||
safeBreadcrumb({
|
||||
category: 'pr-review',
|
||||
message: 'Follow-up PR review subprocess exited',
|
||||
level: result.success ? 'info' : 'error',
|
||||
data: { exitCode: result.exitCode, success: result.success, prNumber },
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
// Finalize logs with failure
|
||||
logCollector.finalize(false);
|
||||
|
||||
safeCaptureException(
|
||||
new Error(`Follow-up PR review subprocess failed: ${result.error ?? 'unknown error'}`),
|
||||
{ extra: { exitCode: result.exitCode, prNumber, stderr: sanitizeForSentry(result.stderr.slice(0, 500)) } }
|
||||
);
|
||||
|
||||
throw new Error(result.error ?? "Follow-up review failed");
|
||||
}
|
||||
|
||||
|
||||
@@ -137,6 +137,7 @@ export async function createSpecForIssue(
|
||||
status: 'pending',
|
||||
phases: []
|
||||
};
|
||||
// lgtm[js/http-to-file-access] - specDir is controlled, slugifiedTitle sanitizes input
|
||||
writeFileSync(
|
||||
path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN),
|
||||
JSON.stringify(implementationPlan, null, 2),
|
||||
@@ -149,6 +150,7 @@ export async function createSpecForIssue(
|
||||
workflow_type: 'feature'
|
||||
};
|
||||
writeFileSync(
|
||||
// lgtm[js/http-to-file-access] - specDir is controlled, slugifiedTitle sanitizes input
|
||||
path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS),
|
||||
JSON.stringify(requirements, null, 2),
|
||||
'utf-8'
|
||||
@@ -168,6 +170,7 @@ export async function createSpecForIssue(
|
||||
...(baseBranch && { baseBranch })
|
||||
};
|
||||
writeFileSync(
|
||||
// lgtm[js/http-to-file-access] - specDir is controlled, slugifiedTitle sanitizes input
|
||||
path.join(specDir, 'task_metadata.json'),
|
||||
JSON.stringify(metadata, null, 2),
|
||||
'utf-8'
|
||||
|
||||
@@ -3,7 +3,6 @@ import { getAPIProfileEnv } from '../../../services/profile';
|
||||
import { getBestAvailableProfileEnv } from '../../../rate-limit-detector';
|
||||
import { pythonEnvManager } from '../../../python-env-manager';
|
||||
import { getGitHubTokenForSubprocess } from '../utils';
|
||||
import { getSentryEnvForSubprocess } from '../../../sentry';
|
||||
|
||||
/**
|
||||
* Get environment variables for Python runner subprocesses.
|
||||
@@ -49,7 +48,6 @@ export async function getRunnerEnv(
|
||||
...oauthModeClearVars,
|
||||
...profileEnv, // OAuth token from profile manager (fixes #563, rate-limit aware)
|
||||
...githubEnv, // Fresh GitHub token from gh CLI (fixes #151)
|
||||
...getSentryEnvForSubprocess(), // Sentry DSN + sample rates for Python subprocess
|
||||
...extraEnv, // extraEnv last so callers can still override
|
||||
...extraEnv,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import { isWindows, isMacOS } from '../../../platform';
|
||||
import { getEffectiveSourcePath } from '../../../updater/path-resolver';
|
||||
import { pythonEnvManager, getConfiguredPythonPath } from '../../../python-env-manager';
|
||||
import { getTaskkillExePath, getWhereExePath } from '../../../utils/windows-paths';
|
||||
import { safeCaptureException } from '../../../sentry';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -215,17 +214,6 @@ export function runPythonSubprocess<T = unknown>(
|
||||
let killedDueToAuthFailure = false; // Track if subprocess was killed due to auth failure
|
||||
let billingFailureEmitted = false; // Track if we've already emitted a billing failure
|
||||
let killedDueToBillingFailure = false; // Track if subprocess was killed due to billing failure
|
||||
let receivedOutput = false; // Track if any stdout/stderr has been received
|
||||
|
||||
// Health-check: report to Sentry if no output received within 120 seconds
|
||||
const healthCheckTimeout = setTimeout(() => {
|
||||
if (!receivedOutput) {
|
||||
safeCaptureException(
|
||||
new Error('[SubprocessRunner] No output received from subprocess after 120s'),
|
||||
{ extra: { pythonPath: options.pythonPath, args: options.args, cwd: options.cwd, envKeys: options.env ? Object.keys(options.env) : [] } }
|
||||
);
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
// Default progress pattern: [ 30%] message OR [30%] message
|
||||
const progressPattern = options.progressPattern ?? /\[\s*(\d+)%\]\s*(.+)/;
|
||||
@@ -349,7 +337,6 @@ export function runPythonSubprocess<T = unknown>(
|
||||
};
|
||||
|
||||
child.stdout.on('data', (data: Buffer) => {
|
||||
receivedOutput = true;
|
||||
const text = data.toString('utf-8');
|
||||
stdout += text;
|
||||
|
||||
@@ -377,7 +364,6 @@ export function runPythonSubprocess<T = unknown>(
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data: Buffer) => {
|
||||
receivedOutput = true;
|
||||
const text = data.toString('utf-8');
|
||||
stderr += text;
|
||||
|
||||
@@ -396,7 +382,6 @@ export function runPythonSubprocess<T = unknown>(
|
||||
});
|
||||
|
||||
child.on('close', (code: number | null) => {
|
||||
clearTimeout(healthCheckTimeout);
|
||||
// Treat null exit code (killed with SIGKILL) as failure, not success
|
||||
const exitCode = code ?? -1;
|
||||
|
||||
@@ -476,7 +461,6 @@ export function runPythonSubprocess<T = unknown>(
|
||||
});
|
||||
|
||||
child.on('error', (err: Error) => {
|
||||
clearTimeout(healthCheckTimeout);
|
||||
options.onError?.(err.message);
|
||||
resolve({
|
||||
success: false,
|
||||
|
||||
@@ -8,8 +8,8 @@ import { IPC_CHANNELS } from '../../../shared/constants';
|
||||
import type { GitLabInvestigationStatus, GitLabInvestigationResult } from '../../../shared/types';
|
||||
import { projectStore } from '../../project-store';
|
||||
import { getGitLabConfig, gitlabFetch, encodeProjectPath } from './utils';
|
||||
import type { GitLabAPIIssue, GitLabAPINote } from './types';
|
||||
import { buildIssueContext, createSpecForIssue } from './spec-utils';
|
||||
import type { GitLabAPIIssue, GitLabNoteBasic } from './types';
|
||||
import { createSpecForIssue } from './spec-utils';
|
||||
import type { AgentManager } from '../../agent';
|
||||
|
||||
// Debug logging helper
|
||||
@@ -109,16 +109,88 @@ export function registerInvestigateIssue(
|
||||
`/projects/${encodedProject}/issues/${issueIid}`
|
||||
) as GitLabAPIIssue;
|
||||
|
||||
// Fetch notes if any selected
|
||||
let selectedNotes: GitLabAPINote[] = [];
|
||||
// Fetch notes if any selected (with pagination to get all notes)
|
||||
let filteredNotes: GitLabNoteBasic[] = [];
|
||||
if (selectedNoteIds && selectedNoteIds.length > 0) {
|
||||
const allNotes = await gitlabFetch(
|
||||
config.token,
|
||||
config.instanceUrl,
|
||||
`/projects/${encodedProject}/issues/${issueIid}/notes`
|
||||
) as GitLabAPINote[];
|
||||
// Fetch all notes with pagination (GitLab defaults to 20 per page)
|
||||
const allNotes: GitLabNoteBasic[] = [];
|
||||
let page = 1;
|
||||
const perPage = 100;
|
||||
const MAX_PAGES = 50; // Safety limit: max 5000 notes
|
||||
let hasMore = true;
|
||||
|
||||
selectedNotes = allNotes.filter(note => selectedNoteIds.includes(note.id));
|
||||
while (hasMore && page <= MAX_PAGES) {
|
||||
try {
|
||||
const notesPage = await gitlabFetch(
|
||||
config.token,
|
||||
config.instanceUrl,
|
||||
`/projects/${encodedProject}/issues/${issueIid}/notes?page=${page}&per_page=${perPage}`
|
||||
) as unknown[];
|
||||
|
||||
// Runtime validation: ensure we got an array
|
||||
if (!Array.isArray(notesPage)) {
|
||||
debugLog('GitLab notes API returned non-array, stopping pagination');
|
||||
break;
|
||||
}
|
||||
|
||||
if (notesPage.length === 0) {
|
||||
hasMore = false;
|
||||
} else {
|
||||
// Extract only needed fields with null-safe defaults
|
||||
const noteSummaries: GitLabNoteBasic[] = notesPage
|
||||
.filter((note: unknown): note is Record<string, unknown> =>
|
||||
note !== null && typeof note === 'object' && typeof (note as Record<string, unknown>).id === 'number'
|
||||
)
|
||||
.map((note) => {
|
||||
// Validate author structure defensively
|
||||
const author = note.author;
|
||||
const username = (author !== null && typeof author === 'object' && typeof (author as Record<string, unknown>).username === 'string')
|
||||
? (author as Record<string, unknown>).username as string
|
||||
: 'unknown';
|
||||
return {
|
||||
id: note.id as number,
|
||||
body: (note.body as string | undefined) || '',
|
||||
author: { username },
|
||||
};
|
||||
});
|
||||
allNotes.push(...noteSummaries);
|
||||
if (notesPage.length < perPage) {
|
||||
hasMore = false;
|
||||
} else {
|
||||
page++;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Check for authentication/rate-limit errors - these should be surfaced
|
||||
const isAuthError = errorMessage.includes('401') || errorMessage.includes('403');
|
||||
const isRateLimited = errorMessage.includes('429');
|
||||
|
||||
if (isAuthError || isRateLimited) {
|
||||
// Re-throw critical errors to let the outer handler surface them to the user
|
||||
console.warn(`[GitLab Investigation] ${isAuthError ? 'Authentication' : 'Rate limit'} error during notes fetch`, { page, error: errorMessage });
|
||||
throw error;
|
||||
}
|
||||
|
||||
// For transient errors on page 1, warn the user but continue
|
||||
if (page === 1 && allNotes.length === 0) {
|
||||
console.warn('[GitLab Investigation] Failed to fetch any notes, proceeding without notes context', { error: errorMessage });
|
||||
} else {
|
||||
// Log pagination failure for subsequent pages
|
||||
debugLog('Failed to fetch notes page, using partial notes', { page, error: errorMessage, notesRetrieved: allNotes.length });
|
||||
}
|
||||
hasMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Warn if we hit the pagination limit
|
||||
if (page > MAX_PAGES && hasMore) {
|
||||
debugLog('Pagination limit reached, some notes may be missing', { maxPages: MAX_PAGES, notesRetrieved: allNotes.length });
|
||||
}
|
||||
|
||||
// Filter notes based on selection
|
||||
filteredNotes = allNotes.filter(note => selectedNoteIds.includes(note.id));
|
||||
}
|
||||
|
||||
// Phase 2: Analyzing
|
||||
@@ -129,21 +201,6 @@ export function registerInvestigateIssue(
|
||||
message: 'Analyzing issue with AI...'
|
||||
});
|
||||
|
||||
// Note: Context building previously done here has been moved to createSpecForIssue utility.
|
||||
// The buildIssueContext() function and selectedNotes processing are now handled internally
|
||||
// by the spec creation pipeline. This avoids duplicate context generation.
|
||||
// TODO: If advanced context customization is needed in the future, consider extracting
|
||||
// context building into a reusable utility function.
|
||||
|
||||
// Use agent manager to investigate
|
||||
// Note: This is a simplified version - full implementation would use Claude SDK
|
||||
sendProgress(getMainWindow, project.id, {
|
||||
phase: 'analyzing',
|
||||
issueIid,
|
||||
progress: 50,
|
||||
message: 'AI analyzing the issue...'
|
||||
});
|
||||
|
||||
// Phase 3: Creating task
|
||||
sendProgress(getMainWindow, project.id, {
|
||||
phase: 'creating_task',
|
||||
@@ -152,8 +209,14 @@ export function registerInvestigateIssue(
|
||||
message: 'Creating task from analysis...'
|
||||
});
|
||||
|
||||
// Create spec for the issue
|
||||
const task = await createSpecForIssue(project, issue, config, project.settings?.mainBranch);
|
||||
// Create spec for the issue with notes
|
||||
const task = await createSpecForIssue(
|
||||
project,
|
||||
issue,
|
||||
config,
|
||||
project.settings?.mainBranch,
|
||||
filteredNotes
|
||||
);
|
||||
|
||||
if (!task) {
|
||||
sendError(getMainWindow, project.id, 'Failed to create task from issue');
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { mkdir, writeFile, readFile, stat } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import type { Project } from '../../../shared/types';
|
||||
import type { GitLabAPIIssue, GitLabConfig } from './types';
|
||||
import type { GitLabAPIIssue, GitLabNoteBasic, GitLabConfig } from './types';
|
||||
import { labelMatchesWholeWord } from '../shared/label-utils';
|
||||
import { sanitizeText, sanitizeStringArray } from '../shared/sanitize';
|
||||
|
||||
@@ -208,7 +208,12 @@ function generateSpecDirName(issueIid: number, title: string): string {
|
||||
/**
|
||||
* Build issue context for spec creation
|
||||
*/
|
||||
export function buildIssueContext(issue: IssueLike, projectPath: string, instanceUrl: string): string {
|
||||
export function buildIssueContext(
|
||||
issue: IssueLike,
|
||||
projectPath: string,
|
||||
instanceUrl: string,
|
||||
notes?: GitLabNoteBasic[]
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
const safeProjectPath = sanitizeText(projectPath, 200);
|
||||
const safeIssue = sanitizeIssueForSpec(issue, instanceUrl);
|
||||
@@ -238,6 +243,19 @@ export function buildIssueContext(issue: IssueLike, projectPath: string, instanc
|
||||
lines.push('');
|
||||
lines.push(`**Web URL:** ${safeIssue.web_url}`);
|
||||
|
||||
// Add notes section if notes are provided
|
||||
if (notes && notes.length > 0) {
|
||||
lines.push('');
|
||||
lines.push(`## Notes (${notes.length})`);
|
||||
lines.push('');
|
||||
for (const note of notes) {
|
||||
const safeAuthor = sanitizeText(note.author?.username || 'unknown', 100);
|
||||
const safeBody = sanitizeText(note.body, 20000, true);
|
||||
lines.push(`**${safeAuthor}:** ${safeBody}`);
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -260,7 +278,8 @@ export async function createSpecForIssue(
|
||||
project: Project,
|
||||
issue: GitLabAPIIssue,
|
||||
config: GitLabConfig,
|
||||
baseBranch?: string
|
||||
baseBranch?: string,
|
||||
notes?: GitLabNoteBasic[]
|
||||
): Promise<GitLabTaskInfo | null> {
|
||||
try {
|
||||
// Validate and sanitize network data before writing to disk
|
||||
@@ -319,8 +338,8 @@ export async function createSpecForIssue(
|
||||
// Create spec directory
|
||||
await mkdir(specDir, { recursive: true });
|
||||
|
||||
// Create TASK.md with issue context
|
||||
const taskContent = buildIssueContext(safeIssue, safeProject, config.instanceUrl);
|
||||
// Create TASK.md with issue context (including selected notes)
|
||||
const taskContent = buildIssueContext(safeIssue, safeProject, safeInstanceUrl, notes);
|
||||
await writeFile(path.join(specDir, 'TASK.md'), taskContent, 'utf-8');
|
||||
|
||||
// Create metadata.json (legacy format for GitLab-specific data)
|
||||
|
||||
@@ -420,6 +420,7 @@ export function registerTriageHandlers(
|
||||
}
|
||||
|
||||
// Save result
|
||||
// lgtm[js/http-to-file-access] - triageDir from controlled project path, issue_iid is numeric
|
||||
fs.writeFileSync(
|
||||
path.join(triageDir, `triage_${sanitizedResult.issue_iid}.json`),
|
||||
JSON.stringify(sanitizedResult, null, 2),
|
||||
|
||||
@@ -51,6 +51,13 @@ export interface GitLabAPINote {
|
||||
system: boolean;
|
||||
}
|
||||
|
||||
// Basic note type with only fields needed by investigation handlers
|
||||
export interface GitLabNoteBasic {
|
||||
id: number;
|
||||
body: string;
|
||||
author: { username: string };
|
||||
}
|
||||
|
||||
export interface GitLabAPIMergeRequest {
|
||||
id: number;
|
||||
iid: number;
|
||||
|
||||
@@ -507,6 +507,7 @@ ${safeDescription || 'No description provided.'}
|
||||
status: 'pending',
|
||||
phases: []
|
||||
};
|
||||
// lgtm[js/http-to-file-access] - specDir is controlled, Linear data sanitized
|
||||
writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN), JSON.stringify(implementationPlan, null, 2), 'utf-8');
|
||||
|
||||
// Create requirements.json
|
||||
@@ -514,6 +515,7 @@ ${safeDescription || 'No description provided.'}
|
||||
task_description: description,
|
||||
workflow_type: 'feature'
|
||||
};
|
||||
// lgtm[js/http-to-file-access] - specDir is controlled, Linear data sanitized
|
||||
writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS), JSON.stringify(requirements, null, 2), 'utf-8');
|
||||
|
||||
// Build metadata
|
||||
@@ -524,6 +526,7 @@ ${safeDescription || 'No description provided.'}
|
||||
linearUrl: safeUrl,
|
||||
category: 'feature'
|
||||
};
|
||||
// lgtm[js/http-to-file-access] - specDir is controlled, Linear data sanitized
|
||||
writeFileSync(path.join(specDir, 'task_metadata.json'), JSON.stringify(metadata, null, 2), 'utf-8');
|
||||
|
||||
// Start spec creation with the existing spec directory
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ipcMain, app } from 'electron';
|
||||
import { existsSync, } from 'fs';
|
||||
import path from 'path';
|
||||
import { ipcMain } from 'electron';
|
||||
import { existsSync } from 'fs';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
import type {
|
||||
|
||||
@@ -753,8 +753,6 @@ if sys.version_info >= (3, 12):
|
||||
...windowsEnv,
|
||||
// Don't write bytecode - not needed and avoids permission issues
|
||||
PYTHONDONTWRITEBYTECODE: '1',
|
||||
// Force unbuffered stdout/stderr so progress updates reach Electron immediately
|
||||
PYTHONUNBUFFERED: '1',
|
||||
// Use UTF-8 encoding
|
||||
PYTHONIOENCODING: 'utf-8',
|
||||
PYTHONUTF8: '1',
|
||||
|
||||
@@ -222,5 +222,7 @@ export function getSentryEnvForSubprocess(): Record<string, string> {
|
||||
SENTRY_DSN: dsn,
|
||||
SENTRY_TRACES_SAMPLE_RATE: String(getTracesSampleRate()),
|
||||
SENTRY_PROFILES_SAMPLE_RATE: String(getProfilesSampleRate()),
|
||||
// Pass SENTRY_DEV so Python backend also enables Sentry in dev mode
|
||||
...(process.env.SENTRY_DEV ? { SENTRY_DEV: process.env.SENTRY_DEV } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -174,8 +174,6 @@ export function GitHubIssues({ onOpenSettings, onNavigateToTask }: GitHubIssuesP
|
||||
onSelectIssue={selectIssue}
|
||||
onInvestigate={handleInvestigate}
|
||||
onLoadMore={!isSearchActive ? handleLoadMore : undefined}
|
||||
onRetry={handleRefresh}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
-371
@@ -1,371 +0,0 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Key,
|
||||
Shield,
|
||||
WifiOff,
|
||||
SearchX,
|
||||
RefreshCw,
|
||||
Settings2,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Card, CardContent } from '../../ui/card';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { parseGitHubError } from '../utils/github-error-parser';
|
||||
import type { GitHubErrorInfo, GitHubErrorType } from '../types';
|
||||
|
||||
/**
|
||||
* Props for the GitHubErrorDisplay component.
|
||||
*/
|
||||
export interface GitHubErrorDisplayProps {
|
||||
/** Raw error string or pre-parsed GitHubErrorInfo */
|
||||
error: string | GitHubErrorInfo | null;
|
||||
/** Callback when user clicks retry button */
|
||||
onRetry?: () => void;
|
||||
/** Callback when user clicks settings button */
|
||||
onOpenSettings?: () => void;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
/** Whether to show as compact inline error (vs full-width card) */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for each error type: icon, color, title key.
|
||||
*/
|
||||
const ERROR_CONFIG: Record<
|
||||
GitHubErrorType,
|
||||
{
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
titleKey: string;
|
||||
iconColorClass: string;
|
||||
}
|
||||
> = {
|
||||
rate_limit: {
|
||||
icon: Clock,
|
||||
titleKey: 'githubErrors.rateLimitTitle',
|
||||
iconColorClass: 'text-warning',
|
||||
},
|
||||
auth: {
|
||||
icon: Key,
|
||||
titleKey: 'githubErrors.authTitle',
|
||||
iconColorClass: 'text-destructive',
|
||||
},
|
||||
permission: {
|
||||
icon: Shield,
|
||||
titleKey: 'githubErrors.permissionTitle',
|
||||
iconColorClass: 'text-destructive',
|
||||
},
|
||||
not_found: {
|
||||
icon: SearchX,
|
||||
titleKey: 'githubErrors.notFoundTitle',
|
||||
iconColorClass: 'text-muted-foreground',
|
||||
},
|
||||
network: {
|
||||
icon: WifiOff,
|
||||
titleKey: 'githubErrors.networkTitle',
|
||||
iconColorClass: 'text-warning',
|
||||
},
|
||||
unknown: {
|
||||
icon: AlertTriangle,
|
||||
titleKey: 'githubErrors.unknownTitle',
|
||||
iconColorClass: 'text-destructive',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Base message keys for each error type.
|
||||
* Hoisted to module scope to avoid recreation on every function call.
|
||||
*/
|
||||
const BASE_MESSAGE_KEYS: Record<GitHubErrorType, string> = {
|
||||
rate_limit: 'githubErrors.rateLimitMessage',
|
||||
auth: 'githubErrors.authMessage',
|
||||
permission: 'githubErrors.permissionMessage',
|
||||
not_found: 'githubErrors.notFoundMessage',
|
||||
network: 'githubErrors.networkMessage',
|
||||
unknown: 'githubErrors.unknownMessage',
|
||||
};
|
||||
|
||||
/**
|
||||
* Countdown time components for i18n-friendly formatting.
|
||||
*/
|
||||
interface CountdownComponents {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate countdown time components from reset time.
|
||||
* Returns numeric values for i18n-friendly formatting in the component.
|
||||
*/
|
||||
function getCountdownComponents(resetTime: Date): CountdownComponents | null {
|
||||
const now = new Date();
|
||||
const diffMs = resetTime.getTime() - now.getTime();
|
||||
|
||||
if (diffMs <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const diffSecs = Math.floor(diffMs / 1000);
|
||||
const diffMins = Math.floor(diffSecs / 60);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
|
||||
return {
|
||||
hours: diffHours,
|
||||
minutes: diffHours > 0 ? diffMins % 60 : diffMins,
|
||||
seconds: diffSecs % 60,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the most specific message key based on available metadata.
|
||||
* Pure function extracted to module scope to avoid recreation on each render.
|
||||
* @param info - The error info object
|
||||
* @param rateLimitDiffMs - Pre-computed time difference in milliseconds (avoids dual calculation)
|
||||
*/
|
||||
function getMessageKey(info: GitHubErrorInfo, rateLimitDiffMs?: number): string {
|
||||
if (info.type === 'rate_limit' && rateLimitDiffMs !== undefined && rateLimitDiffMs > 0) {
|
||||
const diffMins = Math.ceil(rateLimitDiffMs / 60000);
|
||||
return diffMins >= 60
|
||||
? 'githubErrors.rateLimitMessageHours'
|
||||
: 'githubErrors.rateLimitMessageMinutes';
|
||||
}
|
||||
if (info.type === 'permission' && info.requiredScopes && info.requiredScopes.length > 0) {
|
||||
return 'githubErrors.permissionMessageScopes';
|
||||
}
|
||||
return BASE_MESSAGE_KEYS[info.type];
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that displays GitHub API errors with appropriate icons,
|
||||
* messages, and action buttons based on error type.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // With raw error string
|
||||
* <GitHubErrorDisplay
|
||||
* error="GitHub API error: 403 - Rate limit exceeded"
|
||||
* onRetry={handleRetry}
|
||||
* />
|
||||
*
|
||||
* // With pre-parsed error info
|
||||
* <GitHubErrorDisplay
|
||||
* error={errorInfo}
|
||||
* onOpenSettings={handleOpenSettings}
|
||||
* compact
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function GitHubErrorDisplay({
|
||||
error,
|
||||
onRetry,
|
||||
onOpenSettings,
|
||||
className,
|
||||
compact = false,
|
||||
}: GitHubErrorDisplayProps) {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
// Parse error if it's a string, otherwise use the provided GitHubErrorInfo
|
||||
// Memoize to prevent useEffect churn from new Date references on each render
|
||||
const errorInfo: GitHubErrorInfo = useMemo(
|
||||
() =>
|
||||
typeof error === 'string' || error === null
|
||||
? parseGitHubError(error)
|
||||
: error,
|
||||
[error]
|
||||
);
|
||||
|
||||
// State for rate limit countdown components
|
||||
const [countdownComponents, setCountdownComponents] = useState<CountdownComponents | null>(() =>
|
||||
errorInfo.rateLimitResetTime
|
||||
? getCountdownComponents(errorInfo.rateLimitResetTime)
|
||||
: null
|
||||
);
|
||||
|
||||
// Update countdown every second for rate limit errors
|
||||
// Extract timestamp for stable useEffect dependency (avoids optional chaining in deps)
|
||||
const resetTimeMs = errorInfo.rateLimitResetTime?.getTime();
|
||||
|
||||
useEffect(() => {
|
||||
if (errorInfo.type !== 'rate_limit' || !errorInfo.rateLimitResetTime) {
|
||||
// Clear stale countdown state when error type changes away from rate_limit
|
||||
setCountdownComponents(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const resetTime = errorInfo.rateLimitResetTime;
|
||||
let intervalId: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
const updateCountdown = () => {
|
||||
const components = getCountdownComponents(resetTime);
|
||||
setCountdownComponents(components);
|
||||
// Stop the interval when countdown expires
|
||||
if (!components && intervalId) {
|
||||
clearInterval(intervalId);
|
||||
intervalId = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Update immediately
|
||||
updateCountdown();
|
||||
|
||||
// Only set interval if countdown is still active
|
||||
if (getCountdownComponents(resetTime)) {
|
||||
intervalId = setInterval(updateCountdown, 1000);
|
||||
}
|
||||
|
||||
// Cleanup on unmount or when error changes
|
||||
return () => {
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
};
|
||||
}, [errorInfo.type, resetTimeMs]);
|
||||
|
||||
// Format countdown using i18n
|
||||
const formatCountdownDisplay = (components: CountdownComponents | null): string => {
|
||||
if (!components) return '';
|
||||
if (components.hours > 0) {
|
||||
return t('githubErrors.countdownHoursMinutes', {
|
||||
hours: components.hours,
|
||||
minutes: components.minutes,
|
||||
});
|
||||
}
|
||||
return t('githubErrors.countdownMinutesSeconds', {
|
||||
minutes: components.minutes,
|
||||
seconds: components.seconds,
|
||||
});
|
||||
};
|
||||
|
||||
// Get configuration for this error type
|
||||
const config = ERROR_CONFIG[errorInfo.type];
|
||||
const Icon = config.icon;
|
||||
|
||||
// Determine which actions to show
|
||||
const showRetry = ['rate_limit', 'network', 'unknown'].includes(errorInfo.type);
|
||||
const showSettings = ['auth', 'permission'].includes(errorInfo.type);
|
||||
const isRateLimitExpired =
|
||||
errorInfo.type === 'rate_limit' &&
|
||||
errorInfo.rateLimitResetTime &&
|
||||
new Date() >= errorInfo.rateLimitResetTime;
|
||||
|
||||
// Don't render if no error
|
||||
if (!error) return null;
|
||||
|
||||
// Compute time remaining once for both message key selection and translation
|
||||
const rateLimitDiffMs = errorInfo.rateLimitResetTime
|
||||
? errorInfo.rateLimitResetTime.getTime() - Date.now()
|
||||
: undefined;
|
||||
|
||||
// Get the translated message with appropriate interpolation values
|
||||
const messageKey = getMessageKey(errorInfo, rateLimitDiffMs);
|
||||
// Only pass positive minutes/hours values to avoid stale negative/zero values
|
||||
const rawMinutes = rateLimitDiffMs ? Math.ceil(rateLimitDiffMs / 60000) : undefined;
|
||||
const minutes = rawMinutes && rawMinutes > 0 ? rawMinutes : undefined;
|
||||
const hours = minutes ? Math.ceil(minutes / 60) : undefined;
|
||||
|
||||
const errorMessage = t(messageKey, {
|
||||
defaultValue: errorInfo.message,
|
||||
minutes,
|
||||
hours,
|
||||
scopes: errorInfo.requiredScopes?.join(', '),
|
||||
});
|
||||
|
||||
// Compact variant for inline display
|
||||
if (compact) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
aria-label={errorMessage}
|
||||
className={cn(
|
||||
'flex items-center gap-2 p-3 rounded-lg bg-muted/50 border border-border',
|
||||
className
|
||||
)}
|
||||
title={errorMessage}
|
||||
>
|
||||
<Icon className={cn('h-4 w-4 shrink-0', config.iconColorClass)} />
|
||||
<span className="text-sm text-muted-foreground flex-1 truncate">
|
||||
{t(config.titleKey)}
|
||||
</span>
|
||||
{showRetry && onRetry && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRetry}
|
||||
className="h-7 px-2"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
{t('buttons.retry')}
|
||||
</Button>
|
||||
)}
|
||||
{showSettings && onOpenSettings && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onOpenSettings}
|
||||
className="h-7 px-2"
|
||||
>
|
||||
<Settings2 className="h-3 w-3 mr-1" />
|
||||
{t('actions.settings')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Full card variant for blocking errors
|
||||
return (
|
||||
<Card role="alert" className={cn('border-destructive/50 m-4', className)}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-muted/50 flex items-center justify-center">
|
||||
<Icon className={cn('h-6 w-6', config.iconColorClass)} />
|
||||
</div>
|
||||
<div className="space-y-2 max-w-md">
|
||||
<h3 className="font-semibold text-lg text-foreground">
|
||||
{t(config.titleKey)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">{errorMessage}</p>
|
||||
{/* Rate limit countdown display */}
|
||||
{errorInfo.type === 'rate_limit' && countdownComponents && (
|
||||
<p className="text-xs text-warning font-medium">
|
||||
{t('githubErrors.resetsIn', { time: formatCountdownDisplay(countdownComponents) })}
|
||||
</p>
|
||||
)}
|
||||
{/* Rate limit expired - show retry prompt */}
|
||||
{isRateLimitExpired && (
|
||||
<p className="text-xs text-primary">
|
||||
{t('githubErrors.rateLimitExpired')}
|
||||
</p>
|
||||
)}
|
||||
{/* Required scopes for permission errors */}
|
||||
{errorInfo.requiredScopes && errorInfo.requiredScopes.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('githubErrors.requiredScopes')}:{' '}
|
||||
<code className="bg-muted px-1 rounded">
|
||||
{errorInfo.requiredScopes.join(', ')}
|
||||
</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2">
|
||||
{showRetry && onRetry && (
|
||||
<Button onClick={onRetry} variant="outline" size="sm">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
{t('buttons.retry')}
|
||||
</Button>
|
||||
)}
|
||||
{showSettings && onOpenSettings && (
|
||||
<Button onClick={onOpenSettings} variant="outline" size="sm">
|
||||
<Settings2 className="h-4 w-4 mr-2" />
|
||||
{t('actions.settings')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useRef, useEffect, useCallback, useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Loader2, AlertCircle } from 'lucide-react';
|
||||
import { ScrollArea } from '../../ui/scroll-area';
|
||||
import { IssueListItem } from './IssueListItem';
|
||||
import { EmptyState } from './EmptyStates';
|
||||
import { GitHubErrorDisplay } from './GitHubErrorDisplay';
|
||||
import type { IssueListProps } from '../types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -16,9 +15,7 @@ export function IssueList({
|
||||
error,
|
||||
onSelectIssue,
|
||||
onInvestigate,
|
||||
onLoadMore,
|
||||
onRetry,
|
||||
onOpenSettings
|
||||
onLoadMore
|
||||
}: IssueListProps) {
|
||||
const { t } = useTranslation('common');
|
||||
const loadMoreTriggerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -53,12 +50,12 @@ export function IssueList({
|
||||
// Load-more errors are shown inline near the load-more trigger
|
||||
if (error && issues.length === 0) {
|
||||
return (
|
||||
<GitHubErrorDisplay
|
||||
error={error}
|
||||
onRetry={onRetry}
|
||||
onOpenSettings={onOpenSettings}
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="p-4 bg-destructive/10 border-b border-destructive/30">
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,18 +85,15 @@ export function IssueList({
|
||||
))}
|
||||
|
||||
{/* Load more trigger / Loading indicator */}
|
||||
{/* Inline error for load-more failures (visible even when onLoadMore is undefined during search) */}
|
||||
{error && issues.length > 0 && (
|
||||
<GitHubErrorDisplay
|
||||
error={error}
|
||||
onRetry={onRetry}
|
||||
onOpenSettings={onOpenSettings}
|
||||
compact
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
{onLoadMore && (
|
||||
<div ref={loadMoreTriggerRef} className="py-4 flex flex-col items-center gap-2">
|
||||
{/* Inline error for load-more failures (when issues are already loaded) */}
|
||||
{error && issues.length > 0 && (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{isLoadingMore ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
|
||||
-500
@@ -1,500 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
/**
|
||||
* Unit tests for GitHubErrorDisplay component.
|
||||
* Tests error display, icon rendering, button visibility, and countdown functionality.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { GitHubErrorDisplay } from '../GitHubErrorDisplay';
|
||||
import type { GitHubErrorInfo } from '../../types';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
const translations: Record<string, string> = {
|
||||
'githubErrors.rateLimitTitle': 'GitHub Rate Limit Reached',
|
||||
'githubErrors.authTitle': 'GitHub Authentication Required',
|
||||
'githubErrors.permissionTitle': 'GitHub Permission Denied',
|
||||
'githubErrors.notFoundTitle': 'GitHub Resource Not Found',
|
||||
'githubErrors.networkTitle': 'GitHub Connection Error',
|
||||
'githubErrors.unknownTitle': 'GitHub Error',
|
||||
'githubErrors.rateLimitMessage': 'GitHub API rate limit reached. Please wait a moment before trying again.',
|
||||
'githubErrors.rateLimitMessageMinutes': `GitHub API rate limit reached. Please wait ${options?.minutes ?? 'X'} minute(s) before trying again.`,
|
||||
'githubErrors.rateLimitMessageHours': `GitHub API rate limit reached. Rate limit resets in approximately ${options?.hours ?? 'X'} hour(s).`,
|
||||
'githubErrors.authMessage': 'GitHub authentication failed. Please check your GitHub token in Settings.',
|
||||
'githubErrors.permissionMessage': 'GitHub permission denied. Your token may not have the required access.',
|
||||
'githubErrors.permissionMessageScopes': `GitHub permission denied. Your token is missing required scopes: ${options?.scopes ?? ''}. Please update your GitHub token in Settings.`,
|
||||
'githubErrors.notFoundMessage': 'The requested GitHub resource was not found.',
|
||||
'githubErrors.networkMessage': 'Unable to connect to GitHub. Please check your internet connection.',
|
||||
'githubErrors.unknownMessage': 'An unexpected error occurred while communicating with GitHub.',
|
||||
'githubErrors.resetsIn': options?.time ? `Resets in ${options.time as string}` : 'Resets in',
|
||||
'githubErrors.countdownHoursMinutes': `${options?.hours ?? 0}h ${options?.minutes ?? 0}m`,
|
||||
'githubErrors.countdownMinutesSeconds': `${options?.minutes ?? 0}m ${options?.seconds ?? 0}s`,
|
||||
'githubErrors.rateLimitExpired': 'Rate limit has reset. You can retry now.',
|
||||
'githubErrors.requiredScopes': 'Required scopes',
|
||||
'buttons.retry': 'Retry',
|
||||
'actions.settings': 'Settings',
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Helper to create mock GitHubErrorInfo
|
||||
function createMockErrorInfo(
|
||||
type: GitHubErrorInfo['type'],
|
||||
overrides: Partial<GitHubErrorInfo> = {}
|
||||
): GitHubErrorInfo {
|
||||
const defaults: Record<string, GitHubErrorInfo> = {
|
||||
rate_limit: {
|
||||
type: 'rate_limit',
|
||||
message: 'GitHub API rate limit reached. Please wait a moment before trying again.',
|
||||
statusCode: 403,
|
||||
},
|
||||
auth: {
|
||||
type: 'auth',
|
||||
message: 'GitHub authentication failed. Please check your GitHub token in Settings.',
|
||||
statusCode: 401,
|
||||
},
|
||||
permission: {
|
||||
type: 'permission',
|
||||
message: 'GitHub permission denied. Your token may not have the required access.',
|
||||
statusCode: 403,
|
||||
},
|
||||
not_found: {
|
||||
type: 'not_found',
|
||||
message: 'The requested GitHub resource was not found.',
|
||||
statusCode: 404,
|
||||
},
|
||||
network: {
|
||||
type: 'network',
|
||||
message: 'Unable to connect to GitHub. Please check your internet connection.',
|
||||
},
|
||||
unknown: {
|
||||
type: 'unknown',
|
||||
message: 'An unexpected error occurred while communicating with GitHub.',
|
||||
},
|
||||
};
|
||||
|
||||
return { ...defaults[type], ...overrides };
|
||||
}
|
||||
|
||||
describe('GitHubErrorDisplay', () => {
|
||||
describe('rendering null/empty states', () => {
|
||||
it('should render nothing when error is null', () => {
|
||||
const { container } = render(<GitHubErrorDisplay error={null} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('should render nothing when error is an empty string', () => {
|
||||
// Empty string is falsy, so component should return null
|
||||
const { container } = render(
|
||||
<GitHubErrorDisplay error={'' as string} />
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rendering with string error', () => {
|
||||
it('should render error display when error is a string', () => {
|
||||
render(<GitHubErrorDisplay error="401 Unauthorized" />);
|
||||
|
||||
// Should show the auth title (parsed from the error)
|
||||
expect(screen.getByText('GitHub Authentication Required')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render error display for rate limit string error', () => {
|
||||
render(<GitHubErrorDisplay error="rate limit exceeded" />);
|
||||
|
||||
expect(screen.getByText('GitHub Rate Limit Reached')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rendering with GitHubErrorInfo object', () => {
|
||||
it('should render rate_limit error correctly', () => {
|
||||
const errorInfo = createMockErrorInfo('rate_limit');
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
expect(screen.getByText('GitHub Rate Limit Reached')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/GitHub API rate limit reached/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render auth error correctly', () => {
|
||||
const errorInfo = createMockErrorInfo('auth');
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
expect(screen.getByText('GitHub Authentication Required')).toBeInTheDocument();
|
||||
expect(screen.getByText(/authentication failed/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render permission error correctly', () => {
|
||||
const errorInfo = createMockErrorInfo('permission', {
|
||||
requiredScopes: ['repo', 'workflow'],
|
||||
});
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
expect(screen.getByText('GitHub Permission Denied')).toBeInTheDocument();
|
||||
// Check that permission message is rendered
|
||||
expect(screen.getByText(/Your token is missing required scopes/)).toBeInTheDocument();
|
||||
// Should show required scopes in the code element
|
||||
expect(screen.getByText('repo, workflow')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render not_found error correctly', () => {
|
||||
const errorInfo = createMockErrorInfo('not_found');
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
expect(screen.getByText('GitHub Resource Not Found')).toBeInTheDocument();
|
||||
expect(screen.getByText(/not found/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render network error correctly', () => {
|
||||
const errorInfo = createMockErrorInfo('network');
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
expect(screen.getByText('GitHub Connection Error')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Unable to connect/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render unknown error correctly', () => {
|
||||
const errorInfo = createMockErrorInfo('unknown');
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
expect(screen.getByText('GitHub Error')).toBeInTheDocument();
|
||||
expect(screen.getByText(/unexpected error/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('compact mode', () => {
|
||||
it('should render compact variant when compact=true', () => {
|
||||
const errorInfo = createMockErrorInfo('rate_limit');
|
||||
render(<GitHubErrorDisplay error={errorInfo} compact />);
|
||||
|
||||
// In compact mode, the title is in a smaller span
|
||||
expect(screen.getByText('GitHub Rate Limit Reached')).toBeInTheDocument();
|
||||
// Should not render the card structure (no centered layout)
|
||||
expect(screen.queryByRole('heading', { level: 3 })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show retry button in compact mode for rate_limit errors', () => {
|
||||
const errorInfo = createMockErrorInfo('rate_limit');
|
||||
const onRetry = vi.fn();
|
||||
render(<GitHubErrorDisplay error={errorInfo} compact onRetry={onRetry} />);
|
||||
|
||||
const retryButton = screen.getByRole('button', { name: /retry/i });
|
||||
expect(retryButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(retryButton);
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should show settings button in compact mode for auth errors', () => {
|
||||
const errorInfo = createMockErrorInfo('auth');
|
||||
const onOpenSettings = vi.fn();
|
||||
render(<GitHubErrorDisplay error={errorInfo} compact onOpenSettings={onOpenSettings} />);
|
||||
|
||||
const settingsButton = screen.getByRole('button', { name: /settings/i });
|
||||
expect(settingsButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(settingsButton);
|
||||
expect(onOpenSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('full card mode (default)', () => {
|
||||
it('should render card structure by default', () => {
|
||||
const errorInfo = createMockErrorInfo('rate_limit');
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
// Should render heading
|
||||
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show retry button for rate_limit errors with onRetry callback', () => {
|
||||
const errorInfo = createMockErrorInfo('rate_limit');
|
||||
const onRetry = vi.fn();
|
||||
render(<GitHubErrorDisplay error={errorInfo} onRetry={onRetry} />);
|
||||
|
||||
const retryButton = screen.getByRole('button', { name: /retry/i });
|
||||
expect(retryButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(retryButton);
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should show retry button for network errors', () => {
|
||||
const errorInfo = createMockErrorInfo('network');
|
||||
const onRetry = vi.fn();
|
||||
render(<GitHubErrorDisplay error={errorInfo} onRetry={onRetry} />);
|
||||
|
||||
const retryButton = screen.getByRole('button', { name: /retry/i });
|
||||
expect(retryButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show retry button for unknown errors', () => {
|
||||
const errorInfo = createMockErrorInfo('unknown');
|
||||
const onRetry = vi.fn();
|
||||
render(<GitHubErrorDisplay error={errorInfo} onRetry={onRetry} />);
|
||||
|
||||
const retryButton = screen.getByRole('button', { name: /retry/i });
|
||||
expect(retryButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should NOT show retry button for auth errors', () => {
|
||||
const errorInfo = createMockErrorInfo('auth');
|
||||
const onRetry = vi.fn();
|
||||
render(<GitHubErrorDisplay error={errorInfo} onRetry={onRetry} />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: /retry/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should NOT show retry button for permission errors', () => {
|
||||
const errorInfo = createMockErrorInfo('permission');
|
||||
const onRetry = vi.fn();
|
||||
render(<GitHubErrorDisplay error={errorInfo} onRetry={onRetry} />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: /retry/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should NOT show retry button for not_found errors', () => {
|
||||
const errorInfo = createMockErrorInfo('not_found');
|
||||
const onRetry = vi.fn();
|
||||
render(<GitHubErrorDisplay error={errorInfo} onRetry={onRetry} />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: /retry/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show settings button for auth errors with onOpenSettings callback', () => {
|
||||
const errorInfo = createMockErrorInfo('auth');
|
||||
const onOpenSettings = vi.fn();
|
||||
render(<GitHubErrorDisplay error={errorInfo} onOpenSettings={onOpenSettings} />);
|
||||
|
||||
const settingsButton = screen.getByRole('button', { name: /settings/i });
|
||||
expect(settingsButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(settingsButton);
|
||||
expect(onOpenSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should show settings button for permission errors', () => {
|
||||
const errorInfo = createMockErrorInfo('permission');
|
||||
const onOpenSettings = vi.fn();
|
||||
render(<GitHubErrorDisplay error={errorInfo} onOpenSettings={onOpenSettings} />);
|
||||
|
||||
const settingsButton = screen.getByRole('button', { name: /settings/i });
|
||||
expect(settingsButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should NOT show settings button for rate_limit errors', () => {
|
||||
const errorInfo = createMockErrorInfo('rate_limit');
|
||||
const onOpenSettings = vi.fn();
|
||||
render(<GitHubErrorDisplay error={errorInfo} onOpenSettings={onOpenSettings} />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: /settings/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should NOT show settings button for network errors', () => {
|
||||
const errorInfo = createMockErrorInfo('network');
|
||||
const onOpenSettings = vi.fn();
|
||||
render(<GitHubErrorDisplay error={errorInfo} onOpenSettings={onOpenSettings} />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: /settings/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rate limit countdown', () => {
|
||||
it('should display countdown for rate limit errors with reset time', () => {
|
||||
// Set reset time 5 minutes in the future
|
||||
const resetTime = new Date(Date.now() + 5 * 60 * 1000);
|
||||
const errorInfo = createMockErrorInfo('rate_limit', {
|
||||
rateLimitResetTime: resetTime,
|
||||
});
|
||||
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
// Should show countdown in "Xm Ys" format (e.g., "4m 59s" or "5m 0s")
|
||||
expect(screen.getByText(/Resets in \d+m \d+s/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should set up interval to update countdown', () => {
|
||||
vi.useFakeTimers();
|
||||
const resetTime = new Date(Date.now() + 2 * 60 * 1000);
|
||||
const errorInfo = createMockErrorInfo('rate_limit', {
|
||||
rateLimitResetTime: resetTime,
|
||||
});
|
||||
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
// Initial countdown should be displayed
|
||||
expect(screen.getByText(/Resets in/)).toBeInTheDocument();
|
||||
|
||||
// Verify interval is running by checking timers
|
||||
const timerCount = vi.getTimerCount();
|
||||
expect(timerCount).toBe(1); // One interval should be running
|
||||
|
||||
// Advance time and verify interval still fires
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(screen.getByText(/Resets in/)).toBeInTheDocument();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should NOT show countdown for non-rate-limit errors', () => {
|
||||
const errorInfo = createMockErrorInfo('auth');
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
expect(screen.queryByText(/Resets in/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show rate limit expired message when reset time has passed', () => {
|
||||
// Set reset time in the past
|
||||
const resetTime = new Date(Date.now() - 1000);
|
||||
const errorInfo = createMockErrorInfo('rate_limit', {
|
||||
rateLimitResetTime: resetTime,
|
||||
});
|
||||
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
expect(
|
||||
screen.getByText('Rate limit has reset. You can retry now.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should cleanup interval on unmount', () => {
|
||||
vi.useFakeTimers();
|
||||
const clearIntervalSpy = vi.spyOn(global, 'clearInterval');
|
||||
|
||||
const resetTime = new Date(Date.now() + 5 * 60 * 1000);
|
||||
const errorInfo = createMockErrorInfo('rate_limit', {
|
||||
rateLimitResetTime: resetTime,
|
||||
});
|
||||
|
||||
const { unmount } = render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
// Verify the countdown was rendered
|
||||
expect(screen.getByText(/Resets in/)).toBeInTheDocument();
|
||||
|
||||
// Unmount and verify clearInterval was called
|
||||
unmount();
|
||||
expect(clearIntervalSpy).toHaveBeenCalled();
|
||||
|
||||
clearIntervalSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('required scopes display', () => {
|
||||
it('should display required scopes for permission errors', () => {
|
||||
const errorInfo = createMockErrorInfo('permission', {
|
||||
requiredScopes: ['repo', 'read:org', 'workflow'],
|
||||
});
|
||||
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
expect(screen.getByText('Required scopes:')).toBeInTheDocument();
|
||||
// The scopes appear in a code element
|
||||
expect(screen.getByText('repo, read:org, workflow')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should NOT display scopes section when no scopes are provided', () => {
|
||||
const errorInfo = createMockErrorInfo('permission', {
|
||||
requiredScopes: undefined,
|
||||
});
|
||||
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
expect(screen.queryByText('Required scopes:')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should NOT display scopes section when scopes array is empty', () => {
|
||||
const errorInfo = createMockErrorInfo('permission', {
|
||||
requiredScopes: [],
|
||||
});
|
||||
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
expect(screen.queryByText('Required scopes:')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('className prop', () => {
|
||||
it('should apply custom className in full card mode', () => {
|
||||
const errorInfo = createMockErrorInfo('rate_limit');
|
||||
const { container } = render(
|
||||
<GitHubErrorDisplay error={errorInfo} className="custom-class" />
|
||||
);
|
||||
|
||||
expect(container.firstChild).toHaveClass('custom-class');
|
||||
});
|
||||
|
||||
it('should apply custom className in compact mode', () => {
|
||||
const errorInfo = createMockErrorInfo('rate_limit');
|
||||
const { container } = render(
|
||||
<GitHubErrorDisplay error={errorInfo} compact className="custom-compact-class" />
|
||||
);
|
||||
|
||||
expect(container.firstChild).toHaveClass('custom-compact-class');
|
||||
});
|
||||
});
|
||||
|
||||
describe('callback stability', () => {
|
||||
it('should not call onRetry on initial render', () => {
|
||||
const errorInfo = createMockErrorInfo('rate_limit');
|
||||
const onRetry = vi.fn();
|
||||
|
||||
render(<GitHubErrorDisplay error={errorInfo} onRetry={onRetry} />);
|
||||
|
||||
expect(onRetry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call onOpenSettings on initial render', () => {
|
||||
const errorInfo = createMockErrorInfo('auth');
|
||||
const onOpenSettings = vi.fn();
|
||||
|
||||
render(<GitHubErrorDisplay error={errorInfo} onOpenSettings={onOpenSettings} />);
|
||||
|
||||
expect(onOpenSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('should have role="alert" for screen reader announcements', () => {
|
||||
const errorInfo = createMockErrorInfo('rate_limit');
|
||||
render(<GitHubErrorDisplay error={errorInfo} />);
|
||||
|
||||
// The error card should have role="alert" for accessibility
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have role="alert" in compact mode', () => {
|
||||
const errorInfo = createMockErrorInfo('network');
|
||||
render(<GitHubErrorDisplay error={errorInfo} compact />);
|
||||
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have accessible button labels', () => {
|
||||
const errorInfo = createMockErrorInfo('rate_limit');
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function -- callback not needed for this test
|
||||
render(<GitHubErrorDisplay error={errorInfo} onRetry={() => { /* no-op */ }} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /retry/i });
|
||||
expect(button).toHaveTextContent('Retry');
|
||||
});
|
||||
|
||||
it('should have accessible settings button label', () => {
|
||||
const errorInfo = createMockErrorInfo('auth');
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function -- callback not needed for this test
|
||||
render(<GitHubErrorDisplay error={errorInfo} onOpenSettings={() => { /* no-op */ }} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /settings/i });
|
||||
expect(button).toHaveTextContent('Settings');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,4 +6,3 @@ export { IssueListHeader } from './IssueListHeader';
|
||||
export { IssueList } from './IssueList';
|
||||
export { AutoFixButton } from './AutoFixButton';
|
||||
export { BatchReviewWizard } from './BatchReviewWizard';
|
||||
export { GitHubErrorDisplay } from './GitHubErrorDisplay';
|
||||
|
||||
@@ -3,47 +3,6 @@ import type { AutoFixConfig, AutoFixQueueItem } from '../../../../preload/api/mo
|
||||
|
||||
export type FilterState = 'open' | 'closed' | 'all';
|
||||
|
||||
/**
|
||||
* Classification types for GitHub API errors.
|
||||
* Used to determine appropriate icon, message, and actions for error display.
|
||||
*/
|
||||
export type GitHubErrorType =
|
||||
| 'rate_limit'
|
||||
| 'auth'
|
||||
| 'permission'
|
||||
| 'network'
|
||||
| 'not_found'
|
||||
| 'unknown';
|
||||
|
||||
/**
|
||||
* Parsed GitHub error information with metadata.
|
||||
* Returned by the github-error-parser utility.
|
||||
*
|
||||
* IMPORTANT: The `message` field contains hardcoded English strings intended
|
||||
* ONLY as a fallback defaultValue for i18n translation. Direct consumers should
|
||||
* use the `type` field to look up the appropriate translation key (e.g.,
|
||||
* 'githubErrors.rateLimitMessage') via react-i18next rather than displaying
|
||||
* `message` directly. This ensures proper localization for all users.
|
||||
*/
|
||||
export interface GitHubErrorInfo {
|
||||
/** The classified error type */
|
||||
type: GitHubErrorType;
|
||||
/**
|
||||
* User-friendly error message in English.
|
||||
* NOTE: Use only as defaultValue for i18n - do not display directly.
|
||||
* Use type field to look up translation key (e.g., 'githubErrors.rateLimitMessage').
|
||||
*/
|
||||
message: string;
|
||||
/** Original raw error string (for debugging/details) */
|
||||
rawMessage?: string;
|
||||
/** Rate limit reset time (only for rate_limit type) */
|
||||
rateLimitResetTime?: Date;
|
||||
/** Required OAuth scopes that are missing (only for permission type) */
|
||||
requiredScopes?: string[];
|
||||
/** HTTP status code if available */
|
||||
statusCode?: number;
|
||||
}
|
||||
|
||||
export interface GitHubIssuesProps {
|
||||
onOpenSettings?: () => void;
|
||||
/** Navigate to view a task in the kanban board */
|
||||
@@ -117,10 +76,6 @@ export interface IssueListProps {
|
||||
onSelectIssue: (issueNumber: number) => void;
|
||||
onInvestigate: (issue: GitHubIssue) => void;
|
||||
onLoadMore?: () => void;
|
||||
/** Callback for retry button in error display */
|
||||
onRetry?: () => void;
|
||||
/** Callback for settings button in error display */
|
||||
onOpenSettings?: () => void;
|
||||
}
|
||||
|
||||
export interface EmptyStateProps {
|
||||
|
||||
-691
@@ -1,691 +0,0 @@
|
||||
/**
|
||||
* Unit tests for GitHub API error parser utility.
|
||||
* Tests error classification, metadata extraction, and helper functions.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseGitHubError,
|
||||
isRateLimitError,
|
||||
isAuthError,
|
||||
isNetworkError,
|
||||
isRecoverableError,
|
||||
requiresSettingsAction,
|
||||
} from '../github-error-parser';
|
||||
import type { GitHubErrorType } from '../../types';
|
||||
|
||||
describe('parseGitHubError', () => {
|
||||
describe('null/undefined/empty handling', () => {
|
||||
it('should return unknown for null input', () => {
|
||||
const result = parseGitHubError(null);
|
||||
expect(result.type).toBe('unknown');
|
||||
expect(result.message).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return unknown for undefined input', () => {
|
||||
const result = parseGitHubError(undefined);
|
||||
expect(result.type).toBe('unknown');
|
||||
expect(result.message).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return unknown for empty string', () => {
|
||||
const result = parseGitHubError('');
|
||||
expect(result.type).toBe('unknown');
|
||||
expect(result.message).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return unknown for whitespace-only string', () => {
|
||||
const result = parseGitHubError(' ');
|
||||
expect(result.type).toBe('unknown');
|
||||
expect(result.message).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rate_limit errors', () => {
|
||||
it('should detect "rate limit exceeded" pattern', () => {
|
||||
const result = parseGitHubError('GitHub API error: rate limit exceeded');
|
||||
expect(result.type).toBe('rate_limit');
|
||||
expect(result.message).toContain('rate limit');
|
||||
expect(result.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('should detect "API rate limit exceeded" pattern', () => {
|
||||
const result = parseGitHubError('API rate limit exceeded for user');
|
||||
expect(result.type).toBe('rate_limit');
|
||||
});
|
||||
|
||||
it('should detect "too many requests" pattern', () => {
|
||||
const result = parseGitHubError('Error: too many requests');
|
||||
expect(result.type).toBe('rate_limit');
|
||||
});
|
||||
|
||||
it('should detect "403 rate limit" pattern', () => {
|
||||
const result = parseGitHubError('403 rate limit reached');
|
||||
expect(result.type).toBe('rate_limit');
|
||||
expect(result.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('should detect "abuse rate limit" pattern', () => {
|
||||
const result = parseGitHubError('Abuse rate limit triggered');
|
||||
expect(result.type).toBe('rate_limit');
|
||||
});
|
||||
|
||||
it('should detect "secondary rate limit" pattern', () => {
|
||||
const result = parseGitHubError('Secondary rate limit exceeded');
|
||||
expect(result.type).toBe('rate_limit');
|
||||
});
|
||||
|
||||
it('should extract rate limit reset time from ISO date format', () => {
|
||||
const result = parseGitHubError('rate limit exceeded, resets at 2024-01-15T12:00:00Z');
|
||||
expect(result.type).toBe('rate_limit');
|
||||
expect(result.rateLimitResetTime).toBeInstanceOf(Date);
|
||||
expect(result.rateLimitResetTime?.getUTCFullYear()).toBe(2024);
|
||||
});
|
||||
|
||||
it('should extract rate limit reset time from Unix timestamp', () => {
|
||||
const result = parseGitHubError('X-RateLimit-Reset: 1705312800');
|
||||
expect(result.type).toBe('rate_limit');
|
||||
expect(result.rateLimitResetTime).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should generate user-friendly message with time remaining', () => {
|
||||
// Create a date 5 minutes in the future
|
||||
const futureDate = new Date(Date.now() + 5 * 60 * 1000);
|
||||
const isoString = futureDate.toISOString();
|
||||
const result = parseGitHubError(`rate limit exceeded, resets at ${isoString}`);
|
||||
expect(result.type).toBe('rate_limit');
|
||||
expect(result.message).toContain('rate limit');
|
||||
});
|
||||
|
||||
it('should generate fallback message when reset time has passed', () => {
|
||||
// Create a date in the past
|
||||
const pastDate = new Date(Date.now() - 5 * 60 * 1000);
|
||||
const isoString = pastDate.toISOString();
|
||||
const result = parseGitHubError(`rate limit exceeded, resets at ${isoString}`);
|
||||
expect(result.type).toBe('rate_limit');
|
||||
expect(result.message).toContain('moment');
|
||||
});
|
||||
|
||||
it('should include raw message truncated to MAX_RAW_ERROR_LENGTH', () => {
|
||||
const longError = 'rate limit exceeded ' + 'x'.repeat(600);
|
||||
const result = parseGitHubError(longError);
|
||||
expect(result.type).toBe('rate_limit');
|
||||
expect(result.rawMessage).toBeDefined();
|
||||
expect(result.rawMessage?.length).toBeLessThanOrEqual(503); // 500 + '...'
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth errors', () => {
|
||||
it('should detect "401" pattern', () => {
|
||||
const result = parseGitHubError('HTTP 401 Unauthorized');
|
||||
expect(result.type).toBe('auth');
|
||||
expect(result.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('should detect "unauthorized" pattern', () => {
|
||||
const result = parseGitHubError('Error: unauthorized access');
|
||||
expect(result.type).toBe('auth');
|
||||
});
|
||||
|
||||
it('should detect "bad credentials" pattern', () => {
|
||||
const result = parseGitHubError('Bad credentials');
|
||||
expect(result.type).toBe('auth');
|
||||
});
|
||||
|
||||
it('should detect "authentication failed" pattern', () => {
|
||||
const result = parseGitHubError('Authentication failed');
|
||||
expect(result.type).toBe('auth');
|
||||
});
|
||||
|
||||
it('should detect "invalid token" pattern', () => {
|
||||
const result = parseGitHubError('Invalid token provided');
|
||||
expect(result.type).toBe('auth');
|
||||
});
|
||||
|
||||
it('should detect "token expired" pattern', () => {
|
||||
const result = parseGitHubError('Token expired');
|
||||
expect(result.type).toBe('auth');
|
||||
});
|
||||
|
||||
it('should detect "not authenticated" pattern', () => {
|
||||
const result = parseGitHubError('Not authenticated');
|
||||
expect(result.type).toBe('auth');
|
||||
});
|
||||
|
||||
it('should generate user-friendly message mentioning Settings', () => {
|
||||
const result = parseGitHubError('401 Unauthorized');
|
||||
expect(result.message).toContain('authentication');
|
||||
expect(result.message).toContain('Settings');
|
||||
});
|
||||
});
|
||||
|
||||
describe('not_found errors', () => {
|
||||
it('should detect "404" pattern', () => {
|
||||
const result = parseGitHubError('HTTP 404 Not Found');
|
||||
expect(result.type).toBe('not_found');
|
||||
expect(result.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('should detect "not found" pattern', () => {
|
||||
const result = parseGitHubError('Repository not found');
|
||||
expect(result.type).toBe('not_found');
|
||||
});
|
||||
|
||||
it('should detect "no such repository" pattern', () => {
|
||||
const result = parseGitHubError('No such repository exists');
|
||||
expect(result.type).toBe('not_found');
|
||||
});
|
||||
|
||||
it('should detect "does not exist" pattern', () => {
|
||||
const result = parseGitHubError('Resource does not exist');
|
||||
expect(result.type).toBe('not_found');
|
||||
});
|
||||
|
||||
it('should detect "user not found" pattern', () => {
|
||||
const result = parseGitHubError('User not found');
|
||||
expect(result.type).toBe('not_found');
|
||||
});
|
||||
|
||||
it('should generate user-friendly message about verifying repository', () => {
|
||||
const result = parseGitHubError('404 Not Found');
|
||||
expect(result.message).toContain('not found');
|
||||
expect(result.message).toContain('verify');
|
||||
});
|
||||
});
|
||||
|
||||
describe('network errors', () => {
|
||||
it('should detect "network error" pattern', () => {
|
||||
const result = parseGitHubError('Network error');
|
||||
expect(result.type).toBe('network');
|
||||
});
|
||||
|
||||
it('should detect "failed to fetch" pattern', () => {
|
||||
const result = parseGitHubError('Failed to fetch data');
|
||||
expect(result.type).toBe('network');
|
||||
});
|
||||
|
||||
it('should detect "ECONNREFUSED" pattern', () => {
|
||||
const result = parseGitHubError('Error: ECONNREFUSED');
|
||||
expect(result.type).toBe('network');
|
||||
});
|
||||
|
||||
it('should detect "ECONNRESET" pattern', () => {
|
||||
const result = parseGitHubError('Error: ECONNRESET');
|
||||
expect(result.type).toBe('network');
|
||||
});
|
||||
|
||||
it('should detect "ETIMEDOUT" pattern', () => {
|
||||
const result = parseGitHubError('Error: ETIMEDOUT');
|
||||
expect(result.type).toBe('network');
|
||||
});
|
||||
|
||||
it('should detect "connection refused" pattern', () => {
|
||||
const result = parseGitHubError('Connection refused');
|
||||
expect(result.type).toBe('network');
|
||||
});
|
||||
|
||||
it('should detect "connection timeout" pattern', () => {
|
||||
const result = parseGitHubError('Connection timeout');
|
||||
expect(result.type).toBe('network');
|
||||
});
|
||||
|
||||
it('should detect "DNS error" pattern', () => {
|
||||
const result = parseGitHubError('DNS error occurred');
|
||||
expect(result.type).toBe('network');
|
||||
});
|
||||
|
||||
it('should detect "offline" pattern', () => {
|
||||
const result = parseGitHubError('You are offline');
|
||||
expect(result.type).toBe('network');
|
||||
});
|
||||
|
||||
it('should detect "no internet" pattern', () => {
|
||||
const result = parseGitHubError('No internet connection');
|
||||
expect(result.type).toBe('network');
|
||||
});
|
||||
|
||||
it('should generate user-friendly message about internet connection', () => {
|
||||
const result = parseGitHubError('Network error');
|
||||
expect(result.message).toContain('internet');
|
||||
});
|
||||
});
|
||||
|
||||
describe('permission errors', () => {
|
||||
it('should detect "403" pattern (without rate limit context)', () => {
|
||||
const result = parseGitHubError('HTTP 403 Forbidden');
|
||||
expect(result.type).toBe('permission');
|
||||
expect(result.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('should detect "forbidden" pattern', () => {
|
||||
const result = parseGitHubError('Access forbidden');
|
||||
expect(result.type).toBe('permission');
|
||||
});
|
||||
|
||||
it('should detect "permission denied" pattern', () => {
|
||||
const result = parseGitHubError('Permission denied');
|
||||
expect(result.type).toBe('permission');
|
||||
});
|
||||
|
||||
it('should detect "insufficient scope" pattern', () => {
|
||||
const result = parseGitHubError('Insufficient scope');
|
||||
expect(result.type).toBe('permission');
|
||||
});
|
||||
|
||||
it('should detect "access denied" pattern', () => {
|
||||
const result = parseGitHubError('Access denied');
|
||||
expect(result.type).toBe('permission');
|
||||
});
|
||||
|
||||
it('should detect "repository access denied" pattern', () => {
|
||||
const result = parseGitHubError('Repository access denied');
|
||||
expect(result.type).toBe('permission');
|
||||
});
|
||||
|
||||
it('should detect "requires admin access" pattern', () => {
|
||||
const result = parseGitHubError('Requires admin access');
|
||||
expect(result.type).toBe('permission');
|
||||
});
|
||||
|
||||
it('should detect "missing required scope" pattern', () => {
|
||||
const result = parseGitHubError('Missing required scope');
|
||||
expect(result.type).toBe('permission');
|
||||
});
|
||||
|
||||
it('should extract required scopes from error message with 403', () => {
|
||||
const result = parseGitHubError('403 Forbidden - missing scopes: repo, read:org');
|
||||
expect(result.type).toBe('permission');
|
||||
expect(result.requiredScopes).toContain('repo');
|
||||
expect(result.requiredScopes).toContain('read:org');
|
||||
});
|
||||
|
||||
it('should extract scopes from "requires:" format with 403', () => {
|
||||
const result = parseGitHubError('403 - Requires: repo, workflow');
|
||||
expect(result.type).toBe('permission');
|
||||
expect(result.requiredScopes).toContain('repo');
|
||||
expect(result.requiredScopes).toContain('workflow');
|
||||
});
|
||||
|
||||
it('should extract scopes from X-Accepted-OAuth-Scopes header with 403', () => {
|
||||
const result = parseGitHubError('403 Forbidden X-Accepted-OAuth-Scopes: repo');
|
||||
expect(result.type).toBe('permission');
|
||||
expect(result.requiredScopes).toContain('repo');
|
||||
});
|
||||
|
||||
it('should generate user-friendly message with scopes', () => {
|
||||
const result = parseGitHubError('403 Forbidden - missing scopes: repo, workflow');
|
||||
expect(result.message).toContain('repo');
|
||||
expect(result.message).toContain('workflow');
|
||||
expect(result.message).toContain('Settings');
|
||||
});
|
||||
|
||||
it('should generate user-friendly message without scopes', () => {
|
||||
const result = parseGitHubError('403 Forbidden');
|
||||
expect(result.message).toContain('permission');
|
||||
expect(result.message).toContain('Settings');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unknown errors', () => {
|
||||
it('should return unknown for unrecognized error patterns', () => {
|
||||
const result = parseGitHubError('Something unexpected happened');
|
||||
expect(result.type).toBe('unknown');
|
||||
expect(result.message).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include raw message for unknown errors', () => {
|
||||
const result = parseGitHubError('Custom error message');
|
||||
expect(result.rawMessage).toBe('Custom error message');
|
||||
});
|
||||
|
||||
it('should extract status code even for unknown errors', () => {
|
||||
const result = parseGitHubError('HTTP 500 Internal Server Error');
|
||||
expect(result.type).toBe('unknown');
|
||||
expect(result.statusCode).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error classification priority', () => {
|
||||
it('should prioritize rate_limit over permission (both 403)', () => {
|
||||
const result = parseGitHubError('403 rate limit exceeded');
|
||||
expect(result.type).toBe('rate_limit');
|
||||
});
|
||||
|
||||
it('should classify as permission when 403 without rate limit context', () => {
|
||||
const result = parseGitHubError('403 forbidden');
|
||||
expect(result.type).toBe('permission');
|
||||
});
|
||||
|
||||
it('should handle errors with multiple patterns correctly', () => {
|
||||
// Rate limit should take priority
|
||||
const result = parseGitHubError('403 API rate limit exceeded');
|
||||
expect(result.type).toBe('rate_limit');
|
||||
});
|
||||
|
||||
it('should prioritize auth over not_found when both patterns present', () => {
|
||||
// "401" should be classified as auth, not not_found
|
||||
const result = parseGitHubError('HTTP 401 Unauthorized - user not found');
|
||||
expect(result.type).toBe('auth');
|
||||
});
|
||||
|
||||
it('should prioritize auth over network when 401 appears with network context', () => {
|
||||
const result = parseGitHubError('Network error: HTTP 401');
|
||||
expect(result.type).toBe('auth');
|
||||
});
|
||||
|
||||
it('should classify as not_found when 404 without auth patterns', () => {
|
||||
const result = parseGitHubError('HTTP 404 Not Found');
|
||||
expect(result.type).toBe('not_found');
|
||||
});
|
||||
|
||||
it('should not match bare 401 in unrelated numbers', () => {
|
||||
// The word boundary should prevent matching "1401" as a 401 error
|
||||
const result = parseGitHubError('Error code 14010 occurred');
|
||||
expect(result.type).toBe('unknown');
|
||||
});
|
||||
|
||||
it('should not match bare 404 embedded in other numbers', () => {
|
||||
// The word boundary should prevent matching "404" embedded in "14040"
|
||||
const result = parseGitHubError('Error code 14040 occurred');
|
||||
expect(result.type).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle multiline error messages', () => {
|
||||
const result = parseGitHubError(`Error occurred:
|
||||
HTTP 401 Unauthorized
|
||||
Please check your credentials`);
|
||||
expect(result.type).toBe('auth');
|
||||
});
|
||||
|
||||
it('should handle case-insensitive matching', () => {
|
||||
const testCases = [
|
||||
{ input: 'RATE LIMIT EXCEEDED', expected: 'rate_limit' as GitHubErrorType },
|
||||
{ input: 'UNAUTHORIZED', expected: 'auth' as GitHubErrorType },
|
||||
{ input: 'NOT FOUND', expected: 'not_found' as GitHubErrorType },
|
||||
{ input: 'NETWORK ERROR', expected: 'network' as GitHubErrorType },
|
||||
{ input: 'FORBIDDEN', expected: 'permission' as GitHubErrorType },
|
||||
];
|
||||
|
||||
for (const { input, expected } of testCases) {
|
||||
const result = parseGitHubError(input);
|
||||
expect(result.type).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle errors with JSON content', () => {
|
||||
const result = parseGitHubError('{"message":"Bad credentials","status":401}');
|
||||
expect(result.type).toBe('auth');
|
||||
});
|
||||
|
||||
it('should handle errors with leading/trailing whitespace', () => {
|
||||
const result = parseGitHubError(' 401 Unauthorized ');
|
||||
expect(result.type).toBe('auth');
|
||||
});
|
||||
|
||||
it('should sanitize very long error messages', () => {
|
||||
const longError = 'A'.repeat(1000);
|
||||
const result = parseGitHubError(longError);
|
||||
expect(result.rawMessage?.length).toBeLessThanOrEqual(503);
|
||||
expect(result.rawMessage).toContain('...');
|
||||
});
|
||||
|
||||
it('should not include rateLimitResetTime for non-rate-limit errors', () => {
|
||||
const result = parseGitHubError('401 Unauthorized');
|
||||
expect(result.rateLimitResetTime).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not include requiredScopes for non-permission errors', () => {
|
||||
const result = parseGitHubError('401 Unauthorized');
|
||||
expect(result.requiredScopes).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRateLimitError', () => {
|
||||
it('should return true for rate limit errors', () => {
|
||||
expect(isRateLimitError('rate limit exceeded')).toBe(true);
|
||||
expect(isRateLimitError('API rate limit exceeded')).toBe(true);
|
||||
expect(isRateLimitError('too many requests')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-rate-limit errors', () => {
|
||||
expect(isRateLimitError('401 Unauthorized')).toBe(false);
|
||||
expect(isRateLimitError('404 Not Found')).toBe(false);
|
||||
expect(isRateLimitError('Network error')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for null/undefined/empty', () => {
|
||||
expect(isRateLimitError(null)).toBe(false);
|
||||
expect(isRateLimitError(undefined)).toBe(false);
|
||||
expect(isRateLimitError('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should use parsedInfo when provided', () => {
|
||||
const parsedInfo = { type: 'rate_limit' as const, message: 'test' };
|
||||
expect(isRateLimitError('unrelated error', parsedInfo)).toBe(true);
|
||||
expect(isRateLimitError(null, parsedInfo)).toBe(true);
|
||||
expect(isRateLimitError(undefined, parsedInfo)).toBe(true);
|
||||
});
|
||||
|
||||
it('should ignore parsedInfo when error type differs', () => {
|
||||
const authParsedInfo = { type: 'auth' as const, message: 'test' };
|
||||
expect(isRateLimitError('rate limit exceeded', authParsedInfo)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAuthError', () => {
|
||||
it('should return true for auth errors', () => {
|
||||
expect(isAuthError('401 Unauthorized')).toBe(true);
|
||||
expect(isAuthError('Bad credentials')).toBe(true);
|
||||
expect(isAuthError('Invalid token')).toBe(true);
|
||||
expect(isAuthError('Not authenticated')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-auth errors', () => {
|
||||
expect(isAuthError('rate limit exceeded')).toBe(false);
|
||||
expect(isAuthError('404 Not Found')).toBe(false);
|
||||
expect(isAuthError('Network error')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for null/undefined/empty', () => {
|
||||
expect(isAuthError(null)).toBe(false);
|
||||
expect(isAuthError(undefined)).toBe(false);
|
||||
expect(isAuthError('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should use parsedInfo when provided', () => {
|
||||
const parsedInfo = { type: 'auth' as const, message: 'test' };
|
||||
expect(isAuthError('unrelated error', parsedInfo)).toBe(true);
|
||||
expect(isAuthError(null, parsedInfo)).toBe(true);
|
||||
expect(isAuthError(undefined, parsedInfo)).toBe(true);
|
||||
});
|
||||
|
||||
it('should ignore parsedInfo when error type differs', () => {
|
||||
const rateLimitParsedInfo = { type: 'rate_limit' as const, message: 'test' };
|
||||
expect(isAuthError('401 Unauthorized', rateLimitParsedInfo)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isNetworkError', () => {
|
||||
it('should return true for network errors', () => {
|
||||
expect(isNetworkError('Network error')).toBe(true);
|
||||
expect(isNetworkError('Failed to fetch')).toBe(true);
|
||||
expect(isNetworkError('ECONNREFUSED')).toBe(true);
|
||||
expect(isNetworkError('Connection timeout')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-network errors', () => {
|
||||
expect(isNetworkError('401 Unauthorized')).toBe(false);
|
||||
expect(isNetworkError('rate limit exceeded')).toBe(false);
|
||||
expect(isNetworkError('404 Not Found')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for null/undefined/empty', () => {
|
||||
expect(isNetworkError(null)).toBe(false);
|
||||
expect(isNetworkError(undefined)).toBe(false);
|
||||
expect(isNetworkError('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should use parsedInfo when provided', () => {
|
||||
const parsedInfo = { type: 'network' as const, message: 'test' };
|
||||
expect(isNetworkError('unrelated error', parsedInfo)).toBe(true);
|
||||
expect(isNetworkError(null, parsedInfo)).toBe(true);
|
||||
expect(isNetworkError(undefined, parsedInfo)).toBe(true);
|
||||
});
|
||||
|
||||
it('should ignore parsedInfo when error type differs', () => {
|
||||
const authParsedInfo = { type: 'auth' as const, message: 'test' };
|
||||
expect(isNetworkError('Network error', authParsedInfo)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRecoverableError', () => {
|
||||
it('should return true for recoverable errors (rate_limit, network, unknown)', () => {
|
||||
expect(isRecoverableError('rate limit exceeded')).toBe(true);
|
||||
expect(isRecoverableError('Network error')).toBe(true);
|
||||
expect(isRecoverableError('Unknown error occurred')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-recoverable errors (auth, permission, not_found)', () => {
|
||||
expect(isRecoverableError('401 Unauthorized')).toBe(false);
|
||||
expect(isRecoverableError('403 Forbidden')).toBe(false);
|
||||
expect(isRecoverableError('404 Not Found')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for null/undefined/empty', () => {
|
||||
expect(isRecoverableError(null)).toBe(false);
|
||||
expect(isRecoverableError(undefined)).toBe(false);
|
||||
expect(isRecoverableError('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should use parsedInfo when provided', () => {
|
||||
const rateLimitInfo = { type: 'rate_limit' as const, message: 'test' };
|
||||
const networkInfo = { type: 'network' as const, message: 'test' };
|
||||
const unknownInfo = { type: 'unknown' as const, message: 'test' };
|
||||
expect(isRecoverableError('unrelated error', rateLimitInfo)).toBe(true);
|
||||
expect(isRecoverableError(null, networkInfo)).toBe(true);
|
||||
expect(isRecoverableError(undefined, unknownInfo)).toBe(true);
|
||||
});
|
||||
|
||||
it('should ignore parsedInfo when error type is non-recoverable', () => {
|
||||
const authParsedInfo = { type: 'auth' as const, message: 'test' };
|
||||
const permissionParsedInfo = { type: 'permission' as const, message: 'test' };
|
||||
const notFoundParsedInfo = { type: 'not_found' as const, message: 'test' };
|
||||
expect(isRecoverableError('Network error', authParsedInfo)).toBe(false);
|
||||
expect(isRecoverableError('rate limit exceeded', permissionParsedInfo)).toBe(false);
|
||||
expect(isRecoverableError('unknown', notFoundParsedInfo)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requiresSettingsAction', () => {
|
||||
it('should return true for errors requiring settings action (auth, permission)', () => {
|
||||
expect(requiresSettingsAction('401 Unauthorized')).toBe(true);
|
||||
expect(requiresSettingsAction('403 Forbidden')).toBe(true);
|
||||
expect(requiresSettingsAction('Invalid token')).toBe(true);
|
||||
expect(requiresSettingsAction('403 Forbidden - missing scopes: repo')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for errors not requiring settings (rate_limit, network, not_found, unknown)', () => {
|
||||
expect(requiresSettingsAction('rate limit exceeded')).toBe(false);
|
||||
expect(requiresSettingsAction('Network error')).toBe(false);
|
||||
expect(requiresSettingsAction('404 Not Found')).toBe(false);
|
||||
expect(requiresSettingsAction('Unknown error')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for null/undefined/empty', () => {
|
||||
expect(requiresSettingsAction(null)).toBe(false);
|
||||
expect(requiresSettingsAction(undefined)).toBe(false);
|
||||
expect(requiresSettingsAction('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should use parsedInfo when provided', () => {
|
||||
const authInfo = { type: 'auth' as const, message: 'test' };
|
||||
const permissionInfo = { type: 'permission' as const, message: 'test' };
|
||||
expect(requiresSettingsAction('unrelated error', authInfo)).toBe(true);
|
||||
expect(requiresSettingsAction(null, permissionInfo)).toBe(true);
|
||||
expect(requiresSettingsAction(undefined, authInfo)).toBe(true);
|
||||
});
|
||||
|
||||
it('should ignore parsedInfo when error type does not require settings', () => {
|
||||
const rateLimitInfo = { type: 'rate_limit' as const, message: 'test' };
|
||||
const networkInfo = { type: 'network' as const, message: 'test' };
|
||||
const notFoundInfo = { type: 'not_found' as const, message: 'test' };
|
||||
expect(requiresSettingsAction('401 Unauthorized', rateLimitInfo)).toBe(false);
|
||||
expect(requiresSettingsAction('403 Forbidden', networkInfo)).toBe(false);
|
||||
expect(requiresSettingsAction('invalid token', notFoundInfo)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cross-cutting concerns', () => {
|
||||
describe('consistency between parseGitHubError and helper functions', () => {
|
||||
it('should have consistent rate_limit detection', () => {
|
||||
const error = 'rate limit exceeded';
|
||||
const parsed = parseGitHubError(error);
|
||||
expect(parsed.type).toBe('rate_limit');
|
||||
expect(isRateLimitError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should have consistent auth detection', () => {
|
||||
const error = '401 Unauthorized';
|
||||
const parsed = parseGitHubError(error);
|
||||
expect(parsed.type).toBe('auth');
|
||||
expect(isAuthError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should have consistent network detection', () => {
|
||||
const error = 'Network error';
|
||||
const parsed = parseGitHubError(error);
|
||||
expect(parsed.type).toBe('network');
|
||||
expect(isNetworkError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('should have consistent recoverable classification', () => {
|
||||
const errors = ['rate limit exceeded', 'Network error', 'Unknown error'];
|
||||
for (const error of errors) {
|
||||
const parsed = parseGitHubError(error);
|
||||
expect(isRecoverableError(error)).toBe(['rate_limit', 'network', 'unknown'].includes(parsed.type));
|
||||
}
|
||||
});
|
||||
|
||||
it('should have consistent settings action classification', () => {
|
||||
const errors = ['401 Unauthorized', '403 Forbidden'];
|
||||
for (const error of errors) {
|
||||
const parsed = parseGitHubError(error);
|
||||
expect(requiresSettingsAction(error)).toBe(['auth', 'permission'].includes(parsed.type));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('statusCode extraction', () => {
|
||||
it('should extract 403 for rate_limit errors', () => {
|
||||
const result = parseGitHubError('rate limit exceeded');
|
||||
expect(result.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('should extract 401 for auth errors', () => {
|
||||
const result = parseGitHubError('Bad credentials');
|
||||
expect(result.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('should extract 404 for not_found errors', () => {
|
||||
const result = parseGitHubError('Not found');
|
||||
expect(result.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('should extract 403 for permission errors', () => {
|
||||
const result = parseGitHubError('Forbidden');
|
||||
expect(result.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('should extract status code from message when present', () => {
|
||||
const result = parseGitHubError('HTTP 429 Too Many Requests');
|
||||
expect(result.statusCode).toBe(429);
|
||||
});
|
||||
|
||||
it('should not extract invalid status codes', () => {
|
||||
const result = parseGitHubError('Error 999');
|
||||
expect(result.statusCode).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,497 +0,0 @@
|
||||
/**
|
||||
* GitHub API error parser utility.
|
||||
* Parses raw error strings to classify GitHub API errors and extract metadata.
|
||||
*/
|
||||
|
||||
import type { GitHubErrorType, GitHubErrorInfo } from '../types';
|
||||
|
||||
/**
|
||||
* Maximum length for raw error messages stored in GitHubErrorInfo.
|
||||
* Truncates to prevent memory bloat and UI issues.
|
||||
*/
|
||||
const MAX_RAW_ERROR_LENGTH = 500;
|
||||
|
||||
/**
|
||||
* Patterns for rate limit errors (HTTP 403 with rate limit context).
|
||||
* Note: Pattern 1 covers all "rate limit" variations (api rate limit exceeded,
|
||||
* abuse rate limit, secondary rate limit, etc.) via substring matching.
|
||||
*/
|
||||
const RATE_LIMIT_PATTERNS = [
|
||||
/rate\s*limit/i, // Covers all variations containing "rate limit"
|
||||
/too\s*many\s*requests/i,
|
||||
/403.*rate/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Patterns for authentication errors (HTTP 401)
|
||||
* Note: Bare status codes are intentionally omitted here - STATUS_CODE_PATTERN
|
||||
* handles HTTP-context-aware matching to avoid false positives.
|
||||
*/
|
||||
const AUTH_PATTERNS = [
|
||||
/unauthorized/i,
|
||||
/bad\s*credentials/i,
|
||||
/authentication\s*failed/i,
|
||||
/invalid\s*(oauth\s*)?token/i,
|
||||
/token\s*(is\s*)?(invalid|expired|required)/i,
|
||||
/not\s*authenticated/i,
|
||||
/requires\s*authentication/i, // GitHub 401 response body
|
||||
];
|
||||
|
||||
/**
|
||||
* Patterns for permission/scope errors (HTTP 403 with scope context)
|
||||
* Note: Bare status codes are intentionally omitted here - STATUS_CODE_PATTERN
|
||||
* handles HTTP-context-aware matching to avoid false positives.
|
||||
*/
|
||||
const PERMISSION_PATTERNS = [
|
||||
/forbidden/i,
|
||||
/permission\s*denied/i,
|
||||
/insufficient\s*(scope|permission)/i,
|
||||
/access\s*denied/i,
|
||||
/repository\s*access\s*denied/i,
|
||||
/not\s*authorized\s*to\s*access/i,
|
||||
/requires\s*(admin|write|read)\s*access/i,
|
||||
/missing\s*required\s*scope/i,
|
||||
// Matches "requires: repo" or "requires workflow" for OAuth scope context
|
||||
// Uses specific scope names to avoid matching "requires authentication" (auth error)
|
||||
/requires[:\s]+(?:repo|admin|write|read|workflow|org|gist|notification|user|project|package|delete|discussion)/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Patterns for not found errors (HTTP 404)
|
||||
* Note: Bare status codes are intentionally omitted here - STATUS_CODE_PATTERN
|
||||
* handles HTTP-context-aware matching to avoid false positives (e.g., "Issue #404").
|
||||
*/
|
||||
const NOT_FOUND_PATTERNS = [
|
||||
/not\s*found/i,
|
||||
/no\s*such\s*(repository|repo|issue|resource)/i,
|
||||
/does\s*not\s*exist/i,
|
||||
/repository\s*not\s*found/i,
|
||||
/user\s*not\s*found/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Patterns for network/connectivity errors
|
||||
*/
|
||||
const NETWORK_PATTERNS = [
|
||||
/network\s*(error|failed|unreachable)/i,
|
||||
/failed\s*to\s*fetch/i,
|
||||
/enetunreach/i,
|
||||
/econnrefused/i,
|
||||
/econnreset/i,
|
||||
/etimedout/i,
|
||||
/dns\s*(error|failed)/i,
|
||||
/offline/i,
|
||||
/no\s*internet/i,
|
||||
/unable\s*to\s*connect/i,
|
||||
/connection\s*(refused|reset|timeout|failed)/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Pattern to extract required OAuth scopes from error messages
|
||||
* Matches formats like:
|
||||
* - "requires: repo, read:org"
|
||||
* - "missing scopes: repo, workflow"
|
||||
* - "X-Accepted-OAuth-Scopes: repo"
|
||||
* Stops at sentence boundaries or non-scope characters
|
||||
*/
|
||||
const REQUIRED_SCOPES_PATTERN = /(?:requires?[:\s]*|missing\s*scopes?[:\s]*|X-Accepted-OAuth-Scopes[:\s]*)([a-z0-9_:]+(?:[,\s]+[a-z0-9_:]+)*)/i;
|
||||
|
||||
/**
|
||||
* Pattern to extract HTTP status code from error messages.
|
||||
* Matches status codes preceded by HTTP context keywords or at string start
|
||||
* (for common error formats like "403 Forbidden").
|
||||
*/
|
||||
const STATUS_CODE_PATTERN = /(?:^|HTTP\s*|status[:\s]*|error[:\s]*|code[:\s]*)\b([1-5]\d{2})\b/i;
|
||||
|
||||
/**
|
||||
* Sanitize error output to a reasonable length.
|
||||
* Prevents memory bloat and UI issues from very long error messages.
|
||||
*/
|
||||
function sanitizeRawError(error: string): string {
|
||||
if (error.length > MAX_RAW_ERROR_LENGTH) {
|
||||
return error.substring(0, MAX_RAW_ERROR_LENGTH) + '...';
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum reasonable reset duration in seconds (24 hours).
|
||||
* Prevents malformed error strings from creating far-future dates.
|
||||
*/
|
||||
const MAX_RESET_SECONDS = 86400;
|
||||
|
||||
/**
|
||||
* Extract rate limit reset time from error message.
|
||||
* Parses various formats and returns a Date object if found.
|
||||
* Handles both absolute timestamps and relative durations ("in X seconds").
|
||||
*/
|
||||
function extractRateLimitResetTime(error: string): Date | undefined {
|
||||
// First, try to match relative duration pattern (e.g., "reset in 3600 seconds")
|
||||
const relativePattern = /reset[s]?\s*in[:\s]*(\d+)\s*seconds?/i;
|
||||
const relativeMatch = error.match(relativePattern);
|
||||
if (relativeMatch) {
|
||||
const seconds = parseInt(relativeMatch[1], 10);
|
||||
// Validate: positive, non-NaN, and within reasonable bounds (24 hours max)
|
||||
if (!Number.isNaN(seconds) && seconds > 0 && seconds <= MAX_RESET_SECONDS) {
|
||||
return new Date(Date.now() + seconds * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// Then try absolute timestamp pattern
|
||||
const absolutePattern = /(?:reset[s]?\s*at[:\s]*|X-RateLimit-Reset[:\s]*)(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z?|\d+)/i;
|
||||
const match = error.match(absolutePattern);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const resetValue = match[1].trim();
|
||||
|
||||
// Check if it's an ISO date string
|
||||
if (resetValue.includes('-') && resetValue.includes('T')) {
|
||||
const date = new Date(resetValue);
|
||||
if (Number.isNaN(date.getTime())) return undefined;
|
||||
// Validate: within reasonable bounds (24 hours max from now)
|
||||
if (date.getTime() - Date.now() > MAX_RESET_SECONDS * 1000) return undefined;
|
||||
return date;
|
||||
}
|
||||
|
||||
// Check if it's a Unix timestamp (seconds or milliseconds)
|
||||
const numericValue = parseInt(resetValue, 10);
|
||||
if (!Number.isNaN(numericValue)) {
|
||||
// GitHub API uses seconds, JavaScript uses milliseconds
|
||||
// Values > 1e12 are likely milliseconds already
|
||||
const timestamp = numericValue > 1e12 ? numericValue : numericValue * 1000;
|
||||
const date = new Date(timestamp);
|
||||
if (Number.isNaN(date.getTime())) return undefined;
|
||||
// Validate: within reasonable bounds (24 hours max from now)
|
||||
if (date.getTime() - Date.now() > MAX_RESET_SECONDS * 1000) return undefined;
|
||||
return date;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract required OAuth scopes from error message.
|
||||
* Returns an array of scope strings if found.
|
||||
*/
|
||||
function extractRequiredScopes(error: string): string[] | undefined {
|
||||
const match = error.match(REQUIRED_SCOPES_PATTERN);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const scopes = match[1]
|
||||
.split(/[,\s]+/)
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0);
|
||||
|
||||
return scopes.length > 0 ? scopes : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract HTTP status code from error message.
|
||||
*/
|
||||
function extractStatusCode(error: string): number | undefined {
|
||||
const match = error.match(STATUS_CODE_PATTERN);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const code = parseInt(match[1], 10);
|
||||
// Only return valid HTTP status codes
|
||||
if (code >= 100 && code < 600) {
|
||||
return code;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the error matches any of the given patterns.
|
||||
*/
|
||||
function matchesPatterns(error: string, patterns: RegExp[]): boolean {
|
||||
return patterns.some(pattern => pattern.test(error));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly message for rate limit errors.
|
||||
*/
|
||||
function getRateLimitMessage(_error: string, resetTime?: Date): string {
|
||||
if (resetTime) {
|
||||
const now = new Date();
|
||||
const diffMs = resetTime.getTime() - now.getTime();
|
||||
|
||||
if (diffMs > 0) {
|
||||
const diffMins = Math.ceil(diffMs / 60000);
|
||||
if (diffMins < 60) {
|
||||
return `GitHub API rate limit reached. Please wait ${diffMins} minute${diffMins !== 1 ? 's' : ''} before trying again.`;
|
||||
}
|
||||
const diffHours = Math.ceil(diffMins / 60);
|
||||
return `GitHub API rate limit reached. Rate limit resets in approximately ${diffHours} hour${diffHours !== 1 ? 's' : ''}.`;
|
||||
}
|
||||
}
|
||||
|
||||
return 'GitHub API rate limit reached. Please wait a moment before trying again.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly message for authentication errors.
|
||||
*/
|
||||
function getAuthMessage(): string {
|
||||
return 'GitHub authentication failed. Please check your GitHub token in Settings and try again.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly message for permission errors.
|
||||
*/
|
||||
function getPermissionMessage(scopes?: string[]): string {
|
||||
if (scopes && scopes.length > 0) {
|
||||
return `GitHub permission denied. Your token is missing required scopes: ${scopes.join(', ')}. Please update your GitHub token in Settings.`;
|
||||
}
|
||||
return 'GitHub permission denied. Your token may not have the required access. Please check your token permissions in Settings.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly message for not found errors.
|
||||
*/
|
||||
function getNotFoundMessage(): string {
|
||||
return 'The requested GitHub resource was not found. Please verify the repository exists and you have access to it.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly message for network errors.
|
||||
*/
|
||||
function getNetworkMessage(): string {
|
||||
return 'Unable to connect to GitHub. Please check your internet connection and try again.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly message for unknown errors.
|
||||
*/
|
||||
function getUnknownMessage(): string {
|
||||
return 'An unexpected error occurred while communicating with GitHub. Please try again.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify error type based on pattern matching and optional status code.
|
||||
* Priority: rate_limit > auth > permission > not_found > network > unknown
|
||||
* Note: Permission checks run before not_found to properly classify 403 responses.
|
||||
* Status code fallback takes priority over network patterns since HTTP status
|
||||
* codes are more specific than generic network error text.
|
||||
* @param error - The error string to classify
|
||||
* @param statusCode - Optional HTTP status code extracted with context (helps classify when text patterns don't match)
|
||||
*/
|
||||
function classifyError(error: string, statusCode?: number): GitHubErrorType {
|
||||
// Check rate limit first (403 can also be permission, but rate limit is more specific)
|
||||
if (matchesPatterns(error, RATE_LIMIT_PATTERNS)) {
|
||||
return 'rate_limit';
|
||||
}
|
||||
|
||||
// Check auth (401 is always auth)
|
||||
if (matchesPatterns(error, AUTH_PATTERNS)) {
|
||||
return 'auth';
|
||||
}
|
||||
|
||||
// Check permission (403 without rate limit context) before not_found
|
||||
// to properly classify 403 responses that might contain "not found" text
|
||||
if (matchesPatterns(error, PERMISSION_PATTERNS)) {
|
||||
return 'permission';
|
||||
}
|
||||
|
||||
// Check not found (404 is always not_found)
|
||||
if (matchesPatterns(error, NOT_FOUND_PATTERNS)) {
|
||||
return 'not_found';
|
||||
}
|
||||
|
||||
// Use status code fallback BEFORE network patterns
|
||||
// HTTP status codes are more specific than generic network error text
|
||||
if (statusCode === 401) return 'auth';
|
||||
if (statusCode === 403) return 'permission';
|
||||
if (statusCode === 404) return 'not_found';
|
||||
|
||||
// Check network errors (only if no status code fallback matched)
|
||||
if (matchesPatterns(error, NETWORK_PATTERNS)) {
|
||||
return 'network';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a GitHub API error string and return classified error information.
|
||||
*
|
||||
* IMPORTANT: The returned `message` field contains hardcoded English strings
|
||||
* intended ONLY as a fallback defaultValue for i18n translation. Consumers
|
||||
* should use the `type` field to look up the appropriate translation key
|
||||
* (e.g., 'githubErrors.rateLimitMessage') via react-i18next rather than
|
||||
* displaying `message` directly. This ensures proper localization.
|
||||
*
|
||||
* Translation key mapping by type:
|
||||
* - rate_limit → 'githubErrors.rateLimitMessage' (or rateLimitMessageMinutes/Hours)
|
||||
* - auth → 'githubErrors.authMessage'
|
||||
* - permission → 'githubErrors.permissionMessage' (or permissionMessageScopes)
|
||||
* - not_found → 'githubErrors.notFoundMessage'
|
||||
* - network → 'githubErrors.networkMessage'
|
||||
* - unknown → 'githubErrors.unknownMessage'
|
||||
*
|
||||
* @param error - The raw error string (typically from issues-store error state)
|
||||
* @returns GitHubErrorInfo object with classified type, user-friendly message, and metadata
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const errorInfo = parseGitHubError('GitHub API error: 403 - API rate limit exceeded');
|
||||
* // Use type to get i18n key, message only as fallback:
|
||||
* // t(`githubErrors.${errorInfo.type}Message`, { defaultValue: errorInfo.message })
|
||||
* ```
|
||||
*/
|
||||
export function parseGitHubError(error: string | null | undefined): GitHubErrorInfo {
|
||||
// Handle null/undefined/empty errors
|
||||
if (!error || typeof error !== 'string' || error.trim() === '') {
|
||||
return {
|
||||
type: 'unknown',
|
||||
message: getUnknownMessage(),
|
||||
};
|
||||
}
|
||||
|
||||
const trimmedError = error.trim();
|
||||
// Extract status code first so we can use it for classification fallback
|
||||
const statusCode = extractStatusCode(trimmedError);
|
||||
const errorType = classifyError(trimmedError, statusCode);
|
||||
|
||||
switch (errorType) {
|
||||
case 'rate_limit': {
|
||||
const resetTime = extractRateLimitResetTime(trimmedError);
|
||||
return {
|
||||
type: 'rate_limit',
|
||||
message: getRateLimitMessage(trimmedError, resetTime),
|
||||
rawMessage: sanitizeRawError(trimmedError),
|
||||
rateLimitResetTime: resetTime,
|
||||
statusCode: statusCode ?? 403,
|
||||
};
|
||||
}
|
||||
|
||||
case 'auth':
|
||||
return {
|
||||
type: 'auth',
|
||||
message: getAuthMessage(),
|
||||
rawMessage: sanitizeRawError(trimmedError),
|
||||
statusCode: statusCode ?? 401,
|
||||
};
|
||||
|
||||
case 'permission': {
|
||||
const scopes = extractRequiredScopes(trimmedError);
|
||||
return {
|
||||
type: 'permission',
|
||||
message: getPermissionMessage(scopes),
|
||||
rawMessage: sanitizeRawError(trimmedError),
|
||||
requiredScopes: scopes,
|
||||
statusCode: statusCode ?? 403,
|
||||
};
|
||||
}
|
||||
|
||||
case 'not_found':
|
||||
return {
|
||||
type: 'not_found',
|
||||
message: getNotFoundMessage(),
|
||||
rawMessage: sanitizeRawError(trimmedError),
|
||||
statusCode: statusCode ?? 404,
|
||||
};
|
||||
|
||||
case 'network':
|
||||
return {
|
||||
type: 'network',
|
||||
message: getNetworkMessage(),
|
||||
rawMessage: sanitizeRawError(trimmedError),
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
type: 'unknown',
|
||||
message: getUnknownMessage(),
|
||||
rawMessage: sanitizeRawError(trimmedError),
|
||||
statusCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is a rate limit error.
|
||||
* Convenience function for quick checks without full parsing.
|
||||
* @param error - Raw error string or null/undefined
|
||||
* @param parsedInfo - Optional pre-parsed GitHubErrorInfo to avoid re-classification
|
||||
*/
|
||||
export function isRateLimitError(
|
||||
error: string | null | undefined,
|
||||
parsedInfo?: GitHubErrorInfo | null
|
||||
): boolean {
|
||||
if (parsedInfo) return parsedInfo.type === 'rate_limit';
|
||||
if (!error) return false;
|
||||
const trimmed = error.trim();
|
||||
return classifyError(trimmed, extractStatusCode(trimmed)) === 'rate_limit';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is an authentication error.
|
||||
* Convenience function for quick checks without full parsing.
|
||||
* @param error - Raw error string or null/undefined
|
||||
* @param parsedInfo - Optional pre-parsed GitHubErrorInfo to avoid re-classification
|
||||
*/
|
||||
export function isAuthError(
|
||||
error: string | null | undefined,
|
||||
parsedInfo?: GitHubErrorInfo | null
|
||||
): boolean {
|
||||
if (parsedInfo) return parsedInfo.type === 'auth';
|
||||
if (!error) return false;
|
||||
const trimmed = error.trim();
|
||||
return classifyError(trimmed, extractStatusCode(trimmed)) === 'auth';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is a network error.
|
||||
* Convenience function for quick checks without full parsing.
|
||||
* @param error - Raw error string or null/undefined
|
||||
* @param parsedInfo - Optional pre-parsed GitHubErrorInfo to avoid re-classification
|
||||
*/
|
||||
export function isNetworkError(
|
||||
error: string | null | undefined,
|
||||
parsedInfo?: GitHubErrorInfo | null
|
||||
): boolean {
|
||||
if (parsedInfo) return parsedInfo.type === 'network';
|
||||
if (!error) return false;
|
||||
const trimmed = error.trim();
|
||||
return classifyError(trimmed, extractStatusCode(trimmed)) === 'network';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is recoverable (user can retry).
|
||||
* Rate limit, network, and unknown errors are considered recoverable.
|
||||
* @param error - Raw error string or null/undefined
|
||||
* @param parsedInfo - Optional pre-parsed GitHubErrorInfo to avoid re-classification
|
||||
*/
|
||||
export function isRecoverableError(
|
||||
error: string | null | undefined,
|
||||
parsedInfo?: GitHubErrorInfo | null
|
||||
): boolean {
|
||||
if (parsedInfo) return ['rate_limit', 'network', 'unknown'].includes(parsedInfo.type);
|
||||
if (!error) return false;
|
||||
const trimmed = error.trim();
|
||||
const errorType = classifyError(trimmed, extractStatusCode(trimmed));
|
||||
return ['rate_limit', 'network', 'unknown'].includes(errorType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error requires user action in settings.
|
||||
* Auth and permission errors require settings changes.
|
||||
* @param error - Raw error string or null/undefined
|
||||
* @param parsedInfo - Optional pre-parsed GitHubErrorInfo to avoid re-classification
|
||||
*/
|
||||
export function requiresSettingsAction(
|
||||
error: string | null | undefined,
|
||||
parsedInfo?: GitHubErrorInfo | null
|
||||
): boolean {
|
||||
if (parsedInfo) return ['auth', 'permission'].includes(parsedInfo.type);
|
||||
if (!error) return false;
|
||||
const trimmed = error.trim();
|
||||
const errorType = classifyError(trimmed, extractStatusCode(trimmed));
|
||||
return ['auth', 'permission'].includes(errorType);
|
||||
}
|
||||
@@ -19,13 +19,3 @@ export function filterIssuesBySearch(issues: GitHubIssue[], searchQuery: string)
|
||||
issue.body?.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export GitHub error parser utilities
|
||||
export {
|
||||
parseGitHubError,
|
||||
isRateLimitError,
|
||||
isAuthError,
|
||||
isNetworkError,
|
||||
isRecoverableError,
|
||||
requiresSettingsAction,
|
||||
} from './github-error-parser';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -34,20 +34,6 @@ describe('task-store-persistence', () => {
|
||||
let useTaskStore: typeof import('../task-store').useTaskStore;
|
||||
let loadTasks: typeof import('../task-store').loadTasks;
|
||||
let createTask: typeof import('../task-store').createTask;
|
||||
// Helper to create test tasks with all required fields
|
||||
const makeTask = (overrides: Partial<Task> = {}): Task => ({
|
||||
id: 'task-1',
|
||||
specId: '001-test-task',
|
||||
projectId: 'test-project',
|
||||
title: 'Test Task',
|
||||
description: 'Test description',
|
||||
status: 'backlog' as TaskStatus,
|
||||
logs: [],
|
||||
subtasks: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides
|
||||
});
|
||||
|
||||
|
||||
beforeEach(async () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -659,28 +659,6 @@
|
||||
"remote": "Remote"
|
||||
}
|
||||
},
|
||||
"githubErrors": {
|
||||
"rateLimitTitle": "GitHub Rate Limit Reached",
|
||||
"authTitle": "GitHub Authentication Required",
|
||||
"permissionTitle": "GitHub Permission Denied",
|
||||
"notFoundTitle": "GitHub Resource Not Found",
|
||||
"networkTitle": "GitHub Connection Error",
|
||||
"unknownTitle": "GitHub Error",
|
||||
"rateLimitMessage": "GitHub API rate limit reached. Please wait a moment before trying again.",
|
||||
"rateLimitMessageMinutes": "GitHub API rate limit reached. Please wait {{minutes}} minute(s) before trying again.",
|
||||
"rateLimitMessageHours": "GitHub API rate limit reached. Rate limit resets in approximately {{hours}} hour(s).",
|
||||
"authMessage": "GitHub authentication failed. Please check your GitHub token in Settings and try again.",
|
||||
"permissionMessage": "GitHub permission denied. Your token may not have the required access. Please check your token permissions in Settings.",
|
||||
"permissionMessageScopes": "GitHub permission denied. Your token is missing required scopes: {{scopes}}. Please update your GitHub token in Settings.",
|
||||
"notFoundMessage": "The requested GitHub resource was not found. Please verify the repository exists and you have access to it.",
|
||||
"networkMessage": "Unable to connect to GitHub. Please check your internet connection and try again.",
|
||||
"unknownMessage": "An unexpected error occurred while communicating with GitHub. Please try again.",
|
||||
"resetsIn": "Resets in {{time}}",
|
||||
"countdownHoursMinutes": "{{hours}}h {{minutes}}m",
|
||||
"countdownMinutesSeconds": "{{minutes}}m {{seconds}}s",
|
||||
"rateLimitExpired": "Rate limit has reset. You can retry now.",
|
||||
"requiredScopes": "Required scopes"
|
||||
},
|
||||
"roadmapProgress": {
|
||||
"elapsedTime": "Elapsed",
|
||||
"lastActivity": "Last activity",
|
||||
|
||||
@@ -659,28 +659,6 @@
|
||||
"remote": "Distante"
|
||||
}
|
||||
},
|
||||
"githubErrors": {
|
||||
"rateLimitTitle": "Limite de débit GitHub atteinte",
|
||||
"authTitle": "Authentification GitHub requise",
|
||||
"permissionTitle": "Permission GitHub refusée",
|
||||
"notFoundTitle": "Ressource GitHub introuvable",
|
||||
"networkTitle": "Erreur de connexion GitHub",
|
||||
"unknownTitle": "Erreur GitHub",
|
||||
"rateLimitMessage": "Limite de débit de l'API GitHub atteinte. Veuillez patienter un moment avant de réessayer.",
|
||||
"rateLimitMessageMinutes": "Limite de débit de l'API GitHub atteinte. Veuillez attendre {{minutes}} minute(s) avant de réessayer.",
|
||||
"rateLimitMessageHours": "Limite de débit de l'API GitHub atteinte. La limite se réinitialise dans environ {{hours}} heure(s).",
|
||||
"authMessage": "Échec de l'authentification GitHub. Veuillez vérifier votre jeton GitHub dans les Paramètres et réessayer.",
|
||||
"permissionMessage": "Permission GitHub refusée. Votre jeton n'a peut-être pas les accès requis. Veuillez vérifier les permissions de votre jeton dans les Paramètres.",
|
||||
"permissionMessageScopes": "Permission GitHub refusée. Votre jeton manque de permissions requises : {{scopes}}. Veuillez mettre à jour votre jeton GitHub dans les Paramètres.",
|
||||
"notFoundMessage": "La ressource GitHub demandée est introuvable. Veuillez vérifier que le dépôt existe et que vous y avez accès.",
|
||||
"networkMessage": "Impossible de se connecter à GitHub. Veuillez vérifier votre connexion Internet et réessayer.",
|
||||
"unknownMessage": "Une erreur inattendue s'est produite lors de la communication avec GitHub. Veuillez réessayer.",
|
||||
"resetsIn": "Réinitialisation dans {{time}}",
|
||||
"countdownHoursMinutes": "{{hours}}h {{minutes}}m",
|
||||
"countdownMinutesSeconds": "{{minutes}}m {{seconds}}s",
|
||||
"rateLimitExpired": "La limite de débit a été réinitialisée. Vous pouvez réessayer maintenant.",
|
||||
"requiredScopes": "Permissions requises"
|
||||
},
|
||||
"roadmapProgress": {
|
||||
"elapsedTime": "Écoulé",
|
||||
"lastActivity": "Dernière activité",
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -74,31 +74,6 @@ export function maskUserPaths(text: string): string {
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize text for safe inclusion in Sentry reports.
|
||||
* Masks user paths and redacts potential secrets (tokens, keys, credentials).
|
||||
*/
|
||||
export function sanitizeForSentry(text: string): string {
|
||||
if (!text) return text;
|
||||
|
||||
text = maskUserPaths(text);
|
||||
|
||||
// Redact common secret patterns (API keys, tokens, auth headers)
|
||||
// Bearer/token auth
|
||||
text = text.replace(/\b(Bearer|token|Token)\s+[A-Za-z0-9\-_.]+/gi, '$1 [REDACTED]');
|
||||
// API keys / secrets in key=value or key: value format
|
||||
text = text.replace(
|
||||
/\b(api[_-]?key|api[_-]?secret|auth[_-]?token|access[_-]?token|refresh[_-]?token|secret[_-]?key|password|credential|private[_-]?key)[=:]\s*\S+/gi,
|
||||
'$1=[REDACTED]'
|
||||
);
|
||||
// Anthropic API key format
|
||||
text = text.replace(/\bsk-ant-[A-Za-z0-9\-_]{20,}/g, '[REDACTED_KEY]');
|
||||
// Generic long hex/base64 tokens (40+ chars, likely secrets)
|
||||
text = text.replace(/\b[A-Za-z0-9+/]{40,}={0,2}\b/g, '[REDACTED_TOKEN]');
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively mask paths in an object
|
||||
* Handles nested objects and arrays
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user