Compare commits

..

14 Commits

Author SHA1 Message Date
AndyMik90 9fc7320158 feat(web): extract i18n namespaces, consolidate settings locale, and harden stores
Extract all hardcoded UI strings into i18n namespace JSON files (kanban,
views, integrations, layout). Consolidate duplicated settings keys from
layout namespace into dedicated settings namespace. Distinguish network
errors from API errors in task-store so connectivity issues show empty
state while server errors surface to the UI. Add test coverage for
Settings button accessibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 19:39:29 +01:00
AndyMik90 d64e999db5 feat(web): wire onboarding wizard, add config files and pre-existing hooks
Connect OnboardingWizard to AppShell via UI store. Add the remaining
web app config files (next.config.js, tsconfig.json, postcss.config.js),
auth layer, Convex hooks, API routes, and Dockerignore files that were
part of the skeleton but not yet tracked.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 16:09:21 +01:00
AndyMik90 85d6b18529 feat(web): add GitLab views, task creation wizard, onboarding, and keyboard shortcuts
- GitLabIssuesView with list/detail split pane matching GitHub pattern
- GitLabMRsView with approval tracking and AI review button
- TaskCreationWizard with 3-step flow (details, metadata, review)
- OnboardingWizard with step-by-step setup flow
- Keyboard shortcuts for all navigation views (K, N, D, I, L, C, G, P, B, R)
- Theme-aware rendering from settings store
- Sidebar now includes GitLab navigation items

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 16:05:36 +01:00
AndyMik90 737060902f feat(web): add full app shell with sidebar, navigation, and all major page views
Build the web app as a 1:1 replica of the Electron app's UI structure:
- Sidebar with navigation matching Electron app (kanban, insights, roadmap, ideation, changelog, context, GitHub)
- Project tab bar with multi-project support
- KanbanBoard with task cards, status columns, and task detail modal
- RoadmapView with board/timeline/list tabs and feature detail panel
- IdeationView with category filters and idea cards
- InsightsView with AI chat interface and session sidebar
- ChangelogView with expandable version entries
- ContextView with project overview, services, and memories tabs
- GitHubIssuesView and GitHubPRsView with list/detail split pane
- SettingsView with section-based navigation
- Dual-mode data layer (HTTP API + Convex) with Zustand stores
- Dark mode theme system matching Electron app's design tokens

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 16:02:20 +01:00
AndyMik90 062758bc1d refactor: rename libs/ to packages/ for consistency
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 15:51:13 +01:00
AndyMik90 435cc0733a chore: bump to v2.7.7 stable and fix libs build chain for npm publishing
- Update all package versions to 2.7.7 (removing beta flags)
- Fix libs/ui build chain: add TypeScript paths mapping for dev typecheck,
  override with empty paths in build config to use workspace resolution
- Sync roadmap types: add TaskOutcome type and taskOutcome/previousStatus
  fields to RoadmapFeature in libs/types (drifted during rebase)
