Compare commits

...

7 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
212 changed files with 19883 additions and 3568 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/
+6 -6
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 -->
+1 -1
View File
@@ -19,5 +19,5 @@ Quick Start:
See README.md for full documentation.
"""
__version__ = "2.7.6-beta.4"
__version__ = "2.7.7"
__author__ = "Auto Claude Team"
+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.4",
"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",
@@ -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 {
@@ -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
+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.
*/
+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>
);
}
+151
View File
@@ -0,0 +1,151 @@
"use client";
import { useTranslation } from "react-i18next";
import {
AlertCircle,
CheckCircle2,
Clock,
GitPullRequest,
Eye,
} from "lucide-react";
import { cn } from "@auto-claude/ui";
import type { Task } from "@auto-claude/types";
const STATUS_ICONS: Record<string, React.ElementType> = {
backlog: Clock,
queue: Clock,
in_progress: Clock,
ai_review: Eye,
human_review: Eye,
done: CheckCircle2,
pr_created: GitPullRequest,
error: AlertCircle,
};
const PRIORITY_COLORS: Record<string, string> = {
urgent: "border-l-red-500",
high: "border-l-orange-500",
medium: "border-l-yellow-500",
low: "border-l-blue-500",
};
interface TaskCardProps {
task: Task;
onClick: () => void;
}
export function TaskCard({ task, onClick }: TaskCardProps) {
const { t } = useTranslation("kanban");
const priority = task.metadata?.priority;
const category = task.metadata?.category;
const StatusIcon = STATUS_ICONS[task.status] || Clock;
// Calculate subtask progress
const totalSubtasks = task.subtasks?.length || 0;
const completedSubtasks =
task.subtasks?.filter((s) => s.status === "completed").length || 0;
return (
<div
className={cn(
"rounded-lg border border-border bg-card p-3 cursor-pointer transition-all hover:shadow-md hover:border-border/80",
"border-l-2",
priority ? PRIORITY_COLORS[priority] || "border-l-transparent" : "border-l-transparent"
)}
onClick={onClick}
>
{/* Title */}
<h3 className="text-sm font-medium leading-tight line-clamp-2">
{task.title}
</h3>
{/* Description preview */}
{task.description && (
<p className="mt-1 text-xs text-muted-foreground line-clamp-2">
{task.description}
</p>
)}
{/* Metadata row */}
<div className="mt-2 flex items-center gap-2 flex-wrap">
{category && (
<span className="rounded-full bg-secondary px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
{t(`card.category.${category}`, category)}
</span>
)}
{task.metadata?.complexity && (
<span className="rounded-full bg-secondary px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
{task.metadata.complexity}
</span>
)}
{/* Subtask progress */}
{totalSubtasks > 0 && (
<span className="ml-auto flex items-center gap-1 text-[10px] text-muted-foreground">
<CheckCircle2 className="h-3 w-3" />
{completedSubtasks}/{totalSubtasks}
</span>
)}
</div>
{/* Progress bar for execution */}
{task.executionProgress && task.executionProgress.overallProgress > 0 && (
<div className="mt-2">
<div className="h-1 w-full rounded-full bg-secondary">
<div
className="h-1 rounded-full bg-primary transition-all"
style={{
width: `${task.executionProgress.overallProgress}%`,
}}
/>
</div>
{task.executionProgress.message && (
<p className="mt-0.5 text-[10px] text-muted-foreground truncate">
{task.executionProgress.message}
</p>
)}
</div>
)}
{/* Remapped status badges */}
{task.status === "pr_created" && (
<div className="mt-2">
<span className="inline-flex items-center gap-1 rounded-full bg-green-500/10 px-2 py-0.5 text-[10px] font-medium text-green-600">
<GitPullRequest className="h-3 w-3" />
{t("card.badges.prCreated")}
</span>
</div>
)}
{task.status === "error" && (
<div className="mt-2">
<span className="inline-flex items-center gap-1 rounded-full bg-red-500/10 px-2 py-0.5 text-[10px] font-medium text-red-600">
<AlertCircle className="h-3 w-3" />
{t("card.badges.error")}
</span>
</div>
)}
{/* Review reason badge */}
{task.status === "human_review" && task.reviewReason && (
<div className="mt-2">
<span
className={cn(
"rounded-full px-2 py-0.5 text-[10px] font-medium",
task.reviewReason === "completed" &&
"bg-green-500/10 text-green-600",
task.reviewReason === "errors" &&
"bg-red-500/10 text-red-600",
task.reviewReason === "qa_rejected" &&
"bg-orange-500/10 text-orange-600",
task.reviewReason === "plan_review" &&
"bg-blue-500/10 text-blue-600"
)}
>
{t(`card.review.${task.reviewReason}`, task.reviewReason)}
</span>
</div>
)}
</div>
);
}
@@ -0,0 +1,306 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
X,
Sparkles,
ArrowRight,
ArrowLeft,
Plus,
Image,
FileText,
} from "lucide-react";
import { cn } from "@auto-claude/ui";
import type {
TaskCategory,
TaskPriority,
TaskComplexity,
} from "@auto-claude/types";
interface TaskCreationWizardProps {
open: boolean;
onClose: () => void;
projectId: string;
}
type Step = "details" | "metadata" | "review";
const CATEGORIES: { id: TaskCategory; labelKey: string }[] = [
{ id: "feature", labelKey: "wizard.category.feature" },
{ id: "bug_fix", labelKey: "wizard.category.bug_fix" },
{ id: "refactoring", labelKey: "wizard.category.refactoring" },
{ id: "documentation", labelKey: "wizard.category.documentation" },
{ id: "security", labelKey: "wizard.category.security" },
{ id: "performance", labelKey: "wizard.category.performance" },
{ id: "ui_ux", labelKey: "wizard.category.ui_ux" },
{ id: "infrastructure", labelKey: "wizard.category.infrastructure" },
{ id: "testing", labelKey: "wizard.category.testing" },
];
const PRIORITIES: { id: TaskPriority; labelKey: string; color: string }[] = [
{ id: "urgent", labelKey: "wizard.priority.urgent", color: "border-red-500 bg-red-500/10 text-red-600" },
{ id: "high", labelKey: "wizard.priority.high", color: "border-orange-500 bg-orange-500/10 text-orange-600" },
{ id: "medium", labelKey: "wizard.priority.medium", color: "border-yellow-500 bg-yellow-500/10 text-yellow-600" },
{ id: "low", labelKey: "wizard.priority.low", color: "border-blue-500 bg-blue-500/10 text-blue-600" },
];
const COMPLEXITIES: { id: TaskComplexity; labelKey: string; descKey: string }[] = [
{ id: "trivial", labelKey: "wizard.complexityOption.trivial", descKey: "wizard.complexityOption.trivialDesc" },
{ id: "small", labelKey: "wizard.complexityOption.small", descKey: "wizard.complexityOption.smallDesc" },
{ id: "medium", labelKey: "wizard.complexityOption.medium", descKey: "wizard.complexityOption.mediumDesc" },
{ id: "large", labelKey: "wizard.complexityOption.large", descKey: "wizard.complexityOption.largeDesc" },
{ id: "complex", labelKey: "wizard.complexityOption.complex", descKey: "wizard.complexityOption.complexDesc" },
];
export function TaskCreationWizard({
open,
onClose,
projectId,
}: TaskCreationWizardProps) {
const { t } = useTranslation("kanban");
const [step, setStep] = useState<Step>("details");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [category, setCategory] = useState<TaskCategory | "">("");
const [priority, setPriority] = useState<TaskPriority | "">("");
const [complexity, setComplexity] = useState<TaskComplexity | "">("");
const resetForm = () => {
setTitle("");
setDescription("");
setCategory("");
setPriority("");
setComplexity("");
setStep("details");
};
const handleClose = useCallback(() => {
resetForm();
onClose();
}, [onClose]);
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") handleClose();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [open, handleClose]);
if (!open) return null;
const handleSubmit = () => {
// TODO: API call to create task
console.log("Creating task:", { title, description, category, priority, complexity, projectId });
handleClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm" onClick={handleClose} />
<div className="relative z-10 w-full max-w-2xl max-h-[85vh] overflow-hidden rounded-xl border border-border bg-card shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-6 py-4">
<div className="flex items-center gap-2">
<Plus className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">{t("wizard.newTask")}</h2>
</div>
<div className="flex items-center gap-4">
{/* Step indicators */}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className={cn("rounded-full px-2 py-0.5", step === "details" ? "bg-primary text-primary-foreground" : "bg-secondary")}>{t("wizard.steps.details")}</span>
<span className={cn("rounded-full px-2 py-0.5", step === "metadata" ? "bg-primary text-primary-foreground" : "bg-secondary")}>{t("wizard.steps.config")}</span>
<span className={cn("rounded-full px-2 py-0.5", step === "review" ? "bg-primary text-primary-foreground" : "bg-secondary")}>{t("wizard.steps.review")}</span>
</div>
<button
className="flex h-8 w-8 items-center justify-center rounded-md hover:bg-accent transition-colors"
onClick={handleClose}
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* Content */}
<div className="overflow-y-auto max-h-[calc(85vh-130px)] p-6">
{step === "details" && (
<div className="space-y-4">
<div>
<label className="text-sm font-medium">{t("wizard.taskTitle")}</label>
<input
className="mt-1.5 w-full 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("wizard.titlePlaceholder")}
value={title}
onChange={(e) => setTitle(e.target.value)}
autoFocus
/>
</div>
<div>
<label className="text-sm font-medium">{t("wizard.descriptionLabel")}</label>
<textarea
className="mt-1.5 w-full 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("wizard.descriptionPlaceholder")}
rows={6}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">{t("wizard.categoryLabel")}</label>
<div className="flex flex-wrap gap-2">
{CATEGORIES.map((cat) => (
<button
key={cat.id}
className={cn(
"rounded-full px-3 py-1.5 text-xs font-medium transition-colors",
category === cat.id
? "bg-primary text-primary-foreground"
: "bg-secondary text-muted-foreground hover:text-foreground"
)}
onClick={() => setCategory(cat.id)}
>
{t(cat.labelKey)}
</button>
))}
</div>
</div>
</div>
)}
{step === "metadata" && (
<div className="space-y-6">
<div>
<label className="text-sm font-medium mb-2 block">{t("wizard.priorityLabel")}</label>
<div className="grid grid-cols-4 gap-2">
{PRIORITIES.map((p) => (
<button
key={p.id}
className={cn(
"rounded-lg border-2 p-3 text-center text-sm font-medium transition-colors",
priority === p.id ? p.color : "border-border hover:border-border/80"
)}
onClick={() => setPriority(p.id)}
>
{t(p.labelKey)}
</button>
))}
</div>
</div>
<div>
<label className="text-sm font-medium mb-2 block">{t("wizard.complexityLabel")}</label>
<div className="space-y-2">
{COMPLEXITIES.map((c) => (
<button
key={c.id}
className={cn(
"w-full rounded-lg border p-3 text-left transition-colors",
complexity === c.id ? "border-primary bg-primary/5" : "border-border hover:border-border/80"
)}
onClick={() => setComplexity(c.id)}
>
<p className="text-sm font-medium">{t(c.labelKey)}</p>
<p className="text-xs text-muted-foreground">{t(c.descKey)}</p>
</button>
))}
</div>
</div>
</div>
)}
{step === "review" && (
<div className="space-y-4">
<h3 className="text-sm font-medium">{t("wizard.reviewTask")}</h3>
<div className="rounded-lg border border-border bg-card/50 p-4 space-y-3">
<div>
<p className="text-xs text-muted-foreground">{t("detail.title")}</p>
<p className="text-sm font-medium">{title || t("wizard.untitled")}</p>
</div>
{description && (
<div>
<p className="text-xs text-muted-foreground">{t("detail.description")}</p>
<p className="text-sm whitespace-pre-wrap">{description}</p>
</div>
)}
<div className="flex items-center gap-3 flex-wrap">
{category && (
<span className="rounded-full bg-secondary px-2.5 py-0.5 text-xs">
{t(`wizard.category.${category}`)}
</span>
)}
{priority && (
<span className={cn(
"rounded-full px-2.5 py-0.5 text-xs",
PRIORITIES.find((p) => p.id === priority)?.color
)}>
{t(`wizard.priority.${priority}`)}
</span>
)}
{complexity && (
<span className="rounded-full bg-secondary px-2.5 py-0.5 text-xs">
{t(`wizard.complexityOption.${complexity}`)}
</span>
)}
</div>
</div>
<div className="rounded-lg border border-primary/20 bg-primary/5 p-4">
<div className="flex items-start gap-2">
<Sparkles className="h-4 w-4 text-primary mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">{t("wizard.aiHandlesRest")}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{t("wizard.aiHandlesRestDescription")}
</p>
</div>
</div>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t border-border px-6 py-4">
<button
className="flex items-center gap-1.5 rounded-md px-4 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
onClick={() => {
if (step === "details") handleClose();
else if (step === "metadata") setStep("details");
else setStep("metadata");
}}
>
{step === "details" ? (
t("wizard.cancel")
) : (
<>
<ArrowLeft className="h-3.5 w-3.5" />
{t("wizard.back")}
</>
)}
</button>
<button
className="flex items-center gap-1.5 rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
disabled={step === "details" && !title.trim()}
onClick={() => {
if (step === "details") setStep("metadata");
else if (step === "metadata") setStep("review");
else handleSubmit();
}}
>
{step === "review" ? (
<>
<Sparkles className="h-3.5 w-3.5" />
{t("wizard.createTask")}
</>
) : (
<>
{t("wizard.continue")}
<ArrowRight className="h-3.5 w-3.5" />
</>
)}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,271 @@
"use client";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
X,
CheckCircle2,
Clock,
AlertCircle,
FileText,
GitPullRequest,
ExternalLink,
} from "lucide-react";
import { cn } from "@auto-claude/ui";
import type { Task } from "@auto-claude/types";
interface TaskDetailModalProps {
task: Task;
onClose: () => void;
}
export function TaskDetailModal({ task, onClose }: TaskDetailModalProps) {
const { t } = useTranslation("kanban");
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-background/80 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative z-10 w-full max-w-3xl max-h-[85vh] overflow-hidden rounded-xl border border-border bg-card shadow-2xl">
{/* Header */}
<div className="flex items-start justify-between border-b border-border p-6">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<span
className={cn(
"rounded-full px-2.5 py-0.5 text-xs font-medium",
task.status === "done" && "bg-green-500/10 text-green-600",
task.status === "in_progress" && "bg-yellow-500/10 text-yellow-600",
task.status === "error" && "bg-red-500/10 text-red-600",
task.status === "human_review" && "bg-orange-500/10 text-orange-600",
!["done", "in_progress", "error", "human_review"].includes(task.status) &&
"bg-secondary text-muted-foreground"
)}
>
{t(`columns.${task.status}`, task.status)}
</span>
{task.metadata?.category && (
<span className="rounded-full bg-secondary px-2.5 py-0.5 text-xs text-muted-foreground">
{task.metadata.category}
</span>
)}
</div>
<h2 className="text-xl font-semibold">{task.title}</h2>
</div>
<button
className="flex h-8 w-8 items-center justify-center rounded-md hover:bg-accent transition-colors"
onClick={onClose}
>
<X className="h-4 w-4" />
</button>
</div>
{/* Content */}
<div className="overflow-y-auto max-h-[calc(85vh-80px)] p-6 space-y-6">
{/* Description */}
{task.description && (
<div>
<h3 className="text-sm font-medium mb-2">{t("detail.description")}</h3>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{task.description}
</p>
</div>
)}
{/* Execution Progress */}
{task.executionProgress && (
<div>
<h3 className="text-sm font-medium mb-2">{t("detail.executionProgress")}</h3>
<div className="rounded-lg border border-border p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-muted-foreground capitalize">
{t("detail.phase", { phase: task.executionProgress.phase })}
</span>
<span className="text-sm font-medium">
{task.executionProgress.overallProgress}%
</span>
</div>
<div className="h-2 w-full rounded-full bg-secondary">
<div
className="h-2 rounded-full bg-primary transition-all"
style={{
width: `${task.executionProgress.overallProgress}%`,
}}
/>
</div>
{task.executionProgress.message && (
<p className="mt-2 text-xs text-muted-foreground">
{task.executionProgress.message}
</p>
)}
</div>
</div>
)}
{/* Subtasks */}
{task.subtasks && task.subtasks.length > 0 && (
<div>
<h3 className="text-sm font-medium mb-2">
{t("detail.subtasks", {
completed: task.subtasks.filter((s) => s.status === "completed").length,
total: task.subtasks.length,
})}
</h3>
<div className="space-y-2">
{task.subtasks.map((subtask) => (
<div
key={subtask.id}
className="flex items-start gap-2 rounded-md border border-border p-3"
>
{subtask.status === "completed" ? (
<CheckCircle2 className="h-4 w-4 text-green-500 mt-0.5 shrink-0" />
) : subtask.status === "failed" ? (
<AlertCircle className="h-4 w-4 text-red-500 mt-0.5 shrink-0" />
) : subtask.status === "in_progress" ? (
<Clock className="h-4 w-4 text-yellow-500 mt-0.5 shrink-0 animate-pulse" />
) : (
<Clock className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{subtask.title}</p>
{subtask.description && (
<p className="text-xs text-muted-foreground mt-0.5">
{subtask.description}
</p>
)}
{subtask.files && subtask.files.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{subtask.files.map((file) => (
<span
key={file}
className="inline-flex items-center gap-1 rounded bg-secondary px-1.5 py-0.5 text-[10px] text-muted-foreground"
>
<FileText className="h-2.5 w-2.5" />
{file.split("/").pop()}
</span>
))}
</div>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* QA Report */}
{task.qaReport && (
<div>
<h3 className="text-sm font-medium mb-2">{t("detail.qaReport")}</h3>
<div
className={cn(
"rounded-lg border p-4",
task.qaReport.status === "passed" &&
"border-green-500/30 bg-green-500/5",
task.qaReport.status === "failed" &&
"border-red-500/30 bg-red-500/5",
task.qaReport.status === "pending" &&
"border-border bg-card"
)}
>
<p className="text-sm font-medium capitalize">
{t("detail.qaStatus", { status: task.qaReport.status })}
</p>
{task.qaReport.issues && task.qaReport.issues.length > 0 && (
<ul className="mt-2 space-y-1">
{task.qaReport.issues.map((issue) => (
<li
key={issue.id}
className="text-xs text-muted-foreground flex items-start gap-1"
>
<span
className={cn(
"shrink-0 mt-0.5 h-1.5 w-1.5 rounded-full",
issue.severity === "critical" && "bg-red-500",
issue.severity === "major" && "bg-orange-500",
issue.severity === "minor" && "bg-yellow-500"
)}
/>
{issue.description}
</li>
))}
</ul>
)}
</div>
</div>
)}
{/* Metadata */}
{task.metadata && (
<div>
<h3 className="text-sm font-medium mb-2">{t("detail.details")}</h3>
<div className="grid grid-cols-2 gap-3">
{task.metadata.priority && (
<div className="rounded-md border border-border p-3">
<p className="text-xs text-muted-foreground">{t("detail.priority")}</p>
<p className="text-sm font-medium capitalize">
{task.metadata.priority}
</p>
</div>
)}
{task.metadata.complexity && (
<div className="rounded-md border border-border p-3">
<p className="text-xs text-muted-foreground">{t("detail.complexity")}</p>
<p className="text-sm font-medium capitalize">
{task.metadata.complexity}
</p>
</div>
)}
{task.metadata.impact && (
<div className="rounded-md border border-border p-3">
<p className="text-xs text-muted-foreground">{t("detail.impact")}</p>
<p className="text-sm font-medium capitalize">
{task.metadata.impact}
</p>
</div>
)}
{task.metadata.model && (
<div className="rounded-md border border-border p-3">
<p className="text-xs text-muted-foreground">{t("detail.model")}</p>
<p className="text-sm font-medium capitalize">
{task.metadata.model}
</p>
</div>
)}
</div>
</div>
)}
{/* PR Link */}
{task.metadata?.prUrl && (
<div>
<a
href={task.metadata.prUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-lg border border-border p-3 hover:bg-accent transition-colors"
>
<GitPullRequest className="h-4 w-4 text-green-500" />
<span className="text-sm">{t("detail.viewPullRequest")}</span>
<ExternalLink className="h-3 w-3 text-muted-foreground ml-auto" />
</a>
</div>
)}
</div>
</div>
</div>
);
}
+3
View File
@@ -0,0 +1,3 @@
export { KanbanBoard } from "./KanbanBoard";
export { TaskCard } from "./TaskCard";
export { TaskDetailModal } from "./TaskDetailModal";
+180
View File
@@ -0,0 +1,180 @@
"use client";
import { useEffect } from "react";
import { Sidebar } from "./Sidebar";
import { ProjectTabBar } from "./ProjectTabBar";
import { useUIStore } from "@/stores/ui-store";
import { useProjectStore } from "@/stores/project-store";
import { useTaskStore } from "@/stores/task-store";
import { loadTasks } from "@/stores/task-store";
import { useSettingsStore } from "@/stores/settings-store";
import { KanbanBoard } from "@/components/kanban/KanbanBoard";
import { TaskCreationWizard } from "@/components/kanban/TaskCreationWizard";
import { RoadmapView } from "@/components/roadmap/RoadmapView";
import { IdeationView } from "@/components/ideation/IdeationView";
import { InsightsView } from "@/components/insights/InsightsView";
import { ChangelogView } from "@/components/changelog/ChangelogView";
import { ContextView } from "@/components/context/ContextView";
import { GitHubIssuesView } from "@/components/github/GitHubIssuesView";
import { GitHubPRsView } from "@/components/github/GitHubPRsView";
import { GitLabIssuesView } from "@/components/gitlab/GitLabIssuesView";
import { GitLabMRsView } from "@/components/gitlab/GitLabMRsView";
import { SettingsView } from "@/components/settings/SettingsView";
import { OnboardingWizard } from "@/components/onboarding/OnboardingWizard";
import { WelcomeScreen } from "./WelcomeScreen";
export function AppShell() {
const activeView = useUIStore((s) => s.activeView);
const isNewTaskDialogOpen = useUIStore((s) => s.isNewTaskDialogOpen);
const setNewTaskDialogOpen = useUIStore((s) => s.setNewTaskDialogOpen);
const isOnboardingOpen = useUIStore((s) => s.isOnboardingOpen);
const setOnboardingOpen = useUIStore((s) => s.setOnboardingOpen);
const activeProjectId = useProjectStore((s) => s.activeProjectId);
const selectedProjectId = useProjectStore((s) => s.selectedProjectId);
const projects = useProjectStore((s) => s.projects);
const settings = useSettingsStore((s) => s.settings);
const currentProjectId = activeProjectId || selectedProjectId;
const selectedProject = projects.find((p) => p.id === currentProjectId);
// Load tasks when project changes
useEffect(() => {
if (currentProjectId) {
loadTasks(currentProjectId);
} else {
useTaskStore.getState().clearTasks();
}
}, [currentProjectId]);
// Apply theme from settings
useEffect(() => {
const root = document.documentElement;
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)");
const applyTheme = () => {
if (settings.theme === "dark") {
root.classList.add("dark");
} else if (settings.theme === "light") {
root.classList.remove("dark");
} else {
// System preference
if (prefersDark.matches) {
root.classList.add("dark");
} else {
root.classList.remove("dark");
}
}
};
applyTheme();
prefersDark.addEventListener("change", applyTheme);
return () => prefersDark.removeEventListener("change", applyTheme);
}, [settings.theme]);
// Keyboard shortcuts for navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Don't trigger shortcuts when typing in inputs
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement ||
(e.target as HTMLElement)?.isContentEditable
) {
return;
}
if (!currentProjectId) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
const key = e.key.toUpperCase();
const viewMap: Record<string, typeof activeView> = {
K: "kanban",
N: "insights",
D: "roadmap",
I: "ideation",
L: "changelog",
C: "context",
G: "github-issues",
P: "github-prs",
B: "gitlab-issues",
R: "gitlab-merge-requests",
};
if (viewMap[key]) {
e.preventDefault();
useUIStore.getState().setActiveView(viewMap[key]);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [currentProjectId]);
const renderContent = () => {
// Settings is always accessible, even without a project
if (activeView === "settings") {
return <SettingsView />;
}
if (!selectedProject) {
return <WelcomeScreen />;
}
switch (activeView) {
case "kanban":
return <KanbanBoard />;
case "roadmap":
return <RoadmapView projectId={currentProjectId!} />;
case "ideation":
return <IdeationView projectId={currentProjectId!} />;
case "insights":
return <InsightsView projectId={currentProjectId!} />;
case "changelog":
return <ChangelogView projectId={currentProjectId!} />;
case "context":
return <ContextView projectId={currentProjectId!} />;
case "github-issues":
return <GitHubIssuesView projectId={currentProjectId!} />;
case "github-prs":
return <GitHubPRsView projectId={currentProjectId!} />;
case "gitlab-issues":
return <GitLabIssuesView projectId={currentProjectId!} />;
case "gitlab-merge-requests":
return <GitLabMRsView projectId={currentProjectId!} />;
default:
return <KanbanBoard />;
}
};
return (
<div className="flex h-screen bg-background text-foreground">
{/* Sidebar */}
<Sidebar />
{/* Main content */}
<div className="flex flex-1 flex-col overflow-hidden">
{/* Project Tabs */}
<ProjectTabBar />
{/* Main content area */}
<main className="flex-1 overflow-hidden">{renderContent()}</main>
</div>
{/* Task Creation Wizard */}
{currentProjectId && (
<TaskCreationWizard
open={isNewTaskDialogOpen}
onClose={() => setNewTaskDialogOpen(false)}
projectId={currentProjectId}
/>
)}
{/* Onboarding Wizard */}
<OnboardingWizard
open={isOnboardingOpen}
onClose={() => setOnboardingOpen(false)}
/>
</div>
);
}
@@ -0,0 +1,63 @@
"use client";
import { X, Plus } from "lucide-react";
import { cn } from "@auto-claude/ui";
import { useProjectStore } from "@/stores/project-store";
import { useTranslation } from "react-i18next";
export function ProjectTabBar() {
const projects = useProjectStore((s) => s.projects);
const openProjectIds = useProjectStore((s) => s.openProjectIds);
const activeProjectId = useProjectStore((s) => s.activeProjectId);
const setActiveProject = useProjectStore((s) => s.setActiveProject);
const closeProjectTab = useProjectStore((s) => s.closeProjectTab);
const { t } = useTranslation("layout");
const projectTabs = openProjectIds
.map((id) => projects.find((p) => p.id === id))
.filter(Boolean);
if (projectTabs.length === 0) return null;
return (
<div className="flex items-center border-b border-border bg-card/50">
<div className="flex flex-1 items-center overflow-x-auto">
{projectTabs.map((project) => {
if (!project) return null;
const isActive = project.id === activeProjectId;
return (
<div
key={project.id}
className={cn(
"group flex items-center gap-2 border-r border-border px-4 py-2 text-sm cursor-pointer transition-colors min-w-0",
isActive
? "bg-background text-foreground"
: "bg-card/50 text-muted-foreground hover:bg-accent/50"
)}
onClick={() => setActiveProject(project.id)}
>
<div className="w-1.5 h-4 rounded-full bg-muted-foreground/30 shrink-0" />
<span className="truncate max-w-[150px]">{project.name}</span>
<button
className="opacity-0 group-hover:opacity-100 transition-opacity ml-1 hover:bg-accent rounded-sm p-0.5"
onClick={(e) => {
e.stopPropagation();
closeProjectTab(project.id);
}}
>
<X className="h-3 w-3" />
</button>
</div>
);
})}
</div>
<button
className="flex h-full items-center px-3 text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
aria-label={t("projectTabBar.addProject")}
>
<Plus className="h-4 w-4" />
</button>
</div>
);
}
+218
View File
@@ -0,0 +1,218 @@
"use client";
import { useMemo } from "react";
import {
Plus,
Settings,
LayoutGrid,
Map,
BookOpen,
Lightbulb,
Sparkles,
FileText,
Github,
GitPullRequest,
GitMerge,
HelpCircle,
PanelLeft,
PanelLeftClose,
} from "lucide-react";
import { cn } from "@auto-claude/ui";
import { useSettingsStore, saveSettings } from "@/stores/settings-store";
import { useUIStore, type SidebarView } from "@/stores/ui-store";
import { useProjectStore } from "@/stores/project-store";
import { useTranslation } from "react-i18next";
interface NavItem {
id: SidebarView;
labelKey: string;
icon: React.ElementType;
shortcut?: string;
}
const baseNavItems: NavItem[] = [
{ id: "kanban", labelKey: "sidebar.nav.tasks", icon: LayoutGrid, shortcut: "K" },
{ id: "insights", labelKey: "sidebar.nav.insights", icon: Sparkles, shortcut: "N" },
{ id: "roadmap", labelKey: "sidebar.nav.roadmap", icon: Map, shortcut: "D" },
{ id: "ideation", labelKey: "sidebar.nav.ideation", icon: Lightbulb, shortcut: "I" },
{ id: "changelog", labelKey: "sidebar.nav.changelog", icon: FileText, shortcut: "L" },
{ id: "context", labelKey: "sidebar.nav.context", icon: BookOpen, shortcut: "C" },
];
const githubNavItems: NavItem[] = [
{ id: "github-issues", labelKey: "sidebar.nav.githubIssues", icon: Github, shortcut: "G" },
{ id: "github-prs", labelKey: "sidebar.nav.githubPrs", icon: GitPullRequest, shortcut: "P" },
];
const gitlabNavItems: NavItem[] = [
{ id: "gitlab-issues", labelKey: "sidebar.nav.gitlabIssues", icon: Github, shortcut: "B" },
{ id: "gitlab-merge-requests", labelKey: "sidebar.nav.gitlabMrs", icon: GitMerge, shortcut: "R" },
];
export function Sidebar() {
const settings = useSettingsStore((s) => s.settings);
const activeView = useUIStore((s) => s.activeView);
const setActiveView = useUIStore((s) => s.setActiveView);
const setNewTaskDialogOpen = useUIStore((s) => s.setNewTaskDialogOpen);
const activeProjectId = useProjectStore((s) => s.activeProjectId);
const { t } = useTranslation("layout");
const isCollapsed = settings.sidebarCollapsed ?? false;
const toggleSidebar = () => {
saveSettings({ sidebarCollapsed: !isCollapsed });
};
// Show GitHub and GitLab items by default (env config will drive this later)
const visibleNavItems = useMemo(() => {
return [...baseNavItems, ...githubNavItems, ...gitlabNavItems];
}, []);
const renderNavItem = (item: NavItem) => {
const isActive = activeView === item.id;
const Icon = item.icon;
return (
<button
key={item.id}
onClick={() => setActiveView(item.id)}
disabled={!activeProjectId}
className={cn(
"flex w-full items-center rounded-lg text-sm transition-all duration-200",
"hover:bg-accent hover:text-accent-foreground",
"disabled:pointer-events-none disabled:opacity-50",
isActive && "bg-accent text-accent-foreground",
isCollapsed ? "justify-center px-2 py-2.5" : "gap-3 px-3 py-2.5"
)}
>
<Icon className="h-4 w-4 shrink-0" />
{!isCollapsed && (
<>
<span className="flex-1 text-left">{t(item.labelKey)}</span>
{item.shortcut && (
<kbd className="pointer-events-none hidden h-5 select-none items-center gap-1 rounded-md border border-border bg-secondary px-1.5 font-mono text-[10px] font-medium text-muted-foreground sm:flex">
{item.shortcut}
</kbd>
)}
</>
)}
</button>
);
};
return (
<div
className={cn(
"flex h-full flex-col bg-sidebar border-r border-border transition-all duration-300",
isCollapsed ? "w-16" : "w-64"
)}
>
{/* Header */}
<div
className={cn(
"flex h-14 items-center transition-all duration-300",
isCollapsed ? "justify-center px-2" : "px-4"
)}
>
{!isCollapsed && (
<span className="text-lg font-bold text-primary">{t("sidebar.brand")}</span>
)}
{isCollapsed && (
<span className="text-lg font-bold text-primary">{t("sidebar.brandShort")}</span>
)}
</div>
<div className="h-px bg-border" />
{/* Toggle button */}
<div
className={cn(
"flex py-2 transition-all duration-300",
isCollapsed ? "justify-center px-2" : "justify-end px-3"
)}
>
<button
className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent"
onClick={toggleSidebar}
aria-label={isCollapsed ? t("sidebar.aria.expandSidebar") : t("sidebar.aria.collapseSidebar")}
>
{isCollapsed ? (
<PanelLeft className="h-4 w-4" />
) : (
<PanelLeftClose className="h-4 w-4" />
)}
</button>
</div>
<div className="h-px bg-border" />
{/* Navigation */}
<div className="flex-1 overflow-y-auto">
<div
className={cn(
"py-4 transition-all duration-300",
isCollapsed ? "px-2" : "px-3"
)}
>
{!isCollapsed && (
<h3 className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t("sidebar.sectionProject")}
</h3>
)}
<nav className="space-y-1">{visibleNavItems.map(renderNavItem)}</nav>
</div>
</div>
<div className="h-px bg-border" />
{/* Bottom section */}
<div
className={cn(
"space-y-3 transition-all duration-300",
isCollapsed ? "p-2" : "p-4"
)}
>
{/* Settings and Help row */}
<div
className={cn(
"flex items-center",
isCollapsed ? "flex-col gap-1" : "gap-2"
)}
>
<button
className={cn(
"flex items-center rounded-md hover:bg-accent transition-colors",
isCollapsed ? "h-8 w-8 justify-center" : "flex-1 gap-2 px-3 py-1.5 text-sm justify-start"
)}
onClick={() => setActiveView("settings")}
>
<Settings className="h-4 w-4" />
{!isCollapsed && t("sidebar.actions.settings")}
</button>
<button
className="flex h-8 w-8 items-center justify-center rounded-md hover:bg-accent transition-colors"
onClick={() =>
window.open("https://github.com/AndyMik90/Auto-Claude/issues", "_blank")
}
aria-label={t("sidebar.aria.help")}
>
<HelpCircle className="h-4 w-4" />
</button>
</div>
{/* New Task button */}
<button
className={cn(
"w-full rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors flex items-center justify-center",
isCollapsed ? "h-8 w-8 mx-auto" : "px-4 py-2 text-sm"
)}
onClick={() => setNewTaskDialogOpen(true)}
disabled={!activeProjectId}
>
<Plus className={isCollapsed ? "h-4 w-4" : "mr-2 h-4 w-4"} />
{!isCollapsed && t("sidebar.actions.newTask")}
</button>
</div>
</div>
);
}
@@ -0,0 +1,54 @@
"use client";
import { Sparkles, ArrowRight } from "lucide-react";
import { useProjectStore } from "@/stores/project-store";
import { useUIStore } from "@/stores/ui-store";
import { useTranslation } from "react-i18next";
export function WelcomeScreen() {
const { t } = useTranslation("layout");
const connectDemoProject = () => {
const demoProject = {
id: "demo-project",
name: "Auto Claude",
path: "/demo",
repoUrl: "https://github.com/AndyMik90/Auto-Claude",
description: "Autonomous coding framework",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
useProjectStore.getState().setProjects([demoProject] as any);
useProjectStore.getState().openProjectTab(demoProject.id);
useUIStore.getState().setActiveView("kanban");
};
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">
<Sparkles className="h-8 w-8 text-primary" />
</div>
</div>
<h1 className="mb-3 text-2xl font-bold">{t("welcome.title")}</h1>
<p className="mb-8 text-muted-foreground">
{t("welcome.description")}
</p>
<div className="space-y-3">
<button
onClick={connectDemoProject}
className="flex w-full items-center justify-center gap-2 rounded-lg bg-primary px-6 py-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
{t("welcome.connectProject")}
<ArrowRight className="h-4 w-4" />
</button>
<p className="text-xs text-muted-foreground">
{t("welcome.subtext")}
</p>
</div>
</div>
</div>
);
}
+4
View File
@@ -0,0 +1,4 @@
export { AppShell } from "./AppShell";
export { Sidebar } from "./Sidebar";
export { ProjectTabBar } from "./ProjectTabBar";
export { WelcomeScreen } from "./WelcomeScreen";
@@ -0,0 +1,210 @@
"use client";
import { useState, useEffect } from "react";
import {
Sparkles,
Key,
FolderOpen,
CheckCircle2,
ArrowRight,
ArrowLeft,
} from "lucide-react";
import { cn } from "@auto-claude/ui";
import { useTranslation } from "react-i18next";
interface OnboardingWizardProps {
open: boolean;
onClose: () => void;
}
type Step = "welcome" | "api-key" | "project" | "complete";
const STEP_IDS: { id: Step; titleKey: string; icon: React.ElementType }[] = [
{ id: "welcome", titleKey: "onboarding.steps.welcome", icon: Sparkles },
{ id: "api-key", titleKey: "onboarding.steps.apiKey", icon: Key },
{ id: "project", titleKey: "onboarding.steps.project", icon: FolderOpen },
{ id: "complete", titleKey: "onboarding.steps.complete", icon: CheckCircle2 },
];
export function OnboardingWizard({ open, onClose }: OnboardingWizardProps) {
const [currentStep, setCurrentStep] = useState<Step>("welcome");
const { t } = useTranslation("layout");
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [open, onClose]);
if (!open) return null;
const currentIndex = STEP_IDS.findIndex((s) => s.id === currentStep);
const goNext = () => {
if (currentIndex < STEP_IDS.length - 1) {
setCurrentStep(STEP_IDS[currentIndex + 1].id);
}
};
const goPrev = () => {
if (currentIndex > 0) {
setCurrentStep(STEP_IDS[currentIndex - 1].id);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm" />
<div className="relative z-10 w-full max-w-lg rounded-xl border border-border bg-card shadow-2xl">
{/* Progress */}
<div className="flex items-center justify-center gap-2 border-b border-border px-6 py-4">
{STEP_IDS.map((step, idx) => {
const Icon = step.icon;
const isActive = idx === currentIndex;
const isComplete = idx < currentIndex;
return (
<div key={step.id} className="flex items-center">
<div
className={cn(
"flex h-8 w-8 items-center justify-center rounded-full transition-colors",
isActive && "bg-primary text-primary-foreground",
isComplete && "bg-green-500/10 text-green-600",
!isActive && !isComplete && "bg-secondary text-muted-foreground"
)}
>
{isComplete ? (
<CheckCircle2 className="h-4 w-4" />
) : (
<Icon className="h-4 w-4" />
)}
</div>
{idx < STEP_IDS.length - 1 && (
<div
className={cn(
"mx-2 h-px w-8",
idx < currentIndex ? "bg-green-500" : "bg-border"
)}
/>
)}
</div>
);
})}
</div>
{/* Content */}
<div className="p-8">
{currentStep === "welcome" && (
<div className="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">
<Sparkles className="h-8 w-8 text-primary" />
</div>
</div>
<h2 className="text-xl font-semibold mb-2">
{t("onboarding.title")}
</h2>
<p className="text-sm text-muted-foreground mb-4">
{t("onboarding.titleDescription")}
</p>
</div>
)}
{currentStep === "api-key" && (
<div>
<h2 className="text-xl font-semibold mb-2">{t("onboarding.apiConfiguration.title")}</h2>
<p className="text-sm text-muted-foreground mb-6">
{t("onboarding.apiConfiguration.description")}
</p>
<div className="space-y-4">
<div>
<label className="text-sm font-medium">{t("onboarding.apiConfiguration.authMethod")}</label>
<div className="mt-2 grid grid-cols-2 gap-3">
<button className="rounded-lg border-2 border-primary bg-primary/5 p-4 text-left">
<p className="text-sm font-medium">{t("onboarding.apiConfiguration.claudeOAuth")}</p>
<p className="text-xs text-muted-foreground mt-1">
{t("onboarding.apiConfiguration.claudeOAuthDescription")}
</p>
</button>
<button className="rounded-lg border border-border p-4 text-left hover:border-border/80 transition-colors">
<p className="text-sm font-medium">{t("onboarding.apiConfiguration.apiKey")}</p>
<p className="text-xs text-muted-foreground mt-1">
{t("onboarding.apiConfiguration.apiKeyDescription")}
</p>
</button>
</div>
</div>
</div>
</div>
)}
{currentStep === "project" && (
<div>
<h2 className="text-xl font-semibold mb-2">{t("onboarding.connectProject.title")}</h2>
<p className="text-sm text-muted-foreground mb-6">
{t("onboarding.connectProject.description")}
</p>
<div className="space-y-4">
<button className="w-full flex items-center gap-3 rounded-lg border-2 border-dashed border-border p-6 hover:border-primary/50 hover:bg-primary/5 transition-colors">
<FolderOpen className="h-8 w-8 text-muted-foreground" />
<div className="text-left">
<p className="text-sm font-medium">{t("onboarding.connectProject.selectDirectory")}</p>
<p className="text-xs text-muted-foreground">
{t("onboarding.connectProject.selectDirectoryDescription")}
</p>
</div>
</button>
</div>
</div>
)}
{currentStep === "complete" && (
<div className="text-center">
<div className="mb-6 flex justify-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-green-500/10">
<CheckCircle2 className="h-8 w-8 text-green-600" />
</div>
</div>
<h2 className="text-xl font-semibold mb-2">{t("onboarding.complete.title")}</h2>
<p className="text-sm text-muted-foreground mb-4">
{t("onboarding.complete.description")}
</p>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t border-border px-6 py-4">
<button
className="flex items-center gap-1.5 rounded-md px-4 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
onClick={currentStep === "welcome" ? onClose : goPrev}
>
{currentStep === "welcome" ? (
t("onboarding.actions.skipSetup")
) : (
<>
<ArrowLeft className="h-3.5 w-3.5" />
{t("onboarding.actions.back")}
</>
)}
</button>
<button
className="flex items-center gap-1.5 rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 transition-colors"
onClick={currentStep === "complete" ? onClose : goNext}
>
{currentStep === "complete" ? (
t("onboarding.actions.getStarted")
) : (
<>
{t("onboarding.actions.continue")}
<ArrowRight className="h-3.5 w-3.5" />
</>
)}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,332 @@
"use client";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
Map,
Plus,
RefreshCw,
Sparkles,
LayoutGrid,
List,
Calendar,
Target,
TrendingUp,
ChevronRight,
} from "lucide-react";
import { cn } from "@auto-claude/ui";
interface RoadmapViewProps {
projectId: string;
}
interface Feature {
id: string;
title: string;
description: string;
phase: string;
priority: "high" | "medium" | "low";
status: "planned" | "in_progress" | "completed";
effort: string;
}
// Placeholder data for UI layout -- will be replaced with API data
const PLACEHOLDER_PHASES = [
{
nameKey: "roadmap.phases.phase1" as const,
features: [
{ id: "1", title: "Core Authentication", description: "User login, registration, and session management", phase: "Phase 1", priority: "high" as const, status: "completed" as const, effort: "Large" },
{ id: "2", title: "Database Schema", description: "Initial database models and migrations", phase: "Phase 1", priority: "high" as const, status: "completed" as const, effort: "Medium" },
],
},
{
nameKey: "roadmap.phases.phase2" as const,
features: [
{ id: "3", title: "Task Management", description: "CRUD operations for tasks and subtasks", phase: "Phase 2", priority: "high" as const, status: "in_progress" as const, effort: "Large" },
{ id: "4", title: "Real-time Updates", description: "WebSocket integration for live updates", phase: "Phase 2", priority: "medium" as const, status: "planned" as const, effort: "Medium" },
],
},
{
nameKey: "roadmap.phases.phase3" as const,
features: [
{ id: "5", title: "GitHub Integration", description: "Issue sync and PR management", phase: "Phase 3", priority: "medium" as const, status: "planned" as const, effort: "Large" },
{ id: "6", title: "CI/CD Pipeline", description: "Automated deployment workflows", phase: "Phase 3", priority: "low" as const, status: "planned" as const, effort: "Small" },
],
},
];
export function RoadmapView({ projectId }: RoadmapViewProps) {
const { t } = useTranslation("views");
const [activeTab, setActiveTab] = useState<"kanban" | "timeline" | "list">("kanban");
const [selectedFeature, setSelectedFeature] = useState<Feature | null>(null);
const [isEmpty] = useState(false);
const statusLabel = (status: Feature["status"]) => {
if (status === "in_progress") return t("roadmap.status.inProgress");
if (status === "completed") return t("roadmap.status.completed");
return t("roadmap.status.planned");
};
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">
<Map className="h-8 w-8 text-primary" />
</div>
</div>
<h2 className="mb-3 text-xl font-semibold">{t("roadmap.empty.title")}</h2>
<p className="mb-6 text-sm text-muted-foreground">
{t("roadmap.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("roadmap.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">
<div className="flex items-center gap-3">
<h1 className="text-lg font-semibold">{t("roadmap.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 === "kanban" ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground"
)}
onClick={() => setActiveTab("kanban")}
>
<LayoutGrid className="h-3 w-3" />
{t("roadmap.tabs.board")}
</button>
<button
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors",
activeTab === "timeline" ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground"
)}
onClick={() => setActiveTab("timeline")}
>
<Calendar className="h-3 w-3" />
{t("roadmap.tabs.timeline")}
</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 === "list" ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground"
)}
onClick={() => setActiveTab("list")}
>
<List className="h-3 w-3" />
{t("roadmap.tabs.list")}
</button>
</div>
</div>
<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("roadmap.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("roadmap.addFeature")}
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-6">
{activeTab === "kanban" && (
<div className="space-y-8">
{PLACEHOLDER_PHASES.map((phase) => (
<div key={phase.nameKey}>
<h2 className="text-sm font-semibold text-muted-foreground mb-3 flex items-center gap-2">
<Target className="h-4 w-4" />
{t(phase.nameKey)}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{phase.features.map((feature) => (
<div
key={feature.id}
className="rounded-lg border border-border bg-card p-4 cursor-pointer hover:shadow-md transition-all"
onClick={() => setSelectedFeature(feature)}
>
<div className="flex items-start justify-between gap-2">
<h3 className="text-sm font-medium">{feature.title}</h3>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium",
feature.status === "completed" && "bg-green-500/10 text-green-600",
feature.status === "in_progress" && "bg-yellow-500/10 text-yellow-600",
feature.status === "planned" && "bg-secondary text-muted-foreground"
)}
>
{statusLabel(feature.status)}
</span>
</div>
<p className="mt-1.5 text-xs text-muted-foreground line-clamp-2">
{feature.description}
</p>
<div className="mt-3 flex items-center gap-2">
<span
className={cn(
"rounded-full px-2 py-0.5 text-[10px] font-medium",
feature.priority === "high" && "bg-red-500/10 text-red-600",
feature.priority === "medium" && "bg-yellow-500/10 text-yellow-600",
feature.priority === "low" && "bg-blue-500/10 text-blue-600"
)}
>
{feature.priority}
</span>
<span className="text-[10px] text-muted-foreground">
{feature.effort}
</span>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
{activeTab === "timeline" && (
<div className="space-y-4">
{PLACEHOLDER_PHASES.map((phase, idx) => (
<div key={phase.nameKey} className="flex gap-4">
<div className="flex flex-col items-center">
<div className={cn(
"flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold",
idx === 0 && "bg-green-500/10 text-green-600",
idx === 1 && "bg-yellow-500/10 text-yellow-600",
idx === 2 && "bg-blue-500/10 text-blue-600",
)}>
{idx + 1}
</div>
{idx < PLACEHOLDER_PHASES.length - 1 && (
<div className="flex-1 w-px bg-border my-2" />
)}
</div>
<div className="flex-1 pb-6">
<h3 className="text-sm font-semibold mb-2">{t(phase.nameKey)}</h3>
<div className="space-y-2">
{phase.features.map((feature) => (
<div
key={feature.id}
className="flex items-center gap-2 rounded-md border border-border bg-card/50 p-2.5 cursor-pointer hover:bg-accent/50 transition-colors"
onClick={() => setSelectedFeature(feature)}
>
<TrendingUp className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-sm flex-1">{feature.title}</span>
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
</div>
))}
</div>
</div>
</div>
))}
</div>
)}
{activeTab === "list" && (
<div className="rounded-lg border border-border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-card/50">
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">{t("roadmap.table.feature")}</th>
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">{t("roadmap.table.phase")}</th>
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">{t("roadmap.table.priority")}</th>
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">{t("roadmap.table.status")}</th>
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">{t("roadmap.table.effort")}</th>
</tr>
</thead>
<tbody>
{PLACEHOLDER_PHASES.flatMap((phase) =>
phase.features.map((feature) => (
<tr
key={feature.id}
className="border-b border-border hover:bg-accent/50 cursor-pointer transition-colors"
onClick={() => setSelectedFeature(feature)}
>
<td className="px-4 py-2.5 font-medium">{feature.title}</td>
<td className="px-4 py-2.5 text-muted-foreground">{feature.phase}</td>
<td className="px-4 py-2.5">
<span className={cn(
"rounded-full px-2 py-0.5 text-[10px] font-medium",
feature.priority === "high" && "bg-red-500/10 text-red-600",
feature.priority === "medium" && "bg-yellow-500/10 text-yellow-600",
feature.priority === "low" && "bg-blue-500/10 text-blue-600"
)}>
{feature.priority}
</span>
</td>
<td className="px-4 py-2.5">
<span className={cn(
"rounded-full px-2 py-0.5 text-[10px] font-medium",
feature.status === "completed" && "bg-green-500/10 text-green-600",
feature.status === "in_progress" && "bg-yellow-500/10 text-yellow-600",
feature.status === "planned" && "bg-secondary text-muted-foreground"
)}>
{statusLabel(feature.status)}
</span>
</td>
<td className="px-4 py-2.5 text-muted-foreground">{feature.effort}</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
</div>
{/* Feature Detail Side Panel */}
{selectedFeature && (
<div className="fixed inset-y-0 right-0 z-50 w-96 border-l border-border bg-card shadow-xl">
<div className="flex items-center justify-between border-b border-border p-4">
<h2 className="text-sm font-semibold">{t("roadmap.detail.title")}</h2>
<button
className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent"
onClick={() => setSelectedFeature(null)}
>
<span className="text-lg leading-none">&times;</span>
</button>
</div>
<div className="p-4 space-y-4">
<div>
<h3 className="text-lg font-semibold">{selectedFeature.title}</h3>
<p className="mt-1 text-sm text-muted-foreground">{selectedFeature.description}</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-md border border-border p-3">
<p className="text-xs text-muted-foreground">{t("roadmap.detail.priority")}</p>
<p className="text-sm font-medium capitalize">{selectedFeature.priority}</p>
</div>
<div className="rounded-md border border-border p-3">
<p className="text-xs text-muted-foreground">{t("roadmap.detail.effort")}</p>
<p className="text-sm font-medium">{selectedFeature.effort}</p>
</div>
<div className="rounded-md border border-border p-3">
<p className="text-xs text-muted-foreground">{t("roadmap.detail.phase")}</p>
<p className="text-sm font-medium">{selectedFeature.phase}</p>
</div>
<div className="rounded-md border border-border p-3">
<p className="text-xs text-muted-foreground">{t("roadmap.detail.status")}</p>
<p className="text-sm font-medium capitalize">{selectedFeature.status.replace("_", " ")}</p>
</div>
</div>
<button className="w-full rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 transition-colors">
{t("roadmap.detail.convertToTask")}
</button>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,269 @@
"use client";
import { useState } from "react";
import {
Settings,
User,
Palette,
Globe,
Shield,
Bell,
Github,
Key,
Database,
Monitor,
Moon,
Sun,
} from "lucide-react";
import { cn } from "@auto-claude/ui";
import { useSettingsStore, saveSettings } from "@/stores/settings-store";
import { useTranslation } from "react-i18next";
type SettingsSection =
| "general"
| "appearance"
| "accounts"
| "github"
| "notifications"
| "advanced";
const SECTION_IDS: { id: SettingsSection; icon: React.ElementType }[] = [
{ id: "general", icon: Settings },
{ id: "appearance", icon: Palette },
{ id: "accounts", icon: Key },
{ id: "github", icon: Github },
{ id: "notifications", icon: Bell },
{ id: "advanced", icon: Database },
];
export function SettingsView() {
const [activeSection, setActiveSection] = useState<SettingsSection>("general");
const settings = useSettingsStore((s) => s.settings);
const { t } = useTranslation("settings");
const SECTIONS = SECTION_IDS.map((s) => ({
...s,
label: t(`sections.${s.id}.title`),
}));
return (
<div className="flex h-full overflow-hidden">
{/* Sidebar */}
<div className="w-56 border-r border-border bg-card/50 p-4">
<h1 className="text-sm font-semibold mb-4 px-3">{t("title")}</h1>
<nav className="space-y-1">
{SECTIONS.map((section) => {
const Icon = section.icon;
return (
<button
key={section.id}
className={cn(
"flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-colors",
activeSection === section.id
? "bg-accent text-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
)}
onClick={() => setActiveSection(section.id)}
>
<Icon className="h-4 w-4" />
{section.label}
</button>
);
})}
</nav>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-8">
<div className="max-w-2xl">
{activeSection === "general" && (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold mb-1">{t("sections.general.title")}</h2>
<p className="text-sm text-muted-foreground">
{t("sections.general.description")}
</p>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between rounded-lg border border-border p-4">
<div>
<p className="text-sm font-medium">{t("fields.language")}</p>
<p className="text-xs text-muted-foreground">
{t("fields.languageDescription")}
</p>
</div>
<select className="rounded-md border border-border bg-background px-3 py-1.5 text-sm">
<option value="en">{t("languages.en")}</option>
<option value="fr">{t("languages.fr")}</option>
</select>
</div>
</div>
</div>
)}
{activeSection === "appearance" && (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold mb-1">{t("sections.appearance.title")}</h2>
<p className="text-sm text-muted-foreground">
{t("sections.appearance.description")}
</p>
</div>
<div className="space-y-4">
{/* Theme */}
<div className="rounded-lg border border-border p-4">
<p className="text-sm font-medium mb-3">{t("fields.theme")}</p>
<div className="grid grid-cols-3 gap-3">
{(["light", "dark", "system"] as const).map((theme) => {
const Icon = theme === "light" ? Sun : theme === "dark" ? Moon : Monitor;
return (
<button
key={theme}
className={cn(
"flex flex-col items-center gap-2 rounded-lg border p-4 transition-colors",
settings.theme === theme
? "border-primary bg-primary/5"
: "border-border hover:border-border/80"
)}
onClick={() => saveSettings({ theme })}
>
<Icon className="h-5 w-5" />
<span className="text-xs font-medium capitalize">
{t(`themes.${theme}`)}
</span>
</button>
);
})}
</div>
</div>
</div>
</div>
)}
{activeSection === "accounts" && (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold mb-1">{t("sections.accounts.title")}</h2>
<p className="text-sm text-muted-foreground">
{t("sections.accounts.description")}
</p>
</div>
<div className="space-y-4">
<div className="rounded-lg border border-border p-4">
<div className="flex items-center gap-2 mb-3">
<Key className="h-4 w-4 text-primary" />
<p className="text-sm font-medium">{t("fields.claudeApi")}</p>
</div>
<p className="text-xs text-muted-foreground mb-3">
{t("fields.claudeApiDescription")}
</p>
<button className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 transition-colors">
{t("actions.configure")}
</button>
</div>
</div>
</div>
)}
{activeSection === "github" && (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold mb-1">{t("sections.github.title")}</h2>
<p className="text-sm text-muted-foreground">
{t("sections.github.description")}
</p>
</div>
<div className="space-y-4">
<div className="rounded-lg border border-border p-4">
<div className="flex items-center gap-2 mb-3">
<Github className="h-4 w-4" />
<p className="text-sm font-medium">{t("fields.repository")}</p>
</div>
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground">{t("fields.repositoryLabel")}</label>
<input
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
placeholder={t("placeholders.ownerRepo")}
/>
</div>
<div>
<label className="text-xs text-muted-foreground">{t("fields.mainBranch")}</label>
<input
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
placeholder={t("placeholders.main")}
/>
</div>
</div>
</div>
</div>
</div>
)}
{activeSection === "notifications" && (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold mb-1">{t("sections.notifications.title")}</h2>
<p className="text-sm text-muted-foreground">
{t("sections.notifications.description")}
</p>
</div>
<div className="space-y-4">
{(["taskCompleted", "taskFailed", "reviewNeeded"] as const).map((key) => (
<div
key={key}
className="flex items-center justify-between rounded-lg border border-border p-4"
>
<div>
<p className="text-sm font-medium">{t(`notifications.${key}.label`)}</p>
<p className="text-xs text-muted-foreground">{t(`notifications.${key}.description`)}</p>
</div>
<button className="relative inline-flex h-6 w-11 items-center rounded-full bg-primary transition-colors">
<span className="inline-block h-4 w-4 transform rounded-full bg-white transition-transform translate-x-6" />
</button>
</div>
))}
</div>
</div>
)}
{activeSection === "advanced" && (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold mb-1">{t("sections.advanced.title")}</h2>
<p className="text-sm text-muted-foreground">
{t("sections.advanced.description")}
</p>
</div>
<div className="space-y-4">
<div className="rounded-lg border border-border p-4">
<div className="flex items-center gap-2 mb-3">
<Database className="h-4 w-4 text-primary" />
<p className="text-sm font-medium">{t("fields.memorySystem")}</p>
</div>
<p className="text-xs text-muted-foreground mb-3">
{t("fields.memorySystemDescription")}
</p>
<div className="flex items-center gap-2">
<span className="rounded-full bg-green-500/10 text-green-600 px-2 py-0.5 text-xs">
{t("status.active")}
</span>
<span className="text-xs text-muted-foreground">
{t("status.usingLadybugDb")}
</span>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,115 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useCloudMode } from '../useCloudMode';
describe('useCloudMode', () => {
beforeEach(() => {
// Reset environment variables before each test
delete process.env.NEXT_PUBLIC_CONVEX_URL;
delete process.env.NEXT_PUBLIC_API_URL;
// Clear module cache to get fresh imports
vi.resetModules();
});
describe('self-hosted mode', () => {
it('should return isCloud=false when NEXT_PUBLIC_CONVEX_URL is not set', async () => {
delete process.env.NEXT_PUBLIC_CONVEX_URL;
// Dynamically import to get fresh module
const { useCloudMode: freshHook } = await import('../useCloudMode');
const { result } = renderHook(() => freshHook());
expect(result.current.isCloud).toBe(false);
});
it('should return default API URL when NEXT_PUBLIC_API_URL is not set', async () => {
delete process.env.NEXT_PUBLIC_CONVEX_URL;
delete process.env.NEXT_PUBLIC_API_URL;
const { useCloudMode: freshHook } = await import('../useCloudMode');
const { result } = renderHook(() => freshHook());
expect(result.current.apiUrl).toBe('http://localhost:8000');
});
it('should return custom API URL when NEXT_PUBLIC_API_URL is set', async () => {
delete process.env.NEXT_PUBLIC_CONVEX_URL;
process.env.NEXT_PUBLIC_API_URL = 'https://api.example.com';
const { useCloudMode: freshHook } = await import('../useCloudMode');
const { result } = renderHook(() => freshHook());
expect(result.current.apiUrl).toBe('https://api.example.com');
});
});
describe('cloud mode', () => {
it('should return isCloud=true when NEXT_PUBLIC_CONVEX_URL is set', async () => {
process.env.NEXT_PUBLIC_CONVEX_URL = 'https://example.convex.cloud';
const { useCloudMode: freshHook } = await import('../useCloudMode');
const { result } = renderHook(() => freshHook());
expect(result.current.isCloud).toBe(true);
});
it('should still provide API URL in cloud mode', async () => {
process.env.NEXT_PUBLIC_CONVEX_URL = 'https://example.convex.cloud';
process.env.NEXT_PUBLIC_API_URL = 'https://api.example.com';
const { useCloudMode: freshHook } = await import('../useCloudMode');
const { result } = renderHook(() => freshHook());
expect(result.current.isCloud).toBe(true);
expect(result.current.apiUrl).toBe('https://api.example.com');
});
});
describe('return value immutability', () => {
it('should return a readonly object', async () => {
const { useCloudMode: freshHook } = await import('../useCloudMode');
const { result } = renderHook(() => freshHook());
// TypeScript should enforce this, but we can verify the structure
expect(result.current).toHaveProperty('isCloud');
expect(result.current).toHaveProperty('apiUrl');
expect(Object.keys(result.current)).toHaveLength(2);
});
it('should return consistent values across multiple calls', async () => {
process.env.NEXT_PUBLIC_CONVEX_URL = 'https://example.convex.cloud';
process.env.NEXT_PUBLIC_API_URL = 'https://api.example.com';
const { useCloudMode: freshHook } = await import('../useCloudMode');
const { result, rerender } = renderHook(() => freshHook());
const firstCall = result.current;
rerender();
const secondCall = result.current;
expect(firstCall.isCloud).toBe(secondCall.isCloud);
expect(firstCall.apiUrl).toBe(secondCall.apiUrl);
});
});
describe('edge cases', () => {
it('should handle empty string CONVEX_URL', async () => {
process.env.NEXT_PUBLIC_CONVEX_URL = '';
const { useCloudMode: freshHook } = await import('../useCloudMode');
const { result } = renderHook(() => freshHook());
expect(result.current.isCloud).toBe(false);
});
it('should handle whitespace-only CONVEX_URL', async () => {
process.env.NEXT_PUBLIC_CONVEX_URL = ' ';
const { useCloudMode: freshHook } = await import('../useCloudMode');
const { result } = renderHook(() => freshHook());
// Whitespace is truthy, so CLOUD_MODE will be true
expect(result.current.isCloud).toBe(true);
});
});
});
@@ -0,0 +1,143 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useCurrentUser } from '../useCurrentUser';
import * as convexReact from 'convex/react';
// Mock the Convex API
vi.mock('../../convex/_generated/api', () => ({
api: {
users: {
me: 'users:me'
}
}
}));
describe('useCurrentUser', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should call useQuery with the users.me API endpoint', () => {
const mockUseQuery = vi.fn().mockReturnValue(undefined);
vi.spyOn(convexReact, 'useQuery').mockImplementation(mockUseQuery);
renderHook(() => useCurrentUser());
// Verify useQuery was called once with the API endpoint
expect(mockUseQuery).toHaveBeenCalledTimes(1);
// The API endpoint is passed as first argument
expect(mockUseQuery.mock.calls[0].length).toBeGreaterThan(0);
});
it('should return user data when authenticated', () => {
const mockUser = {
_id: 'user123',
email: 'test@example.com',
name: 'Test User',
tier: 'pro' as const,
};
vi.spyOn(convexReact, 'useQuery').mockReturnValue(mockUser);
const { result } = renderHook(() => useCurrentUser());
expect(result.current).toEqual(mockUser);
});
it('should return undefined when not authenticated', () => {
vi.spyOn(convexReact, 'useQuery').mockReturnValue(undefined);
const { result } = renderHook(() => useCurrentUser());
expect(result.current).toBeUndefined();
});
it('should return null when query is loading', () => {
vi.spyOn(convexReact, 'useQuery').mockReturnValue(null);
const { result } = renderHook(() => useCurrentUser());
expect(result.current).toBeNull();
});
it('should update when user data changes', () => {
const mockUser1 = {
_id: 'user123',
email: 'test@example.com',
name: 'Test User',
tier: 'free' as const,
};
const mockUser2 = {
_id: 'user123',
email: 'test@example.com',
name: 'Test User',
tier: 'pro' as const,
};
const mockUseQuery = vi.spyOn(convexReact, 'useQuery');
mockUseQuery.mockReturnValue(mockUser1);
const { result, rerender } = renderHook(() => useCurrentUser());
expect(result.current).toEqual(mockUser1);
// Simulate user upgrade
mockUseQuery.mockReturnValue(mockUser2);
rerender();
expect(result.current).toEqual(mockUser2);
});
it('should handle user with all tier types', () => {
const tiers = ['free', 'pro', 'team', 'enterprise'] as const;
tiers.forEach(tier => {
const mockUser = {
_id: 'user123',
email: 'test@example.com',
name: 'Test User',
tier,
};
vi.spyOn(convexReact, 'useQuery').mockReturnValue(mockUser);
const { result } = renderHook(() => useCurrentUser());
expect(result.current?.tier).toBe(tier);
});
});
it('should handle user without tier (defaults to free)', () => {
const mockUser = {
_id: 'user123',
email: 'test@example.com',
name: 'Test User',
// tier not set - should default to free in the backend
};
vi.spyOn(convexReact, 'useQuery').mockReturnValue(mockUser);
const { result } = renderHook(() => useCurrentUser());
expect(result.current).toEqual(mockUser);
});
it('should handle multiple concurrent renders correctly', () => {
const mockUser = {
_id: 'user123',
email: 'test@example.com',
name: 'Test User',
tier: 'pro' as const,
};
const mockUseQuery = vi.spyOn(convexReact, 'useQuery').mockReturnValue(mockUser);
const { result: result1 } = renderHook(() => useCurrentUser());
const { result: result2 } = renderHook(() => useCurrentUser());
expect(result1.current).toEqual(mockUser);
expect(result2.current).toEqual(mockUser);
expect(mockUseQuery).toHaveBeenCalledTimes(2);
});
});
+8
View File
@@ -0,0 +1,8 @@
// Re-export all hooks for easy importing
export { useSpecs, useSpec } from "./useSpecs";
export { useTeams, useTeam, useTeamMembers } from "./useTeams";
export { usePersonas, usePersona } from "./usePersonas";
export { usePRQueue, usePR } from "./usePRQueue";
export { useAgentSession, useSpecSessions } from "./useAgentSession";
export { useCloudMode } from "./useCloudMode";
export { useCurrentUser } from "./useCurrentUser";
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { useQuery, useMutation } from "convex/react";
/**
* Hook for managing agent sessions via Convex.
* Provides real-time terminal streaming via Convex subscriptions.
* Used by @auto-claude/ui Terminal component.
*/
export function useAgentSession(sessionId: string) {
// TODO: Uncomment when Convex API is generated
// This automatically subscribes to real-time updates!
// const session = useQuery(api.agentSessions.getSession, { sessionId });
return {
session: null,
output: "",
lastChunk: "",
status: "completed" as const,
isLoading: false,
};
}
export function useSpecSessions(specId: string) {
// TODO: Uncomment when Convex API is generated
// const sessions = useQuery(api.agentSessions.getSpecSessions, { specId });
// const startSession = useMutation(api.agentSessions.startSession);
const sessions: never[] = [];
return {
sessions,
isLoading: false,
startSession: async () => {
// TODO: return startSession({ specId });
},
};
}
+19
View File
@@ -0,0 +1,19 @@
"use client";
import { CLOUD_MODE, API_URL } from "@/lib/cloud-mode";
/**
* Hook to detect whether the app is running in cloud mode (Convex-powered)
* or self-hosted mode (local Python backend).
*
* Usage:
* const { isCloud, apiUrl } = useCloudMode();
* if (isCloud) { // use Convex hooks }
* else { // use REST fetches against apiUrl }
*/
export function useCloudMode() {
return {
isCloud: CLOUD_MODE,
apiUrl: API_URL,
} as const;
}
+9
View File
@@ -0,0 +1,9 @@
"use client";
import { useQuery } from "convex/react";
import { api } from "../../convex/_generated/api";
export function useCurrentUser() {
const user = useQuery(api.users.me);
return user;
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { useQuery, useMutation } from "convex/react";
type PRStatus = "open" | "reviewing" | "approved" | "merged";
/**
* Hook for managing PR queue via Convex.
* Team+ feature - used by @auto-claude/ui components.
*/
export function usePRQueue(teamId: string, status?: PRStatus) {
// TODO: Uncomment when Convex API is generated
// const prs = useQuery(api.prQueue.getTeamPRs, { teamId, status });
// const updateStatus = useMutation(api.prQueue.updatePRStatus);
// const assignPR = useMutation(api.prQueue.assignPR);
const prs: never[] = [];
return {
prs,
isLoading: false,
updateStatus: async (prId: string, newStatus: PRStatus) => {
// TODO: return updateStatus({ prId, status: newStatus });
},
assignPR: async (prId: string, assigneeId: string | null) => {
// TODO: return assignPR({ prId, assigneeId: assigneeId ?? undefined });
},
};
}
export function usePR(prId: string) {
// TODO: Uncomment when Convex API is generated
// const pr = useQuery(api.prQueue.getPR, { prId });
return {
pr: null,
isLoading: false,
};
}
+44
View File
@@ -0,0 +1,44 @@
"use client";
import { useQuery, useMutation } from "convex/react";
/**
* Hook for managing personas via Convex.
* Pro+ feature - used by @auto-claude/ui components.
*/
export function usePersonas() {
// TODO: Uncomment when Convex API is generated
// const personas = useQuery(api.personas.getUserPersonas);
// const createPersona = useMutation(api.personas.createPersona);
// const updatePersona = useMutation(api.personas.updatePersona);
// const deletePersona = useMutation(api.personas.deletePersona);
const personas: never[] = [];
return {
personas,
isLoading: false,
createPersona: async (name: string, description: string, traits: string[]) => {
// TODO: return createPersona({ name, description, traits });
},
updatePersona: async (
id: string,
updates: { name?: string; description?: string; traits?: string[] }
) => {
// TODO: return updatePersona({ personaId: id, ...updates });
},
deletePersona: async (id: string) => {
// TODO: return deletePersona({ personaId: id });
},
};
}
export function usePersona(personaId: string) {
// TODO: Uncomment when Convex API is generated
// const persona = useQuery(api.personas.getPersona, { personaId });
return {
persona: null,
isLoading: false,
};
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import { useQuery, useMutation } from "convex/react";
/**
* Hook for managing specs via Convex.
* Used by @auto-claude/ui components.
*/
export function useSpecs() {
// TODO: Uncomment when Convex API is generated
// const specs = useQuery(api.specs.getUserSpecs);
// const createSpec = useMutation(api.specs.createSpec);
// const updateSpec = useMutation(api.specs.updateSpec);
// const deleteSpec = useMutation(api.specs.deleteSpec);
const specs: never[] = [];
return {
specs,
isLoading: false,
createSpec: async (name: string, content: string) => {
// TODO: return createSpec({ name, content });
},
updateSpec: async (id: string, updates: { name?: string; content?: string }) => {
// TODO: return updateSpec({ specId: id, ...updates });
},
deleteSpec: async (id: string) => {
// TODO: return deleteSpec({ specId: id });
},
};
}
export function useSpec(specId: string) {
// TODO: Uncomment when Convex API is generated
// const spec = useQuery(api.specs.getSpec, { specId });
return {
spec: null,
isLoading: false,
};
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
import { useQuery, useMutation } from "convex/react";
/**
* Hook for managing teams via Convex.
* Used by @auto-claude/ui components.
*/
export function useTeams() {
// TODO: Uncomment when Convex API is generated
// const teams = useQuery(api.teams.getUserTeams);
// const createTeam = useMutation(api.teams.createTeam);
const teams: never[] = [];
return {
teams,
isLoading: false,
createTeam: async (name: string) => {
// TODO: return createTeam({ name });
},
};
}
export function useTeam(teamId: string) {
// TODO: Uncomment when Convex API is generated
// const team = useQuery(api.teams.getTeam, { teamId });
// const members = useQuery(api.teams.getTeamMembers, { teamId });
return {
team: null,
members: [],
isLoading: false,
};
}
export function useTeamMembers(teamId: string) {
// TODO: Uncomment when Convex API is generated
// const members = useQuery(api.teams.getTeamMembers, { teamId });
// const inviteMember = useMutation(api.teams.inviteMember);
// const removeMember = useMutation(api.teams.removeMember);
return {
members: [],
isLoading: false,
inviteMember: async (email: string, role: "admin" | "member") => {
// TODO: return inviteMember({ teamId, email, role });
},
removeMember: async (userId: string) => {
// TODO: return removeMember({ teamId, userId });
},
};
}
+10
View File
@@ -0,0 +1,10 @@
import { createAuthClient } from "better-auth/react";
import { convexClient, crossDomainClient } from "@convex-dev/better-auth/client/plugins";
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
plugins: [
convexClient(),
...(typeof window !== "undefined" ? [crossDomainClient()] : []),
],
});
+69
View File
@@ -0,0 +1,69 @@
import { convexBetterAuthNextJs } from "@convex-dev/better-auth/nextjs";
/**
* Server-side auth helpers powered by Convex + Better Auth.
*
* In cloud mode (NEXT_PUBLIC_CONVEX_URL & CONVEX_SITE_URL are set),
* this provides the full auth integration. In self-hosted mode,
* the helpers are stubs that return safe defaults so the build
* doesn't crash.
*/
const isCloudMode =
!!process.env.NEXT_PUBLIC_CONVEX_URL && !!process.env.CONVEX_SITE_URL;
function getAuthHelpers() {
if (!isCloudMode) {
return null;
}
return convexBetterAuthNextJs({
convexUrl: process.env.NEXT_PUBLIC_CONVEX_URL!,
convexSiteUrl: process.env.CONVEX_SITE_URL!,
});
}
let _helpers: ReturnType<typeof getAuthHelpers>;
function helpers() {
if (_helpers === undefined) {
_helpers = getAuthHelpers();
}
return _helpers;
}
// Lazy accessors: safe in both cloud and self-hosted mode.
export const handler = {
GET: async (req: Request) => {
const h = helpers();
if (!h) return new Response("Auth not configured", { status: 404 });
return h.handler.GET(req);
},
POST: async (req: Request) => {
const h = helpers();
if (!h) return new Response("Auth not configured", { status: 404 });
return h.handler.POST(req);
},
};
export async function getToken(): Promise<string | undefined> {
return helpers()?.getToken();
}
export async function isAuthenticated(): Promise<boolean> {
return helpers()?.isAuthenticated() ?? false;
}
export async function preloadAuthQuery(...args: any[]) {
return helpers()?.preloadAuthQuery(...(args as [any]));
}
export async function fetchAuthQuery(...args: any[]) {
return helpers()?.fetchAuthQuery(...(args as [any]));
}
export async function fetchAuthMutation(...args: any[]) {
return helpers()?.fetchAuthMutation(...(args as [any]));
}
export async function fetchAuthAction(...args: any[]) {
return helpers()?.fetchAuthAction(...(args as [any]));
}
+88
View File
@@ -0,0 +1,88 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
describe('cloud-mode', () => {
beforeEach(() => {
// Reset environment variables before each test
delete process.env.NEXT_PUBLIC_CONVEX_URL;
delete process.env.NEXT_PUBLIC_API_URL;
// Clear module cache to get fresh imports
vi.resetModules();
});
describe('CLOUD_MODE', () => {
it('should be false when NEXT_PUBLIC_CONVEX_URL is not set', async () => {
delete process.env.NEXT_PUBLIC_CONVEX_URL;
const { CLOUD_MODE } = await import('./cloud-mode');
expect(CLOUD_MODE).toBe(false);
});
it('should be true when NEXT_PUBLIC_CONVEX_URL is set', async () => {
process.env.NEXT_PUBLIC_CONVEX_URL = 'https://example.convex.cloud';
const { CLOUD_MODE } = await import('./cloud-mode');
expect(CLOUD_MODE).toBe(true);
});
it('should be false when NEXT_PUBLIC_CONVEX_URL is empty string', async () => {
process.env.NEXT_PUBLIC_CONVEX_URL = '';
const { CLOUD_MODE } = await import('./cloud-mode');
expect(CLOUD_MODE).toBe(false);
});
});
describe('CONVEX_URL', () => {
it('should return empty string when NEXT_PUBLIC_CONVEX_URL is not set', async () => {
delete process.env.NEXT_PUBLIC_CONVEX_URL;
const { CONVEX_URL } = await import('./cloud-mode');
expect(CONVEX_URL).toBe('');
});
it('should return the URL when NEXT_PUBLIC_CONVEX_URL is set', async () => {
const testUrl = 'https://example.convex.cloud';
process.env.NEXT_PUBLIC_CONVEX_URL = testUrl;
const { CONVEX_URL } = await import('./cloud-mode');
expect(CONVEX_URL).toBe(testUrl);
});
});
describe('API_URL', () => {
it('should default to localhost:8000 when NEXT_PUBLIC_API_URL is not set', async () => {
delete process.env.NEXT_PUBLIC_API_URL;
const { API_URL } = await import('./cloud-mode');
expect(API_URL).toBe('http://localhost:8000');
});
it('should return custom URL when NEXT_PUBLIC_API_URL is set', async () => {
const testUrl = 'https://api.example.com';
process.env.NEXT_PUBLIC_API_URL = testUrl;
const { API_URL } = await import('./cloud-mode');
expect(API_URL).toBe(testUrl);
});
it('should handle production API URLs correctly', async () => {
const prodUrl = 'https://prod-api.autoclaude.com';
process.env.NEXT_PUBLIC_API_URL = prodUrl;
const { API_URL } = await import('./cloud-mode');
expect(API_URL).toBe(prodUrl);
});
});
describe('environment combinations', () => {
it('should handle cloud mode with custom API URL', async () => {
process.env.NEXT_PUBLIC_CONVEX_URL = 'https://example.convex.cloud';
process.env.NEXT_PUBLIC_API_URL = 'https://api.example.com';
const { CLOUD_MODE, CONVEX_URL, API_URL } = await import('./cloud-mode');
expect(CLOUD_MODE).toBe(true);
expect(CONVEX_URL).toBe('https://example.convex.cloud');
expect(API_URL).toBe('https://api.example.com');
});
it('should handle self-hosted mode with default API URL', async () => {
delete process.env.NEXT_PUBLIC_CONVEX_URL;
delete process.env.NEXT_PUBLIC_API_URL;
const { CLOUD_MODE, CONVEX_URL, API_URL } = await import('./cloud-mode');
expect(CLOUD_MODE).toBe(false);
expect(CONVEX_URL).toBe('');
expect(API_URL).toBe('http://localhost:8000');
});
});
});

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