- Add root build:libs script for ordered types → ui compilation
- Update @auto-claude/types peer dependency in libs/ui to 2.7.7

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 12:36:41 +01:00
Andy 5aac714b59 Cloud Phase 1 — OSS Repo Preparation: Extract shared types and UI into workspace packages (#1783)
* auto-claude: subtask-1-1 - Create libs/types/ and libs/ui/ package scaffolding

* auto-claude: subtask-1-2 - Add @auto-claude/types and @auto-claude/ui as workspace deps

Add workspace package dependencies to apps/frontend/package.json and verify
npm workspace resolution creates correct symlinks in node_modules/@auto-claude/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-2-1 - Copy all 11 portable type files from apps/frontend

Copies common.ts, task.ts, project.ts, settings.ts, agent.ts, kanban.ts,
changelog.ts, insights.ts, roadmap.ts, integrations.ts, pr-status.ts to
libs/types/src/. Creates constants.ts with inline type definitions for
ExecutionPhase, CompletablePhase, and SupportedLanguage. Updates import
paths in task.ts and settings.ts to use local constants.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-2-2 - Create cloud-specific types and future phase stubs

Add cloud.ts (Tier, CloudUser, SubscriptionStatus, FeatureFlag, TierLimits, Usage),
team.ts (Team, TeamMember, TeamRole), and Phase 3-6 stub types: billing.ts,
pr-lifecycle.ts, feedback.ts, sessions.ts with @stub JSDoc annotations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-2-3 - Create barrel exports and re-export layer

- libs/types/src/index.ts: barrel export for all shared type files
- apps/frontend/src/shared/types/index.ts: re-exports from @auto-claude/types
  plus Electron-specific types (terminal, ipc, screenshot, etc.)
- Fix duplicate ExecutionPhase export conflict in task.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-3-1 - Extract cn() utility and theme CSS to libs/ui

- Create libs/ui/src/utils.ts with cn() (clsx + tailwind-merge)
- Create libs/ui/src/styles/theme.css with @theme block, all 7 color
  themes (default, dusk, lime, ocean, retro, neo, forest) with
  light/dark variants
- Update frontend utils.ts to re-export cn() from @auto-claude/ui
- Export cn and ClassValue from libs/ui/src/index.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-3-2 - Extract all 26 shadcn/ui primitive components to libs/ui

Copies all 26 UI primitives from apps/frontend/src/renderer/components/ui/
to libs/ui/src/primitives/ with updated imports:
- cn() import changed from '../../lib/utils' to '../utils'
- error-boundary: removed Sentry dependency, added optional onError prop
- toaster/use-toast: updated internal imports to local primitives
- input/textarea: kept react-i18next import (peer dependency)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-3-3 - Create primitives barrel export and wire up UI re-exports

- Create libs/ui/src/primitives/index.ts barrel export for all 26 primitives + useToast
- Create libs/ui/components.json for shadcn CLI compatibility
- Update libs/ui/src/index.ts to re-export primitives
- Replace all frontend UI component files with one-line re-exports from @auto-claude/ui
- Update frontend components/ui/index.ts to re-export from @auto-claude/ui/primitives
- Update globals.css: import shared theme.css, add @source directive, remove extracted @theme and color selectors
- Update use-toast hook to re-export from @auto-claude/ui
- Add @auto-claude/ui path mappings to frontend tsconfig.json

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-4-1 - Build three core feature gating components

Add FeatureGate, UsageLimitGate, and UpgradePrompt components to
libs/ui/src/core/ with tier hierarchy comparison, usage quota
enforcement, and upgrade CTA display.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-4-2 - Build auth-related components in libs/ui/src/core/

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-5-1 - Build spec management components in libs/ui/src/specs/

Add SpecCard, SpecList, and SpecEditor components as pure prop-driven
UI components for spec/task management. SpecCard displays a single task
with status badge, metadata, and action buttons. SpecList renders a
filterable/sortable list with loading and empty states. SpecEditor
provides a form for creating/editing specs with classification fields
and model selection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-5-2 - Build persona management components in libs/ui/src

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-6-1 - Update build config for workspace packages

- Add @auto-claude/types and @auto-claude/ui to externalizeDepsPlugin exclude
  list so they are bundled (not externalized) in main process builds
- Add resolve.alias entries for workspace packages pointing to source dirs
  for proper Vite resolution and HMR support
- Add @auto-claude/types paths to tsconfig.json for TypeScript resolution
- Include libs/types/src in tsconfig include array
- Fix CSS import paths in globals.css (4 levels -> 5 levels to reach repo root)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-6-2 - Fix UI package exports and verify full build suite

Add explicit export mapping for use-toast.ts in @auto-claude/ui package
to fix resolution of .ts files alongside .tsx glob pattern.

Verification results:
- libs/types: tsc --noEmit ✓
- libs/ui: tsc --noEmit ✓
- apps/frontend: typecheck ✓, lint ✓ (warnings only), 3048 tests passed, build ✓

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-6-3 - Create GitHub Actions workflow for npm package publishing

Add publish-packages.yml workflow that:
- Triggers on v* tag pushes to build and publish @auto-claude/types and @auto-claude/ui
- Validates with typecheck on libs/types, libs/ui, and frontend + frontend tests
- Runs dry-run publish on PRs touching libs/ to catch issues early
- Publishes both packages with --access public using NPM_TOKEN secret

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: Address PR review findings for OSS repo preparation

- Restore Sentry error reporting in ErrorBoundary by creating a
  wrapper in the frontend app that injects captureException as onError
- Add react-i18next and i18next as peerDependencies in libs/ui
- Add explicit directory exports (primitives, core, specs, personas)
  to libs/ui package.json for proper Node.js module resolution
- Fix .replace('_', ' ') to .replace(/_/g, ' ') in SpecCard and
  SpecEditor to handle categories with multiple underscores
- Add currentTier/upgradeTier props to UsageLimitGate instead of
  hardcoding 'pro'/'free'
- Add configurable label props with English defaults to ErrorBoundary,
  LoginForm, UpgradePrompt, AuthGate, UsageLimitGate, PersonaManager,
  and SpecEditor for i18n support at the consumer level
- Replace raw HTML input/select/button elements with shared Input,
  Label, Button primitives in PersonaManager and SpecEditor
- Clean up duplicate IPCResult type: update ipc.ts and mcp-api.ts to
  import from @auto-claude/types, convert common.ts to re-export
- Export new Labels types from barrel files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: Add --provenance flag to npm publish commands

The workflow already requests id-token: write for OIDC attestation
but the publish commands were missing the --provenance flag, making
the permission unused. Adding provenance enables npm supply chain
attestation for published packages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: Add build step for npm packages, fix duplicate CSS, add publish recovery

- Add tsconfig.build.json for both @auto-claude/types and @auto-claude/ui
  that compiles TypeScript to JavaScript + declaration files in dist/
- Add build/clean/prepublishOnly scripts to both package.json files
- Update exports to use conditional exports with types + default conditions
  pointing to compiled dist/ output (source still shipped for reference)
- Add build steps to all CI workflow jobs (validate, dry-run, publish)
- Add version-exists check before each npm publish to handle re-runs
  gracefully when one package published but the other failed
- Remove redundant NPM_TOKEN env blocks from publish steps (already
  configured via .npmrc)
- Remove duplicate .progress-working CSS definition in globals.css that
  silently overrode the themed glow-sweep animation with a plain white
  shimmer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: Add tag-version validation, improve publish error handling, fix ErrorBoundary shadowing

- Add tag-to-package version validation step in publish workflow to
  catch mismatches before attempting to publish
- Improve version-exists check to distinguish network errors from
  "not published" state instead of swallowing all errors
- Re-export Sentry-integrated ErrorBoundary from ui barrel to prevent
  base component from shadowing the app-level wrapper
- Remove unnecessary @auto-claude/ui from main process exclude list
  since it is not imported in src/main/

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: Use ::warning:: for optimistic publish, simplify ErrorBoundary to function component

- Change ::error:: to ::warning:: in publish workflow fallback branches
  since the step intentionally proceeds with publishing anyway
- Convert ErrorBoundary wrapper from class to function component since
  all error boundary logic is delegated to BaseErrorBoundary

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 10:55:18 +01:00
Andy 4d4234378f fix(sentry): enable Sentry for Python subprocesses and add diagnostic instrumentation (#1804)
* fix(sentry): enable Sentry for Python subprocesses and add diagnostic instrumentation

Sentry was broken for PR review (and all GitHub runner) subprocesses due to
two bugs: getRunnerEnv() didn't include getSentryEnvForSubprocess(), and
Python's init_sentry() required sys.frozen which is always False for the
non-frozen interpreter. Also adds a 120s health-check timeout to detect
subprocess hangs, Sentry breadcrumbs to PR review lifecycle, and forces
unbuffered Python output for reliable progress streaming.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(sentry): remove dead should_enable guard and add missing breadcrumb levels

The dsn_explicitly_set check was always True after the early return for
empty DSN, making should_enable always True and the gating block
unreachable dead code. Simplified to just a clear comment explaining
that DSN presence is sufficient to enable Sentry.

Also added missing level field to two safeBreadcrumb calls in PR review
handlers to match the established project convention.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(sentry): clean up dead code, sanitize stderr, and add follow-up review instrumentation

- Remove dead force_enable parameter from init_sentry() (no callers use it)
- Fix misleading SENTRY_DEV comment — Python backend no longer reads it
- Remove SENTRY_DEV pass-through from getSentryEnvForSubprocess()
- Add sanitizeForSentry() to redact potential secrets (tokens, API keys)
  from subprocess stderr before sending to Sentry
- Add safeBreadcrumb and safeCaptureException to follow-up review handler
  for parity with the initial review handler

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 10:51:41 +01:00
Andy d1fbccde39 fix(pr-review): add three-tier recovery for structured output validation failure (#1797)
* fix(pr-review): add three-tier recovery for structured output validation failure

When structured output validation fails after SDK max retries, the followup
reviewer crashed with RuntimeError instead of recovering. This wastes all
multi-agent analysis work (often 100+ messages across 3 specialist agents).

Changes:
- sdk_utils: add error_recoverable flag and last_assistant_text to stream result
- followup reviewer: attempt extraction call with minimal schema before text fallback
- pydantic_models: add FollowupExtractionResponse (~6 flat fields, near-100% success)
- orchestrator reviewer: add structured_output to FindingValidator retryable errors

Recovery cascade: structured output → extraction call → text parsing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(pr-review): address review findings from PR #1797

- Register pr_followup_extraction agent type in AGENT_CONFIGS (fixes Tier 2 dead code)
- Move RECOVERABLE_ERRORS to module-level constant in sdk_utils for importability
- Update docstring to document new return fields (last_assistant_text, error_recoverable)
- Use self.config.fast_mode instead of hardcoded True for consistency
- Rewrite tests to import actual production constants instead of reimplementing logic

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(tests): fix import paths for CI environment

CI runs pytest from apps/backend/ so runners/github/ must be on sys.path
for services.sdk_utils and services.pydantic_models imports to resolve.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(tests): use bare module imports to avoid services/ package collision

There are two services/ directories (apps/backend/services/ and
runners/github/services/). Adding github services dir to sys.path and
importing via `from services.sdk_utils` fails because Python finds the
wrong services/ package first. Fix: add the services dir directly and
use bare imports (from sdk_utils import ...).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(pr-review): fix extraction call type error and control flow issues

- Use self.project_dir instead of str(Path.cwd()) for create_client (fixes
  AttributeError making Tier 2 always crash, and uses correct project path)
- Force structured_output = None on recoverable errors to skip redundant
  parse-then-fail cycle and go directly to Tier 2 extraction
- Include dismissed_finding_count in extraction return dict for symmetry

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(pr-review): address follow-up review findings

- Read dismissed_finding_count fallback in consumer (fixes silent data loss)
- Consolidate recoverable error handling into single control flow block
- Default text fallback verdict to NEEDS_REVISION (consistent with _create_empty_result)
- Add missing keys to _parse_text_output and _create_empty_result for consistent
  return dict contracts across all three recovery tiers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: ruff format parallel_followup_reviewer.py

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 19:43:44 +01:00
StillKnotKnown ed93df698b test: improve backend agent test coverage to 94% (#1779)
* fix: add mock reset fixtures and resolve async iterator mock issues

- Add pytest_runtest_setup and pytest_runtest_teardown hooks to reset
  shared module-level mocks between tests
- Add module-specific mock reset fixtures for test_qa_fixer and
  test_qa_reviewer to prevent test interference
- Fix async iterator mock for receive_response to properly return
  an AsyncIteratorMock instance
- Update test_qa_fixer.py and test_qa_reviewer.py with proper mock
  setup for isolated test execution

* docs(agents): add CLAUDE.md documentation for agents module

Documents the agents module architecture including:
- Module components (coder, planner, session, memory_manager, base)
- Single-agent architecture without external parallelism
- Subagent architecture clarification

* Revert "docs(agents): add CLAUDE.md documentation for agents module"

This reverts commit bf1ddd7da08f2f34352d11a5d823da981f1a98bb.

* chore: update gitignore to allow agents/tests/

* fix(tests): resolve mock isolation and path permission issues

- Fix test_tool_concurrency_error_detection by patching where functions
  are used (qa.fixer) instead of where they're defined
- Add Path.exists/is_dir/glob mocks to avoid permission errors on
  nonexistent directories in test_validation_strategy.py
- Add helper function clean_project_index_files() to reduce code
  duplication in prereqs_validator tests
- Add comprehensive tests for spec validation validators
  (context, prereqs, spec_document)
- Fix similar mock/path issues in test_qa_reviewer.py,
  test_service_orchestrator.py, test_ci_discovery.py,
  test_prompt_generator.py, test_security_scanner.py

All 2103 tests now pass.

* fix(tests): remove unused imports and fix double assignment

- Remove unused 'patch' import from validator test files
- Remove unused 'pytest' import where not needed
- Fix double assignment typo in test_error_message_includes_filename

* fix(tests): move agents tests to tests/agents/ directory

- Move test_agent_architecture.py, test_agent_configs.py, and
  test_agent_flow.py from apps/backend/agents/tests/ to tests/agents/
- Fix path resolution to work from new location
- Remove gitignore exception for agents/tests/ (no longer needed)

This resolves the issue where tests were not included in the PR
because they were in an untracked location.

* fix(tests): simplify conftest.py mock management

- Remove redundant pytest_runtest_teardown and pytest_runtest_call hooks
  (autouse fixtures in test files already handle mock reset)
- Add prompts_pkg.project_context to potentially mocked modules list
- Remove prompts_pkg from test_qa_fixer entry (not used there)

This reduces maintenance burden by having mock reset in one place.

* refactor(tests): consolidate duplicate mock setup into shared helper

- Create tests/qa_test_helpers.py with shared mock infrastructure:
  - AsyncIteratorMock and ReceiveResponseMock classes
  - setup_qa_mocks(), cleanup_qa_mocks(), reset_qa_mocks() functions
  - Mock response creation helpers
  - Accessor functions for mock objects
- Refactor test_qa_fixer.py to use shared helpers
- Reduces ~80 lines of duplicated code per test file
- Fixes potential mock binding issues by using accessor functions

This addresses code quality issues identified in PR review:
- Duplicate mock setup between test_qa_fixer.py and test_qa_reviewer.py
- Duplicated _AsyncIteratorMock class across files

* refactor(tests): consolidate test_qa_reviewer.py with shared helpers

- Refactor test_qa_reviewer.py to use shared qa_test_helpers
- Remove ~170 lines of duplicated mock setup and helper functions
- Fix unused imports in test_qa_fixer.py (json, sys, MagicMock, etc.)
- Fix rate limit error detection tests to patch where functions are used
- Consolidate duplicated _create_*_response helper methods to module level

Addresses CodeQL warnings about unused imports and reduces code
duplication between test_qa_fixer.py and test_qa_reviewer.py.

* fix(tests): remove unused Path import from test_qa_reviewer.py

* fix(tests): address all PR review findings

PR Review Fixes:
- Remove unused create_mock_qa_approved_response/rejected_response functions
- Guard against overwriting _original_modules on second setup_qa_mocks() call
- Clear _original_modules in cleanup_qa_mocks() to prevent stale state
- Add prompts_pkg.project_context to test_qa_reviewer preserved_mocks in conftest
- Convert asyncio.run() pattern to native async tests in test_agent_flow.py
- Remove redundant @pytest.mark.asyncio decorators (asyncio_mode=auto)
- Remove unused pytest import from qa_test_helpers.py
- Fix structural duplication by keeping fixtures in test files

Code Quality:
- Removed ~100 lines of duplicated/unused code
- Consistent async test patterns across all QA test files
- Proper mock state management to prevent test pollution

* fix(tests): save original modules individually in setup_qa_mocks

The boolean guard `setup_done` prevented saving original modules on
subsequent calls with different parameters. When setup_qa_mocks was
called first with include_prompts_pkg=False, then with True, the
prompts_pkg modules were never saved to _original_modules. During
cleanup, these unsaved modules were deleted from sys.modules instead
of being restored, causing ModuleNotFoundError in subsequent tests.

Now checks each module individually before mocking, ensuring all
originals are saved across multiple setup calls.

* fix(tests): address all PR review findings including low priority

- Fix path in test_no_subtask_worker_config (parent.parent.parent)
- Add guard to prevent double setup in setup_qa_mocks()
- Don't clear _original_modules in cleanup to fix multi-module cleanup

* fix(tests): address PR review follow-up findings

- Fix module-level mock setup ordering dependency: now tracks
  include_prompts_pkg config and allows incremental setup when
  test_qa_fixer.py (False) is imported before test_qa_reviewer.py (True)
- Remove unused asyncio import from test_agent_flow.py
- Replace os.chdir() with monkeypatch.chdir() in prereqs validator
  tests for safe parallel test execution

---------

Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com>
2026-02-12 15:06:25 +01:00
Andy 8872d33e32 fix(github): use UTC timestamps for reviewed_at to fix comment detection (#1795)
* fix(github): use UTC timestamps for reviewed_at to fix comment detection

datetime.now().isoformat() produces local time without timezone info.
When passed to GitHub API's `since` parameter (which expects UTC), this
shifts the cutoff by the local timezone offset, causing follow-up PR
reviews to miss human comments posted shortly after the previous review.

Replace all datetime.now().isoformat() with a UTC-aware _utc_now_iso()
helper using datetime.now(timezone.utc).isoformat().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(github): use Z suffix in UTC timestamps to avoid URL encoding issues

The + in +00:00 can be decoded as a space by GitHub API query
parameters, potentially causing missed comments. Z is semantically
identical in ISO 8601 and URL-safe.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 15:00:47 +01:00
AndyMik90 3b3ad75c1b chore: bump version to 2.7.6-beta.4 2026-02-12 14:17:58 +01:00
StillKnotKnown 8ece0009ee feat: add user-friendly GitHub API error handling (#1790)
* auto-claude: subtask-1-1 - Add GitHubErrorType and GitHubErrorInfo types

Add error classification types for GitHub API error handling:
- GitHubErrorType: Discriminated union for error categories
  (rate_limit, auth, permission, network, not_found, unknown)
- GitHubErrorInfo: Structured error info with user-friendly message,
  raw error, rate limit reset time, required OAuth scopes, and status code

These types will be used by the github-error-parser utility and
GitHubApiErrorDisplay component for consistent error handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-1-2 - Create github-error-parser.ts utility with parseGitHubError function

- Create github-error-parser.ts utility to classify GitHub API errors
- Implement parseGitHubError() to detect error types: rate_limit, auth, permission, not_found, network, unknown
- Extract metadata from errors (rate limit reset times, required scopes, status codes)
- Add convenience functions: isRateLimitError, isAuthError, isNetworkError, isRecoverableError, requiresSettingsAction
- Export all functions from utils/index.ts barrel file
- Follow patterns from rate-limit-detector.ts with pattern arrays and classification functions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-2-1 - Create GitHubErrorDisplay.tsx component

Add GitHubErrorDisplay component with error-type-specific rendering:
- Different icons per error type (Clock, Key, Shield, WifiOff, SearchX, AlertTriangle)
- Rate limit countdown timer with useEffect cleanup
- Conditional action buttons (retry for recoverable, settings for auth/permission)
- Compact and full card display variants
- i18n-ready with common namespace translation keys

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-2-2 - Add rate limit countdown timer with useEffect cleanup

- Fixed non-null assertion lint warning in countdown useEffect
- Extract resetTime to local variable with conditional check
- Maintains proper cleanup pattern with clearInterval on unmount

* auto-claude: subtask-2-3 - Export GitHubErrorDisplay from components/index.ts

* auto-claude: subtask-3-1 - Update IssueList.tsx to use GitHubErrorDisplay for blocking errors

- Added onRetry and onOpenSettings props to IssueListProps interface
- Updated IssueList component to use GitHubErrorDisplay for blocking errors (when issues.length === 0)
- Updated GitHubIssues.tsx to pass handleRefresh and onOpenSettings callbacks to IssueList
- Blocking errors now show user-friendly messages with retry/settings buttons based on error type

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-3-2 - Update IssueList.tsx to use GitHubErrorDisplay for inline load-more errors

Replace the simple inline error div with GitHubErrorDisplay component using
the compact prop for better error handling when issues are already loaded.
This provides consistent error display with retry/settings actions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-4-1 - Add githubErrors.* translation keys to en/common.json

Added translation keys for GitHub error display component:
- rateLimitTitle, authTitle, permissionTitle, notFoundTitle
- networkTitle, unknownTitle for error type titles
- resetsIn for rate limit countdown display
- rateLimitExpired for when rate limit has reset
- requiredScopes for permission error details

* auto-claude: subtask-4-2 - Add githubErrors.* translation keys to fr/common.json

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-5-1 - Create unit tests for github-error-parser.ts

Add comprehensive unit tests covering all error types and helper functions:
- parseGitHubError: rate_limit, auth, permission, not_found, network, unknown
- Helper functions: isRateLimitError, isAuthError, isNetworkError
- isRecoverableError, requiresSettingsAction
- Edge cases: null/undefined/empty, case insensitivity, multiline, JSON
- Cross-cutting concerns: consistency, status code extraction

92 tests total covering all patterns and behaviors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* auto-claude: subtask-5-2 - Create unit tests for GitHubErrorDisplay.tsx component

Added comprehensive unit tests covering:
- Null/empty error state handling
- String error and GitHubErrorInfo object parsing
- All error types (rate_limit, auth, permission, not_found, network, unknown)
- Compact mode vs full card mode rendering
- Retry and Settings button visibility based on error type
- Rate limit countdown display
- Required scopes display for permission errors
- Custom className prop support
- Callback stability and accessibility

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address lint and TypeScript issues in GitHub error handling

- Fix incorrect import path in test file (../../../types -> ../../types)
- Replace isNaN with Number.isNaN for safer type checking
- Fix unused parameter by prefixing with underscore
- Remove redundant switch case (case 'unknown' with default)
- Remove unused imports in test file (beforeEach, afterEach)
- Add comments to empty arrow functions in tests
- Use optional chaining instead of non-null assertion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address CodeRabbit review feedback on GitHub error handling

- GitHubErrorDisplay.tsx:
  - Memoize errorInfo with useMemo to prevent useEffect churn
  - Remove unnecessary useCallback wrappers for trivial handlers
  - Simplify dead code conditional (if (!error) return null)
  - Use i18n keys for error messages instead of hardcoded strings

- github-error-parser.ts:
  - Add word boundaries to numeric regex patterns (401, 403, 404)
  - Make STATUS_CODE_PATTERN context-aware to avoid false positives

- Tests:
  - Add fake timer tests for countdown interval behavior
  - Add clearInterval spy for unmount cleanup verification
  - Add overlapping pattern priority tests
  - Update translation mock with new message keys

- i18n:
  - Add githubErrors.*Message keys to en/common.json and fr/common.json

* fix: address additional CodeRabbit review feedback

- GitHubErrorDisplay.tsx:
  - Stop interval when countdown expires (clearInterval on empty formatted)
  - Select specific message keys based on metadata (rateLimitMessageMinutes/Hours, permissionMessageScopes)

- github-error-parser.ts:
  - Tighten REQUIRED_SCOPES_PATTERN to stop at sentence boundaries

- Tests:
  - Update interval test to verify timer count
  - Update permission tests to avoid duplicate text matching
  - Add missing translation mocks for specific message keys

* fix: address final CodeRabbit review feedback

- GitHubErrorDisplay.tsx:
  - Extract getMessageKey to module scope (pure function)
  - Use cn() utility for className merging
  - Add title tooltip to compact variant for full error message

- github-error-parser.ts:
  - Fix extractRateLimitResetTime to handle relative durations ("in X seconds")
  - Separate relative vs absolute timestamp patterns
  - Remove unused RATE_LIMIT_RESET_PATTERN constant

- Tests:
  - Update mock type to Record<string, unknown> for accuracy
  - Add test for empty string error input

* fix: address CodeRabbit review feedback - accessibility and optimization

- GitHubErrorDisplay.tsx:
  - Add role="alert" to compact and full card variants for screen readers
  - Fix minutes/hours calculation to be undefined when <= 0 (avoid stale values)

- github-error-parser.ts:
  - Add optional parsedInfo parameter to convenience predicates
  - Avoids re-classification when caller already has parsed info
  - Updated: isRateLimitError, isAuthError, isNetworkError, isRecoverableError, requiresSettingsAction

- Tests:
  - Add tests for role="alert" accessibility in both full and compact modes

* fix: address CodeRabbit feedback - i18n countdown and pattern order

- GitHubErrorDisplay.tsx:
  - Hoist BASE_MESSAGE_KEYS to module scope to avoid recreation
  - Replace formatCountdown with getCountdownComponents returning numeric values
  - Add formatCountdownDisplay using i18n keys for hours/minutes/seconds

- github-error-parser.ts:
  - Reorder classifyError to check PERMISSION_PATTERNS before NOT_FOUND_PATTERNS
  - Properly classifies 403 responses that might contain "not found" text

- i18n:
  - Add countdownHoursMinutes and countdownMinutesSeconds keys (en/fr)
  - Enables locale-aware countdown formatting

- Tests:
  - Add mock translations for countdown formatting keys

* docs: clarify i18n usage for GitHubErrorInfo message field

- Add comprehensive JSDoc to GitHubErrorInfo interface explaining that
  the `message` field should only be used as i18n fallback defaultValue
- Update parseGitHubError function documentation with translation key
  mapping and proper usage example
- Addresses concern about direct consumers bypassing i18n

Note: role="alert" accessibility fix was already present on both
compact and full card variants (lines 272 and 311).

* fix: address Auto Claude PR review findings

- GitHubErrorDisplay.tsx:
  - Clear stale countdown state when error type changes away from rate_limit
  - Prevents stale countdown data from persisting across error type transitions

- github-error-parser.ts:
  - Add MAX_RESET_SECONDS constant (86400 seconds = 24 hours)
  - Validate relative duration seconds are within reasonable bounds
  - Prevents malformed error strings from creating far-future dates

* fix: address Auto Claude PR review findings - bounds validation and pattern fixes

- Add upper-bound validation (MAX_RESET_SECONDS=86400) on absolute timestamps
  in extractRateLimitResetTime to prevent far-future dates from malformed input
- Remove bare status code patterns (401/403/404) from AUTH_PATTERNS,
  PERMISSION_PATTERNS, and NOT_FOUND_PATTERNS to avoid misclassification
  (e.g., Issue #401 not found classified as auth instead of not_found)
  - STATUS_CODE_PATTERN already handles HTTP-context-aware matching
- Unify time-remaining calculation: compute diffMs once and pass to both
  getMessageKey() and translation interpolation to avoid boundary edge cases
- Fix useEffect dependency: use getTime() instead of Date object reference
  to prevent interval churn when callers pass new GitHubErrorInfo each render

* fix: restore status code classification via HTTP context-aware fallback

- Add 'requires:' pattern to PERMISSION_PATTERNS for scope context matching
- Modify classifyError to accept extracted status code as fallback
- Extract status code before classification to enable fallback logic
- Move status code fallback before network patterns to prioritize HTTP status
  (e.g., 'Network error: HTTP 401' now correctly classifies as auth)
- Preserves protection against bare number false positives while still
  supporting HTTP-context-aware status code classification

* fix: address LOW severity findings - accessibility and dead code

- Add aria-label to compact mode container for screen reader accessibility
  (title attribute alone is not reliably announced by screen readers)
- Simplify RATE_LIMIT_PATTERNS by removing unreachable patterns:
  - /rate\s*limit/i is a superset that matches all rate limit variations
  - Removed redundant: api rate limit exceeded, rate limit exceeded,
    abuse rate limit, secondary rate limit
  - Kept unique patterns: too many requests, 403.*rate

* fix: address PR review findings - pattern precision and helper consistency

MEDIUM fixes:
- Add 'requires authentication' pattern to AUTH_PATTERNS to catch GitHub 401 response
- Narrow permission pattern to match only known OAuth scope names (repo, admin, write,
  read, workflow, org, gist, notification, user, project, package, delete, discussion)
  to avoid misclassifying 'Requires authentication' as permission error

LOW fixes:
- Update STATUS_CODE_PATTERN comment to accurately describe ^ anchor matching behavior
  (matches status codes at string start for formats like '403 Forbidden')
- Fix helper functions (isRateLimitError, isAuthError, isNetworkError,
  isRecoverableError, requiresSettingsAction) to extract and pass status code
  to classifyError for consistent classification with parseGitHubError

* fix: address PR review findings - test coverage and edge cases

- Remove duplicate 'gist' from PERMISSION_PATTERNS regex
- Fix error display visibility during active search
- Extract resetTimeMs for stable useEffect dependency
- Add test coverage for parsedInfo shortcut paths in all 5 helper functions

---------

Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 14:16:24 +01:00
Andy 115576e85d fix(roadmap): sync roadmap features with task lifecycle (#1791)
* feat(roadmap): sync roadmap features with task lifecycle

When a roadmap feature is linked to a task (via linkedSpecId), the feature
now automatically updates when the task is completed, deleted, or archived.
Previously, features would show a broken "Go to Task" button pointing to
non-existent tasks.

- Add taskOutcome field to RoadmapFeature type
- Hook into task status changes (IPC listener) for real-time sync
- Update linked features on task deletion (main process)
- Update linked features on task archival (main process)
- Add startup reconciliation to catch missed updates
- Show status badges instead of broken "Go to Task" buttons
- Use AUTO_BUILD_PATHS constants and writeFileAtomicSync for consistency
- Add i18n translations (en/fr) for task outcome labels

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(roadmap): address PR review findings

- Extract shared updateRoadmapFeatureOutcome utility with file locking
  and retry logic (eliminates duplication between crud-handlers and
  project-store, matches established roadmap-handlers pattern)
- Fix stale Zustand state read in useIpc.ts — re-read state after
  markFeatureDoneBySpecId mutation to persist correct data
- Add .catch() to saveRoadmap call in useIpc.ts for error handling
- Add Archive icon for archived outcome in PhaseCard (consistency with
  FeatureCard, SortableFeatureCard, and FeatureDetailPanel)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(roadmap): address follow-up PR review findings

- Fix relative path bug: use path.join(project.path, AUTO_BUILD_PATHS)
  instead of path.join(autoBuildPath, 'roadmap') which produced relative
  paths causing roadmap updates to silently fail
- Allow taskOutcome transitions on already-done features (e.g.,
  completed→deleted) by relaxing the status check condition
- Extract withFileLock into shared file-lock.ts module so roadmap-utils
  and roadmap-handlers use the same lock map for cross-module coordination
- Show Trash2 icon for deleted tasks in PhaseCard instead of misleading
  green checkmark (visual distinction from completed)
- Remove unused writeFileAtomicSync import from crud-handlers.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(roadmap): extract TaskOutcome type and shared badge component

- Extract TaskOutcome type alias in shared/types/roadmap.ts, replacing
  inline union types across 5 locations (follows codebase convention)
- Create TaskOutcomeBadge shared component with consistent icon/color
  per outcome: completed=CheckCircle2/green, archived=Archive/green,
  deleted=Trash2/muted — eliminates duplicated rendering logic across
  SortableFeatureCard, FeatureCard, FeatureDetailPanel, PhaseCard
- Use text-muted-foreground for deleted outcome instead of misleading
  green success styling in all views

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(roadmap): revert feature state when task is unarchived

When unarchiveTasks() is called, linked roadmap features are now reverted
from status='done'/taskOutcome='archived' back to status='in_progress'
with taskOutcome cleared. Without this, unarchived tasks left their
roadmap features permanently stuck in the archived state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(roadmap): preserve original status on outcome update and fix deletion ordering

- Save previous_status before overwriting to 'done' so unarchive restores
  the correct original status instead of always defaulting to 'in_progress'
- Move roadmap feature update after hasErrors check in task deletion so
  roadmap is only updated on successful deletion

* update to .md

* fix(roadmap): round-trip previous_status and add backend completed handling

- Add previousStatus to RoadmapFeature interface so it survives
  renderer-initiated saves through the ROADMAP_SAVE handler
- Map previous_status in both ROADMAP_GET and ROADMAP_SAVE handlers
- Add backend-side roadmap update on PR creation so completed outcome
  is handled server-side like deleted and archived outcomes

* fix(roadmap): preserve previousStatus in renderer and guard empty task list

- Add previousStatus preservation to markFeatureDoneBySpecId so renderer
  path matches backend behavior for unarchive revert
- Guard reconcileLinkedFeatures against empty task arrays to prevent
  falsely marking all linked features as deleted
- Fix broken code fence in CLAUDE.md (2 backticks → 3)

* fix(roadmap): clear taskOutcome when feature is moved away from done

When dragging a feature out of the 'done' column via Kanban, clear
taskOutcome and previousStatus so stale outcome badges don't persist.

* fix(roadmap): clear task_outcome in IPC handler and add test coverage

- ROADMAP_UPDATE_FEATURE handler now clears task_outcome and
  previous_status when status moves away from done, matching the
  renderer store behavior
- Add tests for markFeatureDoneBySpecId (previousStatus preservation,
  taskOutcome setting, feature isolation)
- Add tests for updateFeatureStatus clearing taskOutcome/previousStatus

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 13:04:57 +01:00
253 changed files with 25362 additions and 3727 deletions
+10
View File
@@ -0,0 +1,10 @@
node_modules
.next
.git
.env*
*.md
tests
scripts
guides
apps/frontend
apps/backend
+165
View File
@@ -0,0 +1,165 @@
# 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
+1
View File
@@ -119,6 +119,7 @@ apps/frontend/node_modules
# Build output
dist/
out/
.next
*.tsbuildinfo
apps/frontend/python-runtime/
+13 -13
View File
@@ -16,17 +16,17 @@
### Stable Release
<!-- STABLE_VERSION_BADGE -->
[![Stable](https://img.shields.io/badge/stable-2.7.5-blue?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.5)
[![Stable](https://img.shields.io/badge/stable-2.7.7-blue?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.7)
<!-- STABLE_VERSION_BADGE_END -->
<!-- STABLE_DOWNLOADS -->
| Platform | Download |
|----------|----------|
| **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) |
| **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) |
| **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 -->
[![Beta](https://img.shields.io/badge/beta-2.7.6--beta.3-orange?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.6-beta.3)
[![Beta](https://img.shields.io/badge/beta-2.7.6--beta.4-orange?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.6-beta.4)
<!-- BETA_VERSION_BADGE_END -->
<!-- BETA_DOWNLOADS -->
| Platform | Download |
|----------|----------|
| **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) |
| **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) |
<!-- BETA_DOWNLOADS_END -->
> All releases include SHA256 checksums and VirusTotal scan results for security verification.
+1 -1
View File
@@ -19,5 +19,5 @@ Quick Start:
See README.md for full documentation.
"""
__version__ = "2.7.6-beta.3"
__version__ = "2.7.7"
__author__ = "Auto Claude Team"
+8
View File
@@ -292,6 +292,14 @@ 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
+4 -15
View File
@@ -186,14 +186,12 @@ 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
@@ -212,20 +210,11 @@ def init_sentry(
logger.debug("[Sentry] No SENTRY_DSN configured - error reporting disabled")
return False
# 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
# 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.
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
+17 -12
View File
@@ -12,7 +12,7 @@ from __future__ import annotations
import json
from dataclasses import dataclass, field
from datetime import datetime
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path
@@ -22,6 +22,11 @@ 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."""
@@ -521,7 +526,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: datetime.now().isoformat())
reviewed_at: str = field(default_factory=lambda: _utc_now_iso())
error: str | None = None
# NEW: Enhanced verdict system
@@ -610,7 +615,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", datetime.now().isoformat()),
reviewed_at=data.get("reviewed_at", _utc_now_iso()),
error=data.get("error"),
# NEW fields
verdict=MergeVerdict(data.get("verdict", "ready_to_merge")),
@@ -691,7 +696,7 @@ class PRReviewResult:
reviews.append(entry)
current_data["reviews"] = reviews
current_data["last_updated"] = datetime.now().isoformat()
current_data["last_updated"] = _utc_now_iso()
return current_data
@@ -762,7 +767,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: datetime.now().isoformat())
triaged_at: str = field(default_factory=lambda: _utc_now_iso())
def to_dict(self) -> dict:
return {
@@ -798,7 +803,7 @@ class TriageResult:
suggested_breakdown=data.get("suggested_breakdown", []),
priority=data.get("priority", "medium"),
comment=data.get("comment"),
triaged_at=data.get("triaged_at", datetime.now().isoformat()),
triaged_at=data.get("triaged_at", _utc_now_iso()),
)
async def save(self, github_dir: Path) -> None:
@@ -836,8 +841,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: datetime.now().isoformat())
updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
created_at: str = field(default_factory=lambda: _utc_now_iso())
updated_at: str = field(default_factory=lambda: _utc_now_iso())
def to_dict(self) -> dict:
return {
@@ -875,8 +880,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", datetime.now().isoformat()),
updated_at=data.get("updated_at", datetime.now().isoformat()),
created_at=data.get("created_at", _utc_now_iso()),
updated_at=data.get("updated_at", _utc_now_iso()),
)
def update_status(self, status: AutoFixStatus) -> None:
@@ -886,7 +891,7 @@ class AutoFixState:
f"Invalid state transition: {self.status.value} -> {status.value}"
)
self.status = status
self.updated_at = datetime.now().isoformat()
self.updated_at = _utc_now_iso()
async def save(self, github_dir: Path) -> None:
"""Save auto-fix state to .auto-claude/github/issues/ with file locking."""
@@ -938,7 +943,7 @@ class AutoFixState:
queue.append(entry)
current_data["auto_fix_queue"] = queue
current_data["last_updated"] = datetime.now().isoformat()
current_data["last_updated"] = _utc_now_iso()
return current_data
@@ -18,7 +18,6 @@ from __future__ import annotations
import hashlib
import logging
import re
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any
@@ -33,6 +32,7 @@ try:
PRReviewResult,
ReviewCategory,
ReviewSeverity,
_utc_now_iso,
)
from .category_utils import map_category
from .io_utils import safe_print
@@ -46,6 +46,7 @@ except (ImportError, ValueError, SystemError):
PRReviewResult,
ReviewCategory,
ReviewSeverity,
_utc_now_iso,
)
from services.category_utils import map_category
from services.io_utils import safe_print
@@ -265,7 +266,7 @@ class FollowupReviewer:
verdict=verdict,
verdict_reasoning=verdict_reasoning,
blockers=blockers,
reviewed_at=datetime.now().isoformat(),
reviewed_at=_utc_now_iso(),
# 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 ParallelFollowupResponse
from .pydantic_models import FollowupExtractionResponse, ParallelFollowupResponse
from .sdk_utils import process_sdk_stream
except (ImportError, ValueError, SystemError):
from context_gatherer import _validate_git_ref
@@ -75,7 +75,10 @@ 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 ParallelFollowupResponse
from services.pydantic_models import (
FollowupExtractionResponse,
ParallelFollowupResponse,
)
from services.sdk_utils import process_sdk_stream
@@ -576,16 +579,36 @@ The SDK will run invoked agents in parallel automatically.
)
# Check for stream processing errors
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']}"
)
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}"
)
result_text = stream_result["result_text"]
structured_output = stream_result["structured_output"]
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"]
)
agents_invoked = stream_result["agents_invoked"]
msg_count = stream_result["msg_count"]
@@ -596,22 +619,28 @@ The SDK will run invoked agents in parallel automatically.
pr_number=context.pr_number,
)
# Parse findings from output
# Parse findings from output (three-tier recovery cascade)
if structured_output:
result_data = self._parse_structured_output(structured_output, context)
else:
# Log when structured output is missing - this shouldn't happen normally
# when output_format is configured, so it indicates a problem
# Structured output missing or validation failed.
# Tier 2: Attempt extraction call with minimal schema
logger.warning(
"[ParallelFollowup] No structured output received from SDK - "
"falling back to text parsing. Resolution data may be incomplete."
"[ParallelFollowup] No structured output — attempting extraction call"
)
safe_print(
"[ParallelFollowup] WARNING: Structured output not captured, "
"using text fallback (resolution tracking may be incomplete)",
flush=True,
# 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
)
result_data = self._parse_text_output(result_text, context)
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)
# Extract data
findings = result_data.get("findings", [])
@@ -730,7 +759,9 @@ 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", []))
dismissed_count = len(
result_data.get("dismissed_false_positive_ids", [])
) or result_data.get("dismissed_finding_count", 0)
confirmed_count = result_data.get("confirmed_valid_count", 0)
needs_human_count = result_data.get("needs_human_review_count", 0)
@@ -1074,17 +1105,129 @@ 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.MERGE_WITH_CHANGES
verdict = MergeVerdict.NEEDS_REVISION
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 {
@@ -1092,8 +1235,13 @@ 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,6 +1785,7 @@ 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:
@@ -710,3 +710,39 @@ 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,6 +133,13 @@ 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.
@@ -261,8 +268,11 @@ 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
@@ -481,6 +491,9 @@ 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`.
@@ -647,11 +660,16 @@ 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,
}
+6 -1
View File
@@ -27,6 +27,8 @@ 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',
@@ -90,7 +92,10 @@ 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')
'@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')
}
},
server: {
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "auto-claude-ui",
"version": "2.7.6-beta.3",
"version": "2.7.7",
"type": "module",
"description": "Desktop UI for Auto Claude autonomous coding framework",
"homepage": "https://github.com/AndyMik90/Auto-Claude",
@@ -51,6 +51,8 @@
"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",
@@ -36,6 +36,8 @@ 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,
@@ -1462,6 +1464,20 @@ 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";
@@ -1525,9 +1541,22 @@ 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");
}
@@ -2907,6 +2936,20 @@ 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);
@@ -2964,9 +3007,22 @@ 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");
}
@@ -3,6 +3,7 @@ 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.
@@ -48,6 +49,7 @@ 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)
...extraEnv,
...getSentryEnvForSubprocess(), // Sentry DSN + sample rates for Python subprocess
...extraEnv, // extraEnv last so callers can still override
};
}
@@ -20,6 +20,7 @@ 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);
@@ -214,6 +215,17 @@ 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*(.+)/;
@@ -337,6 +349,7 @@ export function runPythonSubprocess<T = unknown>(
};
child.stdout.on('data', (data: Buffer) => {
receivedOutput = true;
const text = data.toString('utf-8');
stdout += text;
@@ -364,6 +377,7 @@ export function runPythonSubprocess<T = unknown>(
});
child.stderr.on('data', (data: Buffer) => {
receivedOutput = true;
const text = data.toString('utf-8');
stderr += text;
@@ -382,6 +396,7 @@ 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;
@@ -461,6 +476,7 @@ export function runPythonSubprocess<T = unknown>(
});
child.on('error', (err: Error) => {
clearTimeout(healthCheckTimeout);
options.onError?.(err.message);
resolve({
success: false,
@@ -753,6 +753,8 @@ 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',
-2
View File
@@ -222,7 +222,5 @@ 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 '../../../shared/types/common';
import type { IPCResult } from '@auto-claude/types';
import type { CustomMcpServer, McpHealthCheckResult, McpTestConnectionResult } from '../../../shared/types/project';
export interface McpAPI {
@@ -174,6 +174,8 @@ export function GitHubIssues({ onOpenSettings, onNavigateToTask }: GitHubIssuesP
onSelectIssue={selectIssue}
onInvestigate={handleInvestigate}
onLoadMore={!isSearchActive ? handleLoadMore : undefined}
onRetry={handleRefresh}
onOpenSettings={onOpenSettings}
/>
</div>
@@ -0,0 +1,371 @@
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,8 +1,9 @@
import { useRef, useEffect, useCallback, useState } from 'react';
import { Loader2, AlertCircle } from 'lucide-react';
import { Loader2 } 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';
@@ -15,7 +16,9 @@ export function IssueList({
error,
onSelectIssue,
onInvestigate,
onLoadMore
onLoadMore,
onRetry,
onOpenSettings
}: IssueListProps) {
const { t } = useTranslation('common');
const loadMoreTriggerRef = useRef<HTMLDivElement>(null);
@@ -50,12 +53,12 @@ export function IssueList({
// Load-more errors are shown inline near the load-more trigger
if (error && issues.length === 0) {
return (
<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>
<GitHubErrorDisplay
error={error}
onRetry={onRetry}
onOpenSettings={onOpenSettings}
className="flex-1"
/>
);
}
@@ -85,15 +88,18 @@ 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" />
@@ -0,0 +1,500 @@
/**
* @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,3 +6,4 @@ export { IssueListHeader } from './IssueListHeader';
export { IssueList } from './IssueList';
export { AutoFixButton } from './AutoFixButton';
export { BatchReviewWizard } from './BatchReviewWizard';
export { GitHubErrorDisplay } from './GitHubErrorDisplay';
@@ -3,6 +3,47 @@ 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 */
@@ -76,6 +117,10 @@ 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 {
@@ -0,0 +1,691 @@
/**
* 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();
});
});
});
@@ -0,0 +1,497 @@
/**
* 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,3 +19,13 @@ 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,149 +1 @@
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,
};
export * from '@auto-claude/ui/primitives/alert-dialog';
@@ -1,37 +1 @@
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 };
export * from '@auto-claude/ui/primitives/badge';
@@ -1,64 +1 @@
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 };
export * from '@auto-claude/ui/primitives/button';
@@ -1,58 +1 @@
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 };
export * from '@auto-claude/ui/primitives/card';
@@ -1,36 +1 @@
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 };
export * from '@auto-claude/ui/primitives/checkbox';
@@ -1,9 +1 @@
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
export * from '@auto-claude/ui/primitives/collapsible';
@@ -1,301 +1 @@
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 };
export * from '@auto-claude/ui/primitives/combobox';
@@ -1,126 +1 @@
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,
};
export * from '@auto-claude/ui/primitives/dialog';
@@ -1,201 +1 @@
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,
};
export * from '@auto-claude/ui/primitives/dropdown-menu';
@@ -1,80 +1,22 @@
import React from 'react';
import { AlertTriangle, RefreshCw } from 'lucide-react';
import { Button } from './button';
import { Card, CardContent } from './card';
import type React from 'react';
import { ErrorBoundary as BaseErrorBoundary } from '@auto-claude/ui/primitives/error-boundary';
import { captureException } from '../../lib/sentry';
interface ErrorBoundaryProps {
children: React.ReactNode;
fallback?: React.ReactNode;
onReset?: () => void;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
type BaseErrorBoundaryProps = React.ComponentProps<typeof BaseErrorBoundary>;
/**
* Error boundary component to gracefully handle render errors.
* Prevents the entire page from crashing when a component fails.
* App-level ErrorBoundary that automatically reports errors to Sentry.
* Wraps the shared @auto-claude/ui ErrorBoundary with Sentry integration.
*/
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?.();
export function ErrorBoundary(props: BaseErrorBoundaryProps) {
const handleError = (error: Error, errorInfo: React.ErrorInfo): void => {
captureException(error, { componentStack: errorInfo.componentStack });
props.onError?.(error, errorInfo);
};
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;
}
return (
<BaseErrorBoundary {...props} onError={handleError}>
{props.children}
</BaseErrorBoundary>
);
}
@@ -1,144 +1 @@
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,
};
export * from '@auto-claude/ui/primitives/full-screen-dialog';
@@ -1,16 +1,5 @@
// 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';
// 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';
@@ -1,33 +1 @@
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 };
export * from '@auto-claude/ui/primitives/input';
@@ -1,20 +1 @@
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 };
export * from '@auto-claude/ui/primitives/label';
@@ -1,36 +1 @@
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 };
export * from '@auto-claude/ui/primitives/popover';
@@ -1,32 +1 @@
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 };
export * from '@auto-claude/ui/primitives/progress';
@@ -1,43 +1 @@
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 };
export * from '@auto-claude/ui/primitives/radio-group';
@@ -1,165 +1 @@
/**
* 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>
);
}
export * from '@auto-claude/ui/primitives/resizable-panels';
@@ -1,58 +1 @@
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 };
export * from '@auto-claude/ui/primitives/scroll-area';
@@ -1,166 +1 @@
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,
};
export * from '@auto-claude/ui/primitives/select';
@@ -1,23 +1 @@
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 };
export * from '@auto-claude/ui/primitives/separator';
@@ -1,33 +1 @@
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 };
export * from '@auto-claude/ui/primitives/switch';
@@ -1,57 +1 @@
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 };
export * from '@auto-claude/ui/primitives/tabs';
@@ -1,32 +1 @@
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 };
export * from '@auto-claude/ui/primitives/textarea';
@@ -1,130 +1 @@
/**
* 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,
};
export * from '@auto-claude/ui/primitives/toast';
@@ -1,37 +1 @@
/**
* 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>
);
}
export * from '@auto-claude/ui/primitives/toaster';
@@ -1,31 +1 @@
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 };
export * from '@auto-claude/ui/primitives/tooltip';
+1 -192
View File
@@ -1,192 +1 @@
/**
* 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 };
export { useToast, toast, reducer } from '@auto-claude/ui/primitives/use-toast';
+2 -9
View File
@@ -1,12 +1,5 @@
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));
}
// Re-export cn() from the shared UI package
export { cn } from '@auto-claude/ui';
/**
* Calculate progress percentage from subtasks
File diff suppressed because it is too large Load Diff
@@ -659,6 +659,28 @@
"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,6 +659,28 @@
"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é",
+3 -8
View File
@@ -1,10 +1,5 @@
/**
* Common utility types shared across the application
* Re-exports common types from @auto-claude/types.
* Kept for backwards compatibility prefer importing directly from '@auto-claude/types'.
*/
// IPC Types
export interface IPCResult<T = unknown> {
success: boolean;
data?: T;
error?: string;
}
export type { IPCResult } from '@auto-claude/types';
+10 -15
View File
@@ -1,26 +1,21 @@
/**
* Central export point for all shared types
*
* Re-exports shared platform types from @auto-claude/types,
* plus Electron-specific type definitions.
*/
// Common types
export * from './common';
// Shared platform types
export * from '@auto-claude/types';
// Domain-specific types
export * from './project';
export * from './task';
export * from './kanban';
// Electron-specific types
export * from './terminal';
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 './terminal-session';
export * from './screenshot';
export * from './app-update';
export * from './cli';
export * from './pr-status';
export * from './profile';
export * from './unified-account';
// IPC types (must be last to use types from other modules)
export * from './ipc';
+1 -3
View File
@@ -2,9 +2,7 @@
* IPC (Inter-Process Communication) types for Electron API
*/
import type { IPCResult } from './common';
import type { KanbanPreferences } from './kanban';
import type { SupportedIDE, SupportedTerminal } from './settings';
import type { IPCResult, KanbanPreferences, SupportedIDE, SupportedTerminal } from '@auto-claude/types';
import type {
Project,
ProjectSettings,
+1 -1
View File
@@ -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 libs/profile-service/src/types/profile.ts
* NOTE: These types are intentionally duplicated from packages/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,6 +74,31 @@ 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
+6 -2
View File
@@ -20,9 +20,13 @@
"@features/*": ["src/renderer/features/*"],
"@components/*": ["src/renderer/shared/components/*"],
"@hooks/*": ["src/renderer/shared/hooks/*"],
"@lib/*": ["src/renderer/shared/lib/*"]
"@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/*"]
}
},
"include": ["src/**/*"],
"include": ["src/**/*", "../../packages/types/src/**/*", "../../packages/ui/src/**/*"],
"exclude": ["node_modules", "out", "dist"]
}
+6
View File
@@ -0,0 +1,6 @@
node_modules
.next
.git
.gitignore
README.md
.env*
+68
View File
@@ -0,0 +1,68 @@
# 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"]
+6
View File
@@ -0,0 +1,6 @@
/// <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.
+20
View File
@@ -0,0 +1,20 @@
// 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;
+47
View File
@@ -0,0 +1,47 @@
{
"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"
}
}
+5
View File
@@ -0,0 +1,5 @@
module.exports = {
plugins: {
"@tailwindcss/postcss": {},
},
};
+14
View File
@@ -0,0 +1,14 @@
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}</>;
}
+7
View File
@@ -0,0 +1,7 @@
"use client";
import { AppShell } from "@/components/layout";
export default function DashboardPage() {
return <AppShell />;
}
+298
View File
@@ -0,0 +1,298 @@
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);
});
});
});
@@ -0,0 +1,3 @@
import { handler } from "@/lib/auth-server";
export const { GET, POST } = handler;
+3
View File
@@ -0,0 +1,3 @@
export async function GET() {
return Response.json({ status: "ok" }, { status: 200 });
}
+94
View File
@@ -0,0 +1,94 @@
@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;
}
+39
View File
@@ -0,0 +1,39 @@
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>
);
}
+65
View File
@@ -0,0 +1,65 @@
"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;
}
+76
View File
@@ -0,0 +1,76 @@
"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>
);
}
+78
View File
@@ -0,0 +1,78 @@
"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 />;
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
import { usePRQueue } from "@/hooks";
import { useCurrentUser } from "@/hooks/useCurrentUser";
import { useCloudMode } from "@/hooks/useCloudMode";
import { CloudUpgradeCTA } from "@/components/CloudUpgradeCTA";
import { hasTierAccess } from "@/lib/tiers";
import { formatPrice } from "@/lib/pricing";
import { useTranslation } from "react-i18next";
function CloudPRQueue() {
const user = useCurrentUser();
const { t } = useTranslation(["pages", "common"]);
// PR Queue requires Team tier or higher
const hasTeamAccess = hasTierAccess(user?.tier, "team");
if (!hasTeamAccess) {
return (
<div className="flex h-screen flex-col items-center justify-center gap-4 p-8">
<h1 className="text-2xl font-bold">{t("pages:prQueue.title")}</h1>
<p className="text-gray-600">
{t("pages:prQueue.description")}
</p>
<a
href="/settings"
className="rounded-lg bg-blue-600 px-6 py-3 text-white hover:bg-blue-700"
>
{t("common:buttons.upgrade", { tier: t("common:tiers.team") })} - {formatPrice("team")}
</a>
</div>
);
}
// TODO: Get teamId from user's active team
const teamId = "";
const { prs, isLoading, updateStatus, assignPR } = usePRQueue(teamId);
if (isLoading) {
return <div className="p-8">{t("common:loadingData", { dataType: t("common:navigation.prQueue") })}</div>;
}
return (
<div className="p-8">
<h1 className="text-2xl font-bold">{t("pages:prQueue.title")}</h1>
<div className="mt-8">
{prs.length === 0 ? (
<p className="text-gray-500">{t("pages:prQueue.empty")}</p>
) : (
<ul className="space-y-2">
{prs.map((pr: any) => (
<li key={pr._id} className="rounded-lg border p-4">
<div className="flex items-center justify-between">
<span className="font-semibold">{t("pages:prQueue.prNumber", { number: pr.prNumber })} {pr.title}</span>
<span className="rounded bg-gray-100 px-2 py-1 text-sm">{pr.status}</span>
</div>
</li>
))}
</ul>
)}
</div>
</div>
);
}
export default function PRQueuePage() {
const { isCloud } = useCloudMode();
const { t } = useTranslation("pages");
if (!isCloud) {
return <CloudUpgradeCTA title={t("prQueue.title")} description={t("prQueue.cloudFeature")} />;
}
return <CloudPRQueue />;
}
+195
View File
@@ -0,0 +1,195 @@
"use client";
import { useCurrentUser } from "@/hooks/useCurrentUser";
import { useCloudMode } from "@/hooks/useCloudMode";
import { getConvexReact, getConvexApi } from "@/lib/convex-imports";
import { useState } from "react";
import { hasTierAccess } from "@/lib/tiers";
import { formatPrice, PRICING } from "@/lib/pricing";
import { useTranslation } from "react-i18next";
type Tier = "pro" | "team" | "enterprise";
function SelfHostedSettings() {
const { t } = useTranslation(["settings", "common"]);
return (
<div className="p-8 max-w-3xl">
<h1 className="text-2xl font-bold">{t("settings:title")}</h1>
<section className="mt-8">
<h2 className="text-xl font-semibold">{t("settings:selfHosted.mode")}</h2>
<p className="mt-2 text-gray-600">
{t("settings:selfHosted.description")}
</p>
</section>
<section className="mt-8 rounded-lg border border-blue-200 bg-blue-50 p-6">
<h2 className="text-lg font-semibold">{t("settings:selfHosted.unlockFeatures")}</h2>
<p className="mt-2 text-gray-600">
{t("settings:selfHosted.featuresDescription")}
</p>
<a
href="https://autoclaude.com"
className="mt-4 inline-block rounded-lg bg-blue-600 px-6 py-3 text-white hover:bg-blue-700"
>
{t("common:buttons.learnMore")}
</a>
</section>
</div>
);
}
function CloudSettings() {
const { useAction, useQuery } = getConvexReact();
const { api } = getConvexApi();
const { t } = useTranslation(["settings", "common", "auth"]);
const user = useCurrentUser();
const tierInfo = useQuery(api.billing.getTierInfo);
const createCheckout = useAction(api.stripe.createCheckoutSession);
const createPortal = useAction(api.stripe.createPortalSession);
const [loading, setLoading] = useState<Tier | "portal" | null>(null);
const logout = async () => {
const { authClient } = await import("@/lib/auth-client");
authClient.signOut();
};
const handleUpgrade = async (tier: Tier) => {
setLoading(tier);
try {
const url = await createCheckout({ tier });
window.location.href = url;
} catch (error) {
console.error("Checkout error:", error);
alert(t("common:errors.checkoutFailed"));
setLoading(null);
}
};
const handleManageSubscription = async () => {
setLoading("portal");
try {
const url = await createPortal();
window.location.href = url;
} catch (error) {
console.error("Portal error:", error);
alert(t("common:errors.portalFailed"));
setLoading(null);
}
};
if (!user) {
return <div className="p-8">{t("common:errors.loginRequired")}</div>;
}
return (
<div className="p-8 max-w-3xl">
<h1 className="text-2xl font-bold">{t("settings:title")}</h1>
{/* Profile Section */}
<section className="mt-8">
<h2 className="text-xl font-semibold">{t("settings:profile.title")}</h2>
<div className="mt-4 space-y-2">
<p><strong>{t("settings:profile.name")}</strong> {user.name}</p>
<p><strong>{t("settings:profile.email")}</strong> {user.email}</p>
</div>
</section>
{/* Billing Section */}
<section className="mt-8">
<h2 className="text-xl font-semibold">{t("settings:subscription.title")}</h2>
<div className="mt-4">
<p>
<strong>{t("settings:subscription.currentPlan")}</strong>{" "}
{tierInfo?.tierName ?? (user.tier ? user.tier.charAt(0).toUpperCase() + user.tier.slice(1) : t("common:tiers.free"))}
{tierInfo?.price ? `${t("settings:subscription.price", { price: tierInfo.price })}` : ""}
</p>
{/* Usage */}
{tierInfo && (
<div className="mt-4 space-y-1 text-sm text-gray-600">
<p>
{t("settings:subscription.usage.specs", {
current: tierInfo.usage.specs,
limit: tierInfo.limits.specs === -1 ? t("settings:subscription.usage.unlimited") : tierInfo.limits.specs
})}
</p>
<p>
{t("settings:subscription.usage.personas", {
current: tierInfo.usage.personas,
limit: tierInfo.limits.personas === -1 ? t("settings:subscription.usage.unlimited") : tierInfo.limits.personas
})}
</p>
<p>
{t("settings:subscription.usage.teamMembers", {
current: tierInfo.usage.teamMembers,
limit: tierInfo.limits.teamMembers === -1 ? t("settings:subscription.usage.unlimited") : tierInfo.limits.teamMembers
})}
</p>
</div>
)}
{/* Upgrade buttons */}
<div className="mt-4 flex gap-4">
{!hasTierAccess(user.tier, "pro") && (
<button
onClick={() => handleUpgrade("pro")}
disabled={loading !== null}
className="rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading === "pro" ? t("common:buttons.redirecting") : `${t("common:buttons.upgrade", { tier: t("common:tiers.pro") })} - ${formatPrice("pro")}`}
</button>
)}
{!hasTierAccess(user.tier, "team") && (
<button
onClick={() => handleUpgrade("team")}
disabled={loading !== null}
className="rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading === "team" ? t("common:buttons.redirecting") : `${t("common:buttons.upgrade", { tier: t("common:tiers.team") })} - ${formatPrice("team")}`}
</button>
)}
{!hasTierAccess(user.tier, "enterprise") && (
<button
onClick={() => handleUpgrade("enterprise")}
disabled={loading !== null}
className="rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading === "enterprise" ? t("common:buttons.redirecting") : `${t("common:tiers.enterprise")} - ${formatPrice("enterprise")}`}
</button>
)}
{hasTierAccess(user.tier, "pro") && (
<button
onClick={handleManageSubscription}
disabled={loading !== null}
className="rounded-lg border px-4 py-2 hover:bg-gray-50 disabled:opacity-50"
>
{loading === "portal" ? t("common:buttons.redirecting") : t("common:buttons.manageSubscription")}
</button>
)}
</div>
</div>
</section>
{/* Sign Out */}
<section className="mt-8">
<button
onClick={logout}
className="rounded-lg border border-red-600 px-4 py-2 text-red-600 hover:bg-red-50"
>
{t("auth:signOut")}
</button>
</section>
</div>
);
}
export default function SettingsPage() {
const { isCloud } = useCloudMode();
if (!isCloud) {
return <SelfHostedSettings />;
}
return <CloudSettings />;
}
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { useSpec } from "@/hooks";
import { useParams } from "next/navigation";
export default function SpecPage() {
const params = useParams();
const specId = params.id as string;
const { spec, isLoading } = useSpec(specId);
if (isLoading) {
return <div className="p-8">Loading spec...</div>;
}
if (!spec) {
return <div className="p-8">Spec not found</div>;
}
return (
<div className="p-8">
<h1 className="text-2xl font-bold">{(spec as any).name}</h1>
<pre className="mt-4 rounded-lg bg-gray-100 p-4">
{(spec as any).content}
</pre>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { useSpecs } from "@/hooks";
import { useTranslation } from "react-i18next";
export default function SpecsPage() {
const { specs, isLoading, createSpec } = useSpecs();
const { t } = useTranslation(["pages", "common"]);
if (isLoading) {
return <div className="p-8">{t("common:loadingData", { dataType: t("common:navigation.specs") })}</div>;
}
return (
<div className="p-8">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{t("pages:specs.title")}</h1>
<button
onClick={() => createSpec("New Spec", "# New Spec\n\nDescription here...")}
className="rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
>
{t("pages:specs.createButton")}
</button>
</div>
<div className="mt-8">
{specs.length === 0 ? (
<p className="text-gray-500">{t("pages:specs.empty")}</p>
) : (
<ul className="space-y-2">
{specs.map((spec: any) => (
<li key={spec._id}>
<a
href={`/specs/${spec._id}`}
className="block rounded-lg border p-4 hover:bg-gray-50"
>
{spec.name}
</a>
</li>
))}
</ul>
)}
</div>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { useTeam, useTeamMembers } from "@/hooks";
import { useParams } from "next/navigation";
export default function TeamPage() {
const params = useParams();
const teamId = params.id as string;
const { team, isLoading } = useTeam(teamId);
const { members, inviteMember, removeMember } = useTeamMembers(teamId);
if (isLoading) {
return <div className="p-8">Loading team...</div>;
}
if (!team) {
return <div className="p-8">Team not found</div>;
}
return (
<div className="p-8">
<h1 className="text-2xl font-bold">{(team as any).name}</h1>
<h2 className="mt-8 text-xl font-semibold">Members</h2>
<ul className="mt-4 space-y-2">
{members.map((member: any) => (
<li key={member._id} className="flex items-center justify-between rounded-lg border p-4">
<span>{member.userId}</span>
<span className="text-gray-500">{member.role}</span>
</li>
))}
</ul>
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
"use client";
import { useTeams } from "@/hooks";
import { useCurrentUser } from "@/hooks/useCurrentUser";
import { useCloudMode } from "@/hooks/useCloudMode";
import { CloudUpgradeCTA } from "@/components/CloudUpgradeCTA";
import { hasTierAccess } from "@/lib/tiers";
import { formatPrice } from "@/lib/pricing";
import { useTranslation } from "react-i18next";
function CloudTeams() {
const user = useCurrentUser();
const { teams, isLoading, createTeam } = useTeams();
const { t } = useTranslation(["pages", "common"]);
// Team feature requires Team tier or higher
const hasTeamAccess = hasTierAccess(user?.tier, "team");
if (!hasTeamAccess) {
return (
<div className="flex h-screen flex-col items-center justify-center gap-4 p-8">
<h1 className="text-2xl font-bold">{t("pages:teams.singleTitle")}</h1>
<p className="text-gray-600">{t("pages:teams.description")}</p>
<a
href="/settings"
className="rounded-lg bg-blue-600 px-6 py-3 text-white hover:bg-blue-700"
>
{t("common:buttons.upgrade", { tier: t("common:tiers.team") })} - {formatPrice("team")}
</a>
</div>
);
}
if (isLoading) {
return <div className="p-8">{t("common:loadingData", { dataType: t("common:navigation.teams") })}</div>;
}
return (
<div className="p-8">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{t("pages:teams.title")}</h1>
<button
onClick={() => createTeam("New Team")}
className="rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
>
{t("pages:teams.createButton")}
</button>
</div>
<div className="mt-8">
{teams.length === 0 ? (
<p className="text-gray-500">{t("pages:teams.empty")}</p>
) : (
<ul className="space-y-2">
{teams.map((team: any) => (
<li key={team._id}>
<a
href={`/teams/${team._id}`}
className="block rounded-lg border p-4 hover:bg-gray-50"
>
{team.name}
</a>
</li>
))}
</ul>
)}
</div>
</div>
);
}
export default function TeamsPage() {
const { isCloud } = useCloudMode();
const { t } = useTranslation("pages");
if (!isCloud) {
return <CloudUpgradeCTA title={t("teams.singleTitle")} description={t("teams.cloudFeature")} />;
}
return <CloudTeams />;
}
@@ -0,0 +1,17 @@
"use client";
import { useTranslation } from "react-i18next";
export function CloudUpgradeCTA({ title, description }: { title: string; description: string }) {
const { t } = useTranslation("pages");
return (
<div className="flex h-screen flex-col items-center justify-center gap-4 p-8">
<h1 className="text-2xl font-bold">{title}</h1>
<p className="text-gray-600">{description}</p>
<a href="https://autoclaude.com" className="rounded-lg bg-blue-600 px-6 py-3 text-white hover:bg-blue-700">
{t("cloudUpgrade.learnMore")}
</a>
</div>
);
}
@@ -0,0 +1,201 @@
"use client";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
FileText,
RefreshCw,
Sparkles,
Tag,
Calendar,
ChevronDown,
ChevronRight,
Plus,
CheckCircle2,
GitPullRequest,
} from "lucide-react";
import { cn } from "@auto-claude/ui";
interface ChangelogViewProps {
projectId: string;
}
interface ChangelogEntry {
id: string;
version: string;
date: string;
title: string;
changes: {
type: "added" | "changed" | "fixed" | "removed";
description: string;
}[];
isExpanded: boolean;
}
const CHANGE_TYPE_COLORS = {
added: "bg-green-500/10 text-green-600",
changed: "bg-blue-500/10 text-blue-600",
fixed: "bg-orange-500/10 text-orange-600",
removed: "bg-red-500/10 text-red-600",
};
const CHANGE_TYPE_KEYS: Record<string, string> = {
added: "changelog.changeTypes.added",
changed: "changelog.changeTypes.changed",
fixed: "changelog.changeTypes.fixed",
removed: "changelog.changeTypes.removed",
};
const PLACEHOLDER_ENTRIES: ChangelogEntry[] = [
{
id: "1",
version: "2.7.7",
date: "2025-02-14",
title: "Cloud Platform Preparation",
changes: [
{ type: "added", description: "Shared UI component library extracted to @auto-claude/ui" },
{ type: "added", description: "Shared TypeScript types package @auto-claude/types" },
{ type: "added", description: "Web application foundation with Next.js 16" },
{ type: "changed", description: "Migrated from libs/ to packages/ directory structure" },
],
isExpanded: true,
},
{
id: "2",
version: "2.7.6",
date: "2025-02-10",
title: "Stability Improvements",
changes: [
{ type: "fixed", description: "PR review recovery for structured output validation" },
{ type: "fixed", description: "Sentry integration for Python subprocesses" },
{ type: "changed", description: "Improved task execution progress tracking" },
],
isExpanded: false,
},
{
id: "3",
version: "2.7.5",
date: "2025-02-05",
title: "OAuth and Security Updates",
changes: [
{ type: "added", description: "Claude OAuth authentication support" },
{ type: "fixed", description: "Session management for multi-profile setups" },
{ type: "changed", description: "Re-authentication flow for improved security" },
],
isExpanded: false,
},
];
export function ChangelogView({ projectId }: ChangelogViewProps) {
const { t } = useTranslation("views");
const [entries, setEntries] = useState(PLACEHOLDER_ENTRIES);
const [isEmpty] = useState(false);
const toggleEntry = (id: string) => {
setEntries((prev) =>
prev.map((e) => (e.id === id ? { ...e, isExpanded: !e.isExpanded } : e))
);
};
if (isEmpty) {
return (
<div className="flex h-full flex-col items-center justify-center p-8">
<div className="max-w-md text-center">
<div className="mb-6 flex justify-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10">
<FileText className="h-8 w-8 text-primary" />
</div>
</div>
<h2 className="mb-3 text-xl font-semibold">{t("changelog.empty.title")}</h2>
<p className="mb-6 text-sm text-muted-foreground">
{t("changelog.empty.description")}
</p>
<button className="flex items-center gap-2 mx-auto rounded-lg bg-primary px-6 py-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors">
<Sparkles className="h-4 w-4" />
{t("changelog.empty.generate")}
</button>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-6 py-3">
<h1 className="text-lg font-semibold">{t("changelog.title")}</h1>
<div className="flex items-center gap-2">
<button className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors">
<RefreshCw className="h-3.5 w-3.5" />
{t("changelog.refresh")}
</button>
<button className="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90 transition-colors">
<Plus className="h-3.5 w-3.5" />
{t("changelog.newRelease")}
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-3xl mx-auto space-y-4">
{entries.map((entry) => (
<div
key={entry.id}
className="rounded-lg border border-border bg-card overflow-hidden"
>
<button
className="w-full flex items-center gap-3 px-5 py-4 hover:bg-accent/50 transition-colors text-left"
onClick={() => toggleEntry(entry.id)}
>
{entry.isExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
)}
<div className="flex items-center gap-2.5 flex-1">
<Tag className="h-4 w-4 text-primary shrink-0" />
<span className="font-mono text-sm font-semibold">
v{entry.version}
</span>
<span className="text-sm text-muted-foreground">
{entry.title}
</span>
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground shrink-0">
<Calendar className="h-3 w-3" />
{entry.date}
</div>
</button>
{entry.isExpanded && (
<div className="border-t border-border px-5 py-4">
<div className="space-y-2">
{entry.changes.map((change, idx) => {
const color = CHANGE_TYPE_COLORS[change.type];
return (
<div key={idx} className="flex items-start gap-2">
<span
className={cn(
"shrink-0 mt-0.5 rounded-full px-2 py-0.5 text-[10px] font-semibold",
color
)}
>
{t(CHANGE_TYPE_KEYS[change.type])}
</span>
<p className="text-sm text-muted-foreground">
{change.description}
</p>
</div>
);
})}
</div>
</div>
)}
</div>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,249 @@
"use client";
import { useState } from "react";
import {
BookOpen,
FolderTree,
Database,
Brain,
Server,
Code,
RefreshCw,
ChevronDown,
ChevronRight,
FileCode,
Globe,
Box,
Search,
} from "lucide-react";
import { cn } from "@auto-claude/ui";
import { useTranslation } from "react-i18next";
interface ContextViewProps {
projectId: string;
}
interface ServiceInfo {
name: string;
language: string;
framework: string;
type: string;
path: string;
}
const PLACEHOLDER_SERVICES: ServiceInfo[] = [
{ name: "frontend", language: "TypeScript", framework: "React", type: "frontend", path: "apps/frontend/" },
{ name: "web", language: "TypeScript", framework: "Next.js", type: "frontend", path: "apps/web/" },
{ name: "backend", language: "Python", framework: "FastAPI", type: "backend", path: "apps/backend/" },
{ name: "types", language: "TypeScript", framework: "N/A", type: "library", path: "packages/types/" },
{ name: "ui", language: "TypeScript", framework: "React", type: "library", path: "packages/ui/" },
];
const TYPE_ICONS: Record<string, React.ElementType> = {
frontend: Globe,
backend: Server,
library: Box,
worker: Code,
};
export function ContextView({ projectId }: ContextViewProps) {
const { t } = useTranslation("integrations");
const [activeTab, setActiveTab] = useState<"overview" | "services" | "memories">("overview");
const [searchQuery, setSearchQuery] = useState("");
return (
<div className="flex h-full flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-6 py-3">
<div className="flex items-center gap-3">
<h1 className="text-lg font-semibold">{t("context.title")}</h1>
<div className="flex items-center rounded-lg border border-border bg-card/50">
<button
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors rounded-l-lg",
activeTab === "overview" ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground"
)}
onClick={() => setActiveTab("overview")}
>
<FolderTree className="h-3 w-3" />
{t("context.tabs.overview")}
</button>
<button
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors",
activeTab === "services" ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground"
)}
onClick={() => setActiveTab("services")}
>
<Server className="h-3 w-3" />
{t("context.tabs.services")}
</button>
<button
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors rounded-r-lg",
activeTab === "memories" ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground"
)}
onClick={() => setActiveTab("memories")}
>
<Brain className="h-3 w-3" />
{t("context.tabs.memories")}
</button>
</div>
</div>
<button className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors">
<RefreshCw className="h-3.5 w-3.5" />
{t("context.reindex")}
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{activeTab === "overview" && (
<div className="max-w-4xl mx-auto space-y-6">
{/* Project Type */}
<div className="rounded-lg border border-border bg-card p-5">
<div className="flex items-center gap-2 mb-4">
<FolderTree className="h-4 w-4 text-primary" />
<h2 className="text-sm font-semibold">{t("context.fields.projectStructure")}</h2>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="rounded-md border border-border p-3">
<p className="text-xs text-muted-foreground">{t("context.fields.type")}</p>
<p className="text-sm font-medium">{t("context.fields.monorepo")}</p>
</div>
<div className="rounded-md border border-border p-3">
<p className="text-xs text-muted-foreground">{t("context.fields.services")}</p>
<p className="text-sm font-medium">{PLACEHOLDER_SERVICES.length}</p>
</div>
</div>
</div>
{/* Services Summary */}
<div className="rounded-lg border border-border bg-card p-5">
<div className="flex items-center gap-2 mb-4">
<Server className="h-4 w-4 text-primary" />
<h2 className="text-sm font-semibold">{t("context.tabs.services")}</h2>
</div>
<div className="space-y-2">
{PLACEHOLDER_SERVICES.map((service) => {
const Icon = TYPE_ICONS[service.type] || Code;
return (
<div
key={service.name}
className="flex items-center gap-3 rounded-md border border-border p-3 hover:bg-accent/50 transition-colors cursor-pointer"
>
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{service.name}</p>
<p className="text-xs text-muted-foreground">
{service.path}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="rounded-full bg-secondary px-2 py-0.5 text-[10px] text-muted-foreground">
{service.language}
</span>
<span className="rounded-full bg-secondary px-2 py-0.5 text-[10px] text-muted-foreground">
{service.framework}
</span>
</div>
</div>
);
})}
</div>
</div>
{/* Memory Status */}
<div className="rounded-lg border border-border bg-card p-5">
<div className="flex items-center gap-2 mb-4">
<Brain className="h-4 w-4 text-primary" />
<h2 className="text-sm font-semibold">{t("context.fields.memorySystem")}</h2>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="rounded-md border border-border p-3">
<p className="text-xs text-muted-foreground">{t("context.fields.status")}</p>
<p className="text-sm font-medium text-green-600">{t("context.fields.active")}</p>
</div>
<div className="rounded-md border border-border p-3">
<p className="text-xs text-muted-foreground">{t("context.fields.episodes")}</p>
<p className="text-sm font-medium">0</p>
</div>
<div className="rounded-md border border-border p-3">
<p className="text-xs text-muted-foreground">{t("context.fields.database")}</p>
<p className="text-sm font-medium">{t("context.fields.ladybugDB")}</p>
</div>
</div>
</div>
</div>
)}
{activeTab === "services" && (
<div className="max-w-4xl mx-auto">
<div className="space-y-4">
{PLACEHOLDER_SERVICES.map((service) => {
const Icon = TYPE_ICONS[service.type] || Code;
return (
<div key={service.name} className="rounded-lg border border-border bg-card p-5">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-secondary">
<Icon className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<h3 className="text-sm font-semibold">{service.name}</h3>
<p className="text-xs text-muted-foreground">{service.path}</p>
</div>
</div>
<div className="grid grid-cols-4 gap-3">
<div className="rounded-md border border-border p-2.5">
<p className="text-[10px] text-muted-foreground">{t("context.fields.language")}</p>
<p className="text-xs font-medium">{service.language}</p>
</div>
<div className="rounded-md border border-border p-2.5">
<p className="text-[10px] text-muted-foreground">{t("context.fields.framework")}</p>
<p className="text-xs font-medium">{service.framework}</p>
</div>
<div className="rounded-md border border-border p-2.5">
<p className="text-[10px] text-muted-foreground">{t("context.fields.type")}</p>
<p className="text-xs font-medium capitalize">{service.type}</p>
</div>
<div className="rounded-md border border-border p-2.5">
<p className="text-[10px] text-muted-foreground">{t("context.fields.path")}</p>
<p className="text-xs font-medium truncate">{service.path}</p>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{activeTab === "memories" && (
<div className="max-w-3xl mx-auto">
{/* Search */}
<div className="mb-6">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
className="w-full rounded-lg border border-border bg-background pl-10 pr-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
placeholder={t("context.search.memories")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
{/* Empty state */}
<div className="flex flex-col items-center justify-center py-16 text-center">
<Brain className="h-12 w-12 text-muted-foreground/50 mb-4" />
<h3 className="text-sm font-semibold mb-1">{t("context.empty.noMemories")}</h3>
<p className="text-xs text-muted-foreground max-w-sm">
{t("context.empty.noMemoriesDescription")}
</p>
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,213 @@
"use client";
import { useState } from "react";
import {
Github,
Search,
RefreshCw,
ExternalLink,
MessageSquare,
Tag,
Settings,
Filter,
AlertCircle,
ArrowRight,
} from "lucide-react";
import { cn } from "@auto-claude/ui";
import { useTranslation } from "react-i18next";
interface GitHubIssuesViewProps {
projectId: string;
}
interface GitHubIssue {
number: number;
title: string;
state: "open" | "closed";
labels: { name: string; color: string }[];
author: string;
comments: number;
createdAt: string;
body: string;
}
const PLACEHOLDER_ISSUES: GitHubIssue[] = [
{
number: 42,
title: "Add dark mode support for mobile views",
state: "open",
labels: [{ name: "enhancement", color: "a2eeef" }, { name: "ui", color: "7057ff" }],
author: "user1",
comments: 3,
createdAt: "2025-02-10",
body: "The mobile views don't properly support dark mode...",
},
{
number: 41,
title: "Fix authentication token refresh race condition",
state: "open",
labels: [{ name: "bug", color: "d73a4a" }, { name: "priority:high", color: "e11d48" }],
author: "user2",
comments: 7,
createdAt: "2025-02-08",
body: "When multiple API calls happen simultaneously...",
},
{
number: 39,
title: "Implement webhook support for external integrations",
state: "open",
labels: [{ name: "feature", color: "0075ca" }],
author: "user3",
comments: 2,
createdAt: "2025-02-05",
body: "We need webhook endpoints for...",
},
];
export function GitHubIssuesView({ projectId }: GitHubIssuesViewProps) {
const { t } = useTranslation("integrations");
const [issues] = useState(PLACEHOLDER_ISSUES);
const [selectedIssue, setSelectedIssue] = useState<GitHubIssue | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [isConnected] = useState(true);
if (!isConnected) {
return (
<div className="flex h-full flex-col items-center justify-center p-8">
<div className="max-w-md text-center">
<div className="mb-6 flex justify-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-secondary">
<Github className="h-8 w-8 text-muted-foreground" />
</div>
</div>
<h2 className="mb-3 text-xl font-semibold">{t("github.issues.notConnected")}</h2>
<p className="mb-6 text-sm text-muted-foreground">
{t("github.issues.notConnectedDescription")}
</p>
<button className="flex items-center gap-2 mx-auto rounded-lg bg-primary px-6 py-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors">
<Settings className="h-4 w-4" />
{t("github.issues.configure")}
</button>
</div>
</div>
);
}
return (
<div className="flex h-full overflow-hidden">
{/* Issue List */}
<div className={cn("flex flex-col border-r border-border", selectedIssue ? "w-96" : "flex-1")}>
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h1 className="text-sm font-semibold flex items-center gap-2">
<Github className="h-4 w-4" />
{t("github.issues.title")}
</h1>
<button className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent transition-colors">
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
{/* Search */}
<div className="border-b border-border px-4 py-2">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<input
className="w-full rounded-md border border-border bg-background pl-8 pr-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-primary/20"
placeholder={t("github.issues.search")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
{/* Issue list */}
<div className="flex-1 overflow-y-auto">
{issues.map((issue) => (
<div
key={issue.number}
className={cn(
"flex items-start gap-3 border-b border-border px-4 py-3 cursor-pointer transition-colors",
selectedIssue?.number === issue.number ? "bg-accent" : "hover:bg-accent/50"
)}
onClick={() => setSelectedIssue(issue)}
>
<AlertCircle className="h-4 w-4 text-green-500 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium leading-tight">
#{issue.number} {issue.title}
</p>
<div className="mt-1 flex items-center gap-2 flex-wrap">
{issue.labels.map((label) => (
<span
key={label.name}
className="rounded-full border border-border px-2 py-0.5 text-[10px]"
style={{ borderColor: `#${label.color}40`, color: `#${label.color}` }}
>
{label.name}
</span>
))}
</div>
<div className="mt-1 flex items-center gap-3 text-[10px] text-muted-foreground">
<span>{issue.author}</span>
<span>{issue.createdAt}</span>
{issue.comments > 0 && (
<span className="flex items-center gap-0.5">
<MessageSquare className="h-2.5 w-2.5" />
{issue.comments}
</span>
)}
</div>
</div>
</div>
))}
</div>
</div>
{/* Issue Detail */}
{selectedIssue && (
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-border px-6 py-3">
<h2 className="text-sm font-semibold">
#{selectedIssue.number} {selectedIssue.title}
</h2>
<div className="flex items-center gap-2">
<a
href={`https://github.com/issues/${selectedIssue.number}`}
target="_blank"
rel="noopener noreferrer"
className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent transition-colors"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
</div>
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-2xl space-y-4">
<div className="flex items-center gap-2 flex-wrap">
{selectedIssue.labels.map((label) => (
<span
key={label.name}
className="rounded-full border px-2.5 py-0.5 text-xs"
style={{ borderColor: `#${label.color}40`, color: `#${label.color}` }}
>
{label.name}
</span>
))}
</div>
<div className="text-sm text-muted-foreground whitespace-pre-wrap">
{selectedIssue.body}
</div>
<div className="pt-4 border-t border-border">
<button className="flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 transition-colors">
<ArrowRight className="h-3.5 w-3.5" />
{t("github.issues.createTask")}
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,283 @@
"use client";
import { useState } from "react";
import {
GitPullRequest,
Search,
RefreshCw,
ExternalLink,
MessageSquare,
CheckCircle2,
XCircle,
Clock,
GitMerge,
Settings,
Eye,
} from "lucide-react";
import { cn } from "@auto-claude/ui";
import { useTranslation } from "react-i18next";
interface GitHubPRsViewProps {
projectId: string;
}
interface PullRequest {
number: number;
title: string;
state: "open" | "closed" | "merged";
author: string;
reviewStatus: "pending" | "approved" | "changes_requested" | "reviewing";
additions: number;
deletions: number;
files: number;
createdAt: string;
labels: { name: string; color: string }[];
draft: boolean;
}
const PLACEHOLDER_PRS: PullRequest[] = [
{
number: 1783,
title: "Cloud Phase 1: Extract shared types and UI packages",
state: "merged",
author: "dev1",
reviewStatus: "approved",
additions: 2500,
deletions: 150,
files: 45,
createdAt: "2025-02-12",
labels: [{ name: "cloud", color: "0075ca" }],
draft: false,
},
{
number: 1804,
title: "Fix Sentry integration for Python subprocesses",
state: "merged",
author: "dev2",
reviewStatus: "approved",
additions: 120,
deletions: 30,
files: 5,
createdAt: "2025-02-11",
labels: [{ name: "bug", color: "d73a4a" }],
draft: false,
},
{
number: 1810,
title: "Add real-time task progress WebSocket support",
state: "open",
author: "dev1",
reviewStatus: "pending",
additions: 890,
deletions: 45,
files: 12,
createdAt: "2025-02-13",
labels: [{ name: "feature", color: "a2eeef" }],
draft: false,
},
];
const STATE_ICONS: Record<string, React.ElementType> = {
open: GitPullRequest,
merged: GitMerge,
closed: XCircle,
};
const STATE_COLORS: Record<string, string> = {
open: "text-green-500",
merged: "text-purple-500",
closed: "text-red-500",
};
const REVIEW_STATUS_COLORS: Record<string, string> = {
pending: "bg-yellow-500/10 text-yellow-600",
approved: "bg-green-500/10 text-green-600",
changes_requested: "bg-red-500/10 text-red-600",
reviewing: "bg-blue-500/10 text-blue-600",
};
const REVIEW_STATUS_KEYS: Record<string, string> = {
pending: "github.prs.reviews.pending",
approved: "github.prs.reviews.approved",
changes_requested: "github.prs.reviews.changesRequested",
reviewing: "github.prs.reviews.inReview",
};
export function GitHubPRsView({ projectId }: GitHubPRsViewProps) {
const { t } = useTranslation("integrations");
const [prs] = useState(PLACEHOLDER_PRS);
const [selectedPR, setSelectedPR] = useState<PullRequest | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [filter, setFilter] = useState<"all" | "open" | "merged" | "closed">("all");
const filteredPRs = prs.filter((pr) => {
if (filter !== "all" && pr.state !== filter) return false;
if (searchQuery && !pr.title.toLowerCase().includes(searchQuery.toLowerCase())) return false;
return true;
});
return (
<div className="flex h-full overflow-hidden">
{/* PR List */}
<div className={cn("flex flex-col border-r border-border", selectedPR ? "w-96" : "flex-1")}>
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h1 className="text-sm font-semibold flex items-center gap-2">
<GitPullRequest className="h-4 w-4" />
{t("github.prs.title")}
</h1>
<button className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent transition-colors">
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
{/* Filters */}
<div className="border-b border-border px-4 py-2 space-y-2">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<input
className="w-full rounded-md border border-border bg-background pl-8 pr-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-primary/20"
placeholder={t("github.prs.search")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="flex items-center gap-1">
{(["all", "open", "merged", "closed"] as const).map((f) => (
<button
key={f}
className={cn(
"rounded-full px-2.5 py-1 text-[10px] font-medium transition-colors",
filter === f ? "bg-primary text-primary-foreground" : "bg-secondary text-muted-foreground hover:text-foreground"
)}
onClick={() => setFilter(f)}
>
{t(`github.prs.filters.${f}`)}
</button>
))}
</div>
</div>
{/* PR list */}
<div className="flex-1 overflow-y-auto">
{filteredPRs.map((pr) => {
const StateIcon = STATE_ICONS[pr.state] || GitPullRequest;
const stateColor = STATE_COLORS[pr.state] || "text-muted-foreground";
const reviewColor = REVIEW_STATUS_COLORS[pr.reviewStatus];
const reviewKey = REVIEW_STATUS_KEYS[pr.reviewStatus];
return (
<div
key={pr.number}
className={cn(
"flex items-start gap-3 border-b border-border px-4 py-3 cursor-pointer transition-colors",
selectedPR?.number === pr.number ? "bg-accent" : "hover:bg-accent/50"
)}
onClick={() => setSelectedPR(pr)}
>
<StateIcon className={cn("h-4 w-4 mt-0.5 shrink-0", stateColor)} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium leading-tight">
#{pr.number} {pr.title}
</p>
<div className="mt-1 flex items-center gap-2 flex-wrap">
{pr.labels.map((label) => (
<span
key={label.name}
className="rounded-full border px-2 py-0.5 text-[10px]"
style={{ borderColor: `#${label.color}40`, color: `#${label.color}` }}
>
{label.name}
</span>
))}
<span className={cn("rounded-full px-2 py-0.5 text-[10px] font-medium", reviewColor)}>
{t(reviewKey)}
</span>
</div>
<div className="mt-1 flex items-center gap-3 text-[10px] text-muted-foreground">
<span>{pr.author}</span>
<span>{pr.createdAt}</span>
<span className="text-green-600">+{pr.additions}</span>
<span className="text-red-600">-{pr.deletions}</span>
<span>{t("github.prs.stats.filesCount", { count: pr.files })}</span>
</div>
</div>
</div>
);
})}
</div>
</div>
{/* PR Detail */}
{selectedPR && (
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-border px-6 py-3">
<h2 className="text-sm font-semibold">
#{selectedPR.number} {selectedPR.title}
</h2>
<div className="flex items-center gap-2">
<button className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground transition-colors">
<Eye className="h-3 w-3" />
{t("github.prs.aiReview")}
</button>
<a
href="#"
target="_blank"
rel="noopener noreferrer"
className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent transition-colors"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
</div>
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-2xl space-y-4">
{/* Stats */}
<div className="grid grid-cols-4 gap-3">
<div className="rounded-md border border-border p-3 text-center">
<p className="text-lg font-semibold text-green-600">+{selectedPR.additions}</p>
<p className="text-[10px] text-muted-foreground">{t("github.prs.stats.additions")}</p>
</div>
<div className="rounded-md border border-border p-3 text-center">
<p className="text-lg font-semibold text-red-600">-{selectedPR.deletions}</p>
<p className="text-[10px] text-muted-foreground">{t("github.prs.stats.deletions")}</p>
</div>
<div className="rounded-md border border-border p-3 text-center">
<p className="text-lg font-semibold">{selectedPR.files}</p>
<p className="text-[10px] text-muted-foreground">{t("github.prs.stats.files")}</p>
</div>
<div className="rounded-md border border-border p-3 text-center">
<p className="text-lg font-semibold capitalize">{selectedPR.state}</p>
<p className="text-[10px] text-muted-foreground">{t("github.prs.status")}</p>
</div>
</div>
{/* Review status */}
<div className="rounded-lg border border-border p-4">
<h3 className="text-sm font-medium mb-2">{t("github.prs.reviewStatus")}</h3>
<div className="flex items-center gap-2">
{selectedPR.reviewStatus === "approved" ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : selectedPR.reviewStatus === "changes_requested" ? (
<XCircle className="h-4 w-4 text-red-500" />
) : (
<Clock className="h-4 w-4 text-yellow-500" />
)}
<span className="text-sm">
{t(REVIEW_STATUS_KEYS[selectedPR.reviewStatus])}
</span>
</div>
</div>
{/* AI Review button */}
<button className="w-full flex items-center justify-center gap-2 rounded-md bg-primary px-4 py-2.5 text-sm text-primary-foreground hover:bg-primary/90 transition-colors">
<Eye className="h-4 w-4" />
{t("github.prs.startAiReview")}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,213 @@
"use client";
import { useState } from "react";
import {
Search,
RefreshCw,
ExternalLink,
MessageSquare,
Settings,
AlertCircle,
ArrowRight,
Tag,
} from "lucide-react";
import { cn } from "@auto-claude/ui";
import { useTranslation } from "react-i18next";
// GitLab icon as inline SVG since lucide-react's GitlabIcon may not be available in all versions
function GitLabIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m22 13.29-3.33-10a.42.42 0 0 0-.14-.18.38.38 0 0 0-.22-.11.39.39 0 0 0-.23.07.42.42 0 0 0-.14.18l-2.26 6.67H8.32L6.1 3.26a.42.42 0 0 0-.1-.18.38.38 0 0 0-.26-.08.39.39 0 0 0-.23.07.42.42 0 0 0-.14.18L2 13.29a.74.74 0 0 0 .27.83L12 21l9.69-6.88a.71.71 0 0 0 .31-.83Z" />
</svg>
);
}
interface GitLabIssuesViewProps {
projectId: string;
}
interface GitLabIssue {
iid: number;
title: string;
state: "opened" | "closed";
labels: string[];
author: string;
comments: number;
createdAt: string;
description: string;
}
const PLACEHOLDER_ISSUES: GitLabIssue[] = [
{
iid: 15,
title: "Implement CI/CD pipeline for staging environment",
state: "opened",
labels: ["devops", "priority::high"],
author: "dev1",
comments: 4,
createdAt: "2025-02-12",
description: "We need to set up a proper CI/CD pipeline for the staging environment...",
},
{
iid: 14,
title: "Add internationalization support",
state: "opened",
labels: ["feature", "i18n"],
author: "dev2",
comments: 2,
createdAt: "2025-02-10",
description: "Support multiple languages in the application...",
},
{
iid: 12,
title: "Fix database connection pool exhaustion",
state: "opened",
labels: ["bug", "priority::critical"],
author: "dev3",
comments: 8,
createdAt: "2025-02-08",
description: "Under heavy load, the database connection pool gets exhausted...",
},
];
export function GitLabIssuesView({ projectId }: GitLabIssuesViewProps) {
const { t } = useTranslation("integrations");
const [issues] = useState(PLACEHOLDER_ISSUES);
const [selectedIssue, setSelectedIssue] = useState<GitLabIssue | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [isConnected] = useState(true);
if (!isConnected) {
return (
<div className="flex h-full flex-col items-center justify-center p-8">
<div className="max-w-md text-center">
<div className="mb-6 flex justify-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-secondary">
<GitLabIcon className="h-8 w-8 text-muted-foreground" />
</div>
</div>
<h2 className="mb-3 text-xl font-semibold">{t("gitlab.issues.notConnected")}</h2>
<p className="mb-6 text-sm text-muted-foreground">
{t("gitlab.issues.notConnectedDescription")}
</p>
<button className="flex items-center gap-2 mx-auto rounded-lg bg-primary px-6 py-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors">
<Settings className="h-4 w-4" />
{t("gitlab.issues.configure")}
</button>
</div>
</div>
);
}
return (
<div className="flex h-full overflow-hidden">
{/* Issue List */}
<div className={cn("flex flex-col border-r border-border", selectedIssue ? "w-96" : "flex-1")}>
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h1 className="text-sm font-semibold flex items-center gap-2">
<GitLabIcon className="h-4 w-4" />
{t("gitlab.issues.title")}
</h1>
<button className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent transition-colors">
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
<div className="border-b border-border px-4 py-2">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<input
className="w-full rounded-md border border-border bg-background pl-8 pr-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-primary/20"
placeholder={t("gitlab.issues.search")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{issues.map((issue) => (
<div
key={issue.iid}
className={cn(
"flex items-start gap-3 border-b border-border px-4 py-3 cursor-pointer transition-colors",
selectedIssue?.iid === issue.iid ? "bg-accent" : "hover:bg-accent/50"
)}
onClick={() => setSelectedIssue(issue)}
>
<AlertCircle className="h-4 w-4 text-green-500 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium leading-tight">
#{issue.iid} {issue.title}
</p>
<div className="mt-1 flex items-center gap-1.5 flex-wrap">
{issue.labels.map((label) => (
<span
key={label}
className="rounded-full bg-secondary px-2 py-0.5 text-[10px] text-muted-foreground"
>
{label}
</span>
))}
</div>
<div className="mt-1 flex items-center gap-3 text-[10px] text-muted-foreground">
<span>{issue.author}</span>
<span>{issue.createdAt}</span>
{issue.comments > 0 && (
<span className="flex items-center gap-0.5">
<MessageSquare className="h-2.5 w-2.5" />
{issue.comments}
</span>
)}
</div>
</div>
</div>
))}
</div>
</div>
{/* Issue Detail */}
{selectedIssue && (
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-border px-6 py-3">
<h2 className="text-sm font-semibold">
#{selectedIssue.iid} {selectedIssue.title}
</h2>
<a
href="#"
target="_blank"
rel="noopener noreferrer"
className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent transition-colors"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-2xl space-y-4">
<div className="flex items-center gap-2 flex-wrap">
{selectedIssue.labels.map((label) => (
<span
key={label}
className="rounded-full bg-secondary px-2.5 py-0.5 text-xs text-muted-foreground"
>
{label}
</span>
))}
</div>
<div className="text-sm text-muted-foreground whitespace-pre-wrap">
{selectedIssue.description}
</div>
<div className="pt-4 border-t border-border">
<button className="flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 transition-colors">
<ArrowRight className="h-3.5 w-3.5" />
{t("gitlab.issues.createTask")}
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,250 @@
"use client";
import { useState } from "react";
import {
Search,
RefreshCw,
ExternalLink,
CheckCircle2,
XCircle,
Clock,
Eye,
GitMerge,
} from "lucide-react";
import { cn } from "@auto-claude/ui";
import { useTranslation } from "react-i18next";
interface GitLabMRsViewProps {
projectId: string;
}
interface MergeRequest {
iid: number;
title: string;
state: "opened" | "merged" | "closed";
author: string;
additions: number;
deletions: number;
changedFiles: number;
createdAt: string;
labels: string[];
draft: boolean;
approvals: number;
approvalsRequired: number;
}
const PLACEHOLDER_MRS: MergeRequest[] = [
{
iid: 95,
title: "Add database migration framework",
state: "opened",
author: "dev1",
additions: 450,
deletions: 20,
changedFiles: 8,
createdAt: "2025-02-13",
labels: ["database", "feature"],
draft: false,
approvals: 1,
approvalsRequired: 2,
},
{
iid: 94,
title: "Fix memory leak in worker process",
state: "merged",
author: "dev2",
additions: 35,
deletions: 12,
changedFiles: 3,
createdAt: "2025-02-12",
labels: ["bug", "hotfix"],
draft: false,
approvals: 2,
approvalsRequired: 2,
},
{
iid: 93,
title: "WIP: Refactor authentication module",
state: "opened",
author: "dev1",
additions: 890,
deletions: 340,
changedFiles: 15,
createdAt: "2025-02-11",
labels: ["refactoring"],
draft: true,
approvals: 0,
approvalsRequired: 2,
},
];
const STATE_COLORS: Record<string, string> = {
opened: "text-green-500",
merged: "text-purple-500",
closed: "text-red-500",
};
export function GitLabMRsView({ projectId }: GitLabMRsViewProps) {
const { t } = useTranslation("integrations");
const [mrs] = useState(PLACEHOLDER_MRS);
const [selectedMR, setSelectedMR] = useState<MergeRequest | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [filter, setFilter] = useState<"all" | "opened" | "merged" | "closed">("all");
const filteredMRs = mrs.filter((mr) => {
if (filter !== "all" && mr.state !== filter) return false;
if (searchQuery && !mr.title.toLowerCase().includes(searchQuery.toLowerCase())) return false;
return true;
});
return (
<div className="flex h-full overflow-hidden">
{/* MR List */}
<div className={cn("flex flex-col border-r border-border", selectedMR ? "w-96" : "flex-1")}>
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h1 className="text-sm font-semibold flex items-center gap-2">
<GitMerge className="h-4 w-4" />
{t("gitlab.mrs.title")}
</h1>
<button className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent transition-colors">
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
<div className="border-b border-border px-4 py-2 space-y-2">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<input
className="w-full rounded-md border border-border bg-background pl-8 pr-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-primary/20"
placeholder={t("gitlab.mrs.search")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="flex items-center gap-1">
{(["all", "opened", "merged", "closed"] as const).map((f) => (
<button
key={f}
className={cn(
"rounded-full px-2.5 py-1 text-[10px] font-medium transition-colors",
filter === f ? "bg-primary text-primary-foreground" : "bg-secondary text-muted-foreground hover:text-foreground"
)}
onClick={() => setFilter(f)}
>
{t(`gitlab.mrs.filters.${f}`)}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto">
{filteredMRs.map((mr) => (
<div
key={mr.iid}
className={cn(
"flex items-start gap-3 border-b border-border px-4 py-3 cursor-pointer transition-colors",
selectedMR?.iid === mr.iid ? "bg-accent" : "hover:bg-accent/50"
)}
onClick={() => setSelectedMR(mr)}
>
<GitMerge className={cn("h-4 w-4 mt-0.5 shrink-0", STATE_COLORS[mr.state])} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium leading-tight">
!{mr.iid} {mr.title}
</p>
<div className="mt-1 flex items-center gap-1.5 flex-wrap">
{mr.draft && (
<span className="rounded-full bg-yellow-500/10 text-yellow-600 px-2 py-0.5 text-[10px] font-medium">
{t("gitlab.mrs.draft")}
</span>
)}
{mr.labels.map((label) => (
<span
key={label}
className="rounded-full bg-secondary px-2 py-0.5 text-[10px] text-muted-foreground"
>
{label}
</span>
))}
</div>
<div className="mt-1 flex items-center gap-3 text-[10px] text-muted-foreground">
<span>{mr.author}</span>
<span>{mr.createdAt}</span>
<span className="text-green-600">+{mr.additions}</span>
<span className="text-red-600">-{mr.deletions}</span>
<span>{t("gitlab.mrs.stats.filesCount", { count: mr.changedFiles })}</span>
</div>
</div>
</div>
))}
</div>
</div>
{/* MR Detail */}
{selectedMR && (
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-border px-6 py-3">
<h2 className="text-sm font-semibold">
!{selectedMR.iid} {selectedMR.title}
</h2>
<div className="flex items-center gap-2">
<button className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground transition-colors">
<Eye className="h-3 w-3" />
{t("gitlab.mrs.aiReview")}
</button>
<a
href="#"
target="_blank"
rel="noopener noreferrer"
className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent transition-colors"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
</div>
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-2xl space-y-4">
<div className="grid grid-cols-4 gap-3">
<div className="rounded-md border border-border p-3 text-center">
<p className="text-lg font-semibold text-green-600">+{selectedMR.additions}</p>
<p className="text-[10px] text-muted-foreground">{t("gitlab.mrs.stats.additions")}</p>
</div>
<div className="rounded-md border border-border p-3 text-center">
<p className="text-lg font-semibold text-red-600">-{selectedMR.deletions}</p>
<p className="text-[10px] text-muted-foreground">{t("gitlab.mrs.stats.deletions")}</p>
</div>
<div className="rounded-md border border-border p-3 text-center">
<p className="text-lg font-semibold">{selectedMR.changedFiles}</p>
<p className="text-[10px] text-muted-foreground">{t("gitlab.mrs.stats.files")}</p>
</div>
<div className="rounded-md border border-border p-3 text-center">
<p className="text-lg font-semibold capitalize">{selectedMR.state}</p>
<p className="text-[10px] text-muted-foreground">{t("gitlab.mrs.status")}</p>
</div>
</div>
<div className="rounded-lg border border-border p-4">
<h3 className="text-sm font-medium mb-2">{t("gitlab.mrs.approvals")}</h3>
<div className="flex items-center gap-2">
{selectedMR.approvals >= selectedMR.approvalsRequired ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<Clock className="h-4 w-4 text-yellow-500" />
)}
<span className="text-sm">
{t("gitlab.mrs.approvalsCount", { current: selectedMR.approvals, required: selectedMR.approvalsRequired })}
</span>
</div>
</div>
<button className="w-full flex items-center justify-center gap-2 rounded-md bg-primary px-4 py-2.5 text-sm text-primary-foreground hover:bg-primary/90 transition-colors">
<Eye className="h-4 w-4" />
{t("gitlab.mrs.startAiReview")}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,207 @@
"use client";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
Lightbulb,
Sparkles,
RefreshCw,
Shield,
Zap,
Code,
Paintbrush,
Bug,
ArrowRight,
ThumbsUp,
ThumbsDown,
} from "lucide-react";
import { cn } from "@auto-claude/ui";
interface IdeationViewProps {
projectId: string;
}
type IdeaCategory =
| "code_improvements"
| "security_hardening"
| "performance_optimization"
| "ui_ux_improvements"
| "bug_predictions"
| "new_features";
const CATEGORY_ICONS: Record<IdeaCategory, React.ElementType> = {
code_improvements: Code,
security_hardening: Shield,
performance_optimization: Zap,
ui_ux_improvements: Paintbrush,
bug_predictions: Bug,
new_features: Sparkles,
};
const CATEGORY_KEYS: Record<IdeaCategory, string> = {
code_improvements: "ideation.categories.codeImprovements",
security_hardening: "ideation.categories.securityHardening",
performance_optimization: "ideation.categories.performanceOptimization",
ui_ux_improvements: "ideation.categories.uiUxImprovements",
bug_predictions: "ideation.categories.bugPredictions",
new_features: "ideation.categories.newFeatures",
};
const CATEGORY_IDS: IdeaCategory[] = [
"code_improvements",
"security_hardening",
"performance_optimization",
"ui_ux_improvements",
"bug_predictions",
"new_features",
];
interface Idea {
id: string;
title: string;
description: string;
category: IdeaCategory;
impact: "low" | "medium" | "high";
effort: "small" | "medium" | "large";
}
const PLACEHOLDER_IDEAS: Idea[] = [
{ id: "1", title: "Add input validation to user forms", description: "Several user-facing forms lack proper input validation which could lead to data integrity issues.", category: "code_improvements", impact: "high", effort: "small" },
{ id: "2", title: "Rate limit API endpoints", description: "Public API endpoints should have rate limiting to prevent abuse and ensure fair usage.", category: "security_hardening", impact: "high", effort: "medium" },
{ id: "3", title: "Optimize database queries on dashboard", description: "The dashboard page makes N+1 queries that could be batched for better performance.", category: "performance_optimization", impact: "medium", effort: "small" },
{ id: "4", title: "Add loading skeletons", description: "Replace loading spinners with skeleton loaders for a smoother perceived loading experience.", category: "ui_ux_improvements", impact: "medium", effort: "small" },
];
export function IdeationView({ projectId }: IdeationViewProps) {
const { t } = useTranslation("views");
const [selectedCategory, setSelectedCategory] = useState<IdeaCategory | null>(null);
const [ideas] = useState<Idea[]>(PLACEHOLDER_IDEAS);
const [isEmpty] = useState(false);
const filteredIdeas = selectedCategory
? ideas.filter((i) => i.category === selectedCategory)
: ideas;
if (isEmpty) {
return (
<div className="flex h-full flex-col items-center justify-center p-8">
<div className="max-w-md text-center">
<div className="mb-6 flex justify-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10">
<Lightbulb className="h-8 w-8 text-primary" />
</div>
</div>
<h2 className="mb-3 text-xl font-semibold">{t("ideation.empty.title")}</h2>
<p className="mb-6 text-sm text-muted-foreground">
{t("ideation.empty.description")}
</p>
<button className="flex items-center gap-2 mx-auto rounded-lg bg-primary px-6 py-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors">
<Sparkles className="h-4 w-4" />
{t("ideation.empty.generate")}
</button>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-6 py-3">
<h1 className="text-lg font-semibold">{t("ideation.title")}</h1>
<div className="flex items-center gap-2">
<button className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors">
<RefreshCw className="h-3.5 w-3.5" />
{t("ideation.regenerate")}
</button>
<button className="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90 transition-colors">
<Sparkles className="h-3.5 w-3.5" />
{t("ideation.analyzeCodebase")}
</button>
</div>
</div>
{/* Category filters */}
<div className="border-b border-border px-6 py-3">
<div className="flex items-center gap-2 overflow-x-auto">
<button
className={cn(
"shrink-0 rounded-full px-3 py-1.5 text-xs font-medium transition-colors",
!selectedCategory ? "bg-primary text-primary-foreground" : "bg-secondary text-muted-foreground hover:text-foreground"
)}
onClick={() => setSelectedCategory(null)}
>
{t("ideation.allFilter", { count: ideas.length })}
</button>
{CATEGORY_IDS.map((catId) => {
const Icon = CATEGORY_ICONS[catId];
const count = ideas.filter((i) => i.category === catId).length;
return (
<button
key={catId}
className={cn(
"shrink-0 flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition-colors",
selectedCategory === catId ? "bg-primary text-primary-foreground" : "bg-secondary text-muted-foreground hover:text-foreground"
)}
onClick={() => setSelectedCategory(catId)}
>
<Icon className="h-3 w-3" />
{t("ideation.categoryCount", { label: t(`${CATEGORY_KEYS[catId]}.label`), count })}
</button>
);
})}
</div>
</div>
{/* Ideas list */}
<div className="flex-1 overflow-y-auto p-6">
<div className="space-y-3 max-w-3xl">
{filteredIdeas.map((idea) => {
const Icon = CATEGORY_ICONS[idea.category] || Lightbulb;
return (
<div
key={idea.id}
className="rounded-lg border border-border bg-card p-4 hover:shadow-md transition-all"
>
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-secondary shrink-0">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium">{idea.title}</h3>
<p className="mt-1 text-xs text-muted-foreground">{idea.description}</p>
<div className="mt-3 flex items-center gap-2 flex-wrap">
<span className={cn(
"rounded-full px-2 py-0.5 text-[10px] font-medium",
idea.impact === "high" && "bg-red-500/10 text-red-600",
idea.impact === "medium" && "bg-yellow-500/10 text-yellow-600",
idea.impact === "low" && "bg-blue-500/10 text-blue-600"
)}>
{t("ideation.impact", { level: idea.impact })}
</span>
<span className="rounded-full bg-secondary px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
{t("ideation.effort", { level: idea.effort })}
</span>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<button className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent transition-colors text-muted-foreground hover:text-green-500">
<ThumbsUp className="h-3.5 w-3.5" />
</button>
<button className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent transition-colors text-muted-foreground hover:text-red-500">
<ThumbsDown className="h-3.5 w-3.5" />
</button>
<button className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent transition-colors text-muted-foreground hover:text-primary">
<ArrowRight className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
@@ -0,0 +1,232 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
Sparkles,
Send,
User,
Bot,
Plus,
PanelLeft,
PanelLeftClose,
MessageSquare,
} from "lucide-react";
import { cn } from "@auto-claude/ui";
interface InsightsViewProps {
projectId: string;
}
interface ChatMessage {
id: string;
role: "user" | "assistant";
content: string;
timestamp: Date;
}
interface ChatSession {
id: string;
title: string;
createdAt: Date;
}
const SUGGESTION_KEYS = [
"insights.suggestions.complexity",
"insights.suggestions.security",
"insights.suggestions.performance",
"insights.suggestions.tests",
"insights.suggestions.architecture",
] as const;
export function InsightsView({ projectId }: InsightsViewProps) {
const { t } = useTranslation("views");
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [showSidebar, setShowSidebar] = useState(true);
const [sessions] = useState<ChatSession[]>([
{ id: "1", title: "Architecture Review", createdAt: new Date() },
{ id: "2", title: "Security Audit", createdAt: new Date() },
]);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const handleSend = async () => {
if (!input.trim() || isLoading) return;
const userMessage: ChatMessage = {
id: Date.now().toString(),
role: "user",
content: input.trim(),
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMessage]);
setInput("");
setIsLoading(true);
// Simulate AI response
setTimeout(() => {
const aiMessage: ChatMessage = {
id: (Date.now() + 1).toString(),
role: "assistant",
content: `I've analyzed your query about "${userMessage.content}". This is a placeholder response -- in the full implementation, this will be powered by Claude analyzing your project's codebase and providing detailed insights.`,
timestamp: new Date(),
};
setMessages((prev) => [...prev, aiMessage]);
setIsLoading(false);
}, 1500);
};
const handleSuggestionClick = (suggestion: string) => {
setInput(suggestion);
};
return (
<div className="flex h-full overflow-hidden">
{/* Chat History Sidebar */}
{showSidebar && (
<div className="w-64 border-r border-border bg-card/50 flex flex-col">
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold">{t("insights.chatHistory")}</h2>
<button className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent transition-colors">
<Plus className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-2">
{sessions.map((session) => (
<button
key={session.id}
className="w-full flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors text-left"
>
<MessageSquare className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="truncate">{session.title}</span>
</button>
))}
</div>
</div>
)}
{/* Main Chat Area */}
<div className="flex flex-1 flex-col">
{/* Header */}
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
<button
className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent transition-colors"
onClick={() => setShowSidebar(!showSidebar)}
>
{showSidebar ? (
<PanelLeftClose className="h-4 w-4" />
) : (
<PanelLeft className="h-4 w-4" />
)}
</button>
<Sparkles className="h-4 w-4 text-primary" />
<h1 className="text-sm font-semibold">{t("insights.title")}</h1>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-6">
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full max-w-2xl mx-auto">
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10">
<Sparkles className="h-8 w-8 text-primary" />
</div>
<h2 className="text-xl font-semibold mb-2">{t("insights.welcomeTitle")}</h2>
<p className="text-sm text-muted-foreground text-center mb-8">
{t("insights.welcomeDescription")}
</p>
<div className="grid grid-cols-1 gap-2 w-full max-w-lg">
{SUGGESTION_KEYS.map((key) => {
const suggestion = t(key);
return (
<button
key={key}
className="text-left rounded-lg border border-border bg-card/50 px-4 py-3 text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
onClick={() => handleSuggestionClick(suggestion)}
>
{suggestion}
</button>
);
})}
</div>
</div>
) : (
<div className="max-w-3xl mx-auto space-y-6">
{messages.map((message) => (
<div key={message.id} className="flex gap-3">
<div className={cn(
"flex h-8 w-8 items-center justify-center rounded-full shrink-0",
message.role === "user" ? "bg-secondary" : "bg-primary/10"
)}>
{message.role === "user" ? (
<User className="h-4 w-4" />
) : (
<Bot className="h-4 w-4 text-primary" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-muted-foreground mb-1">
{message.role === "user" ? t("insights.you") : t("insights.aiAssistant")}
</p>
<div className="text-sm leading-relaxed whitespace-pre-wrap">
{message.content}
</div>
</div>
</div>
))}
{isLoading && (
<div className="flex gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 shrink-0">
<Bot className="h-4 w-4 text-primary animate-pulse" />
</div>
<div className="flex items-center gap-1 pt-2">
<div className="h-1.5 w-1.5 rounded-full bg-muted-foreground animate-bounce [animation-delay:0ms]" />
<div className="h-1.5 w-1.5 rounded-full bg-muted-foreground animate-bounce [animation-delay:150ms]" />
<div className="h-1.5 w-1.5 rounded-full bg-muted-foreground animate-bounce [animation-delay:300ms]" />
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
)}
</div>
{/* Input */}
<div className="border-t border-border p-4">
<div className="max-w-3xl mx-auto flex gap-2">
<textarea
className="flex-1 resize-none rounded-lg border border-border bg-background px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
placeholder={t("insights.placeholder")}
rows={1}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
/>
<button
className={cn(
"flex h-10 w-10 items-center justify-center rounded-lg transition-colors",
input.trim() && !isLoading
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-secondary text-muted-foreground"
)}
onClick={handleSend}
disabled={!input.trim() || isLoading}
>
<Send className="h-4 w-4" />
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,174 @@
"use client";
import { useMemo, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
Plus,
Inbox,
Loader2,
Eye,
CheckCircle2,
RefreshCw,
GitPullRequest,
AlertCircle,
} from "lucide-react";
import { cn } from "@auto-claude/ui";
import type { Task, TaskStatus } from "@auto-claude/types";
import { useTaskStore, loadTasks } from "@/stores/task-store";
import { useProjectStore } from "@/stores/project-store";
import { useUIStore } from "@/stores/ui-store";
import { TaskCard } from "./TaskCard";
import { TaskDetailModal } from "./TaskDetailModal";
const TASK_STATUS_COLUMNS: TaskStatus[] = [
"backlog",
"queue",
"in_progress",
"ai_review",
"human_review",
"done",
];
const COLUMN_CONFIG: Record<
string,
{ labelKey: string; icon: React.ElementType; color: string }
> = {
backlog: { labelKey: "columns.backlog", icon: Inbox, color: "text-muted-foreground" },
queue: { labelKey: "columns.queue", icon: Loader2, color: "text-blue-500" },
in_progress: { labelKey: "columns.in_progress", icon: Loader2, color: "text-yellow-500" },
ai_review: { labelKey: "columns.ai_review", icon: Eye, color: "text-purple-500" },
human_review: { labelKey: "columns.human_review", icon: Eye, color: "text-orange-500" },
done: { labelKey: "columns.done", icon: CheckCircle2, color: "text-green-500" },
pr_created: { labelKey: "columns.pr_created", icon: GitPullRequest, color: "text-green-600" },
error: { labelKey: "columns.error", icon: AlertCircle, color: "text-red-500" },
};
export function KanbanBoard() {
const { t } = useTranslation("kanban");
const tasks = useTaskStore((s) => s.tasks);
const isLoading = useTaskStore((s) => s.isLoading);
const activeProjectId = useProjectStore((s) => s.activeProjectId);
const selectedProjectId = useProjectStore((s) => s.selectedProjectId);
const setNewTaskDialogOpen = useUIStore((s) => s.setNewTaskDialogOpen);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const currentProjectId = activeProjectId || selectedProjectId;
// Group tasks by status
const tasksByStatus = useMemo(() => {
const grouped: Record<string, Task[]> = {};
for (const status of TASK_STATUS_COLUMNS) {
grouped[status] = [];
}
for (const task of tasks) {
// Map pr_created to done, error to human_review for display
const displayStatus =
task.status === "pr_created"
? "done"
: task.status === "error"
? "human_review"
: task.status;
if (grouped[displayStatus]) {
grouped[displayStatus].push(task);
}
}
return grouped;
}, [tasks]);
const handleRefresh = useCallback(async () => {
if (!currentProjectId) return;
setIsRefreshing(true);
try {
await loadTasks(currentProjectId);
} finally {
setIsRefreshing(false);
}
}, [currentProjectId]);
if (isLoading && tasks.length === 0) {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-6 py-3">
<h1 className="text-lg font-semibold">{t("board.title")}</h1>
<div className="flex items-center gap-2">
<button
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
onClick={handleRefresh}
disabled={isRefreshing}
>
<RefreshCw
className={cn("h-3.5 w-3.5", isRefreshing && "animate-spin")}
/>
{t("board.refresh")}
</button>
<button
className="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90 transition-colors"
onClick={() => setNewTaskDialogOpen(true)}
>
<Plus className="h-3.5 w-3.5" />
{t("board.newTask")}
</button>
</div>
</div>
{/* Columns */}
<div className="flex flex-1 overflow-x-auto p-4 gap-4">
{TASK_STATUS_COLUMNS.map((status) => {
const config = COLUMN_CONFIG[status];
const columnTasks = tasksByStatus[status] || [];
const Icon = config.icon;
return (
<div
key={status}
className="flex min-w-[280px] max-w-[320px] flex-1 flex-col rounded-lg bg-card/50 border border-border"
>
{/* Column header */}
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border">
<Icon className={cn("h-4 w-4", config.color)} />
<span className="text-sm font-medium">{t(config.labelKey)}</span>
<span className="ml-auto rounded-full bg-secondary px-2 py-0.5 text-xs text-muted-foreground">
{columnTasks.length}
</span>
</div>
{/* Tasks */}
<div className="flex-1 overflow-y-auto p-2 space-y-2">
{columnTasks.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<p className="text-xs">{t("board.noTasks")}</p>
</div>
) : (
columnTasks.map((task) => (
<TaskCard
key={task.id}
task={task}
onClick={() => setSelectedTask(task)}
/>
))
)}
</div>
</div>
);
})}
</div>
{/* Task Detail Modal */}
{selectedTask && (
<TaskDetailModal
task={selectedTask}
onClose={() => setSelectedTask(null)}
/>
)}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More