mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
Merge origin/main into feature/shared-packages
Resolved conflicts: - list.ts: Keep @automaker/git-utils import, add worktree-metadata import - feature-loader.ts: Use Feature type from @automaker/types - automaker-paths.test.ts: Import from @automaker/platform - kanban-card.tsx: Accept deletion (split into components/) - subprocess.test.ts: Keep libs/platform location Added missing exports to @automaker/platform: - getGlobalSettingsPath, getCredentialsPath, getProjectSettingsPath, ensureDataDir Added title and titleGenerating fields to @automaker/types Feature interface. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
57
.github/workflows/claude-code-review.yml
vendored
Normal file
57
.github/workflows/claude-code-review.yml
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
prompt: |
|
||||
REPO: ${{ github.repository }}
|
||||
PR NUMBER: ${{ github.event.pull_request.number }}
|
||||
|
||||
Please review this pull request and provide feedback on:
|
||||
- Code quality and best practices
|
||||
- Potential bugs or issues
|
||||
- Performance considerations
|
||||
- Security concerns
|
||||
- Test coverage
|
||||
|
||||
Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback.
|
||||
|
||||
Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.
|
||||
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'
|
||||
|
||||
50
.github/workflows/claude.yml
vendored
Normal file
50
.github/workflows/claude.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -78,3 +78,5 @@ blob-report/
|
||||
|
||||
# Misc
|
||||
*.pem
|
||||
|
||||
docker-compose.override.yml
|
||||
@@ -30,6 +30,26 @@ Before running Automaker, we strongly recommend reviewing the source code yourse
|
||||
- **Virtual Machine**: Use a VM (such as VirtualBox, VMware, or Parallels) to create an isolated environment
|
||||
- **Cloud Development Environment**: Use a cloud-based development environment that provides isolation
|
||||
|
||||
#### Running in Isolated Docker Container
|
||||
|
||||
For maximum security, run Automaker in an isolated Docker container that **cannot access your laptop's files**:
|
||||
|
||||
```bash
|
||||
# 1. Set your API key (bash/Linux/Mac - creates UTF-8 file)
|
||||
echo "ANTHROPIC_API_KEY=your-api-key-here" > .env
|
||||
|
||||
# On Windows PowerShell, use instead:
|
||||
Set-Content -Path .env -Value "ANTHROPIC_API_KEY=your-api-key-here" -Encoding UTF8
|
||||
|
||||
# 2. Build and run isolated container
|
||||
docker-compose up -d
|
||||
|
||||
# 3. Access the UI at http://localhost:3007
|
||||
# API at http://localhost:3008/api/health
|
||||
```
|
||||
|
||||
The container uses only Docker-managed volumes and has no access to your host filesystem. See [docker-isolation.md](docs/docker-isolation.md) for full documentation.
|
||||
|
||||
### 3. Limit Access
|
||||
|
||||
If you must run locally:
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
>
|
||||
> Automaker itself was built by a group of engineers using AI and agentic coding techniques to build features faster than ever. By leveraging tools like Cursor IDE and Claude Code CLI, the team orchestrated AI agents to implement complex functionality in days instead of weeks.
|
||||
>
|
||||
> **Learn how:** Master these same techniques and workflows in the [Agentic Jumpstart course](https://agenticjumpstart.com/?utm=automaker).
|
||||
> **Learn how:** Master these same techniques and workflows in the [Agentic Jumpstart course](https://agenticjumpstart.com/?utm=automaker-gh).
|
||||
|
||||
# Automaker
|
||||
|
||||
@@ -79,7 +79,7 @@ The future of software development is **agentic coding**—where developers beco
|
||||
>
|
||||
> **We do not recommend running Automaker directly on your local computer** due to the risk of AI agents having access to your entire file system. Please sandbox this application using Docker or a virtual machine.
|
||||
>
|
||||
> **[Read the full disclaimer](../DISCLAIMER.md)**
|
||||
> **[Read the full disclaimer](./DISCLAIMER.md)**
|
||||
|
||||
---
|
||||
|
||||
|
||||
6
apps/app/next-env.d.ts
vendored
Normal file
6
apps/app/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -4,11 +4,15 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Install build dependencies for native modules (node-pty)
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
# Copy package files and scripts needed for postinstall
|
||||
COPY package*.json ./
|
||||
COPY apps/server/package*.json ./apps/server/
|
||||
COPY scripts ./scripts
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --workspace=apps/server
|
||||
|
||||
@@ -37,10 +37,12 @@ import {
|
||||
isTerminalEnabled,
|
||||
isTerminalPasswordRequired,
|
||||
} from "./routes/terminal/index.js";
|
||||
import { createSettingsRoutes } from "./routes/settings/index.js";
|
||||
import { AgentService } from "./services/agent-service.js";
|
||||
import { FeatureLoader } from "./services/feature-loader.js";
|
||||
import { AutoModeService } from "./services/auto-mode-service.js";
|
||||
import { getTerminalService } from "./services/terminal-service.js";
|
||||
import { SettingsService } from "./services/settings-service.js";
|
||||
import { createSpecRegenerationRoutes } from "./routes/app-spec/index.js";
|
||||
|
||||
// Load environment variables
|
||||
@@ -108,6 +110,7 @@ const events: EventEmitter = createEventEmitter();
|
||||
const agentService = new AgentService(DATA_DIR, events);
|
||||
const featureLoader = new FeatureLoader();
|
||||
const autoModeService = new AutoModeService(events);
|
||||
const settingsService = new SettingsService(DATA_DIR);
|
||||
|
||||
// Initialize services
|
||||
(async () => {
|
||||
@@ -137,6 +140,7 @@ app.use("/api/running-agents", createRunningAgentsRoutes(autoModeService));
|
||||
app.use("/api/workspace", createWorkspaceRoutes());
|
||||
app.use("/api/templates", createTemplatesRoutes());
|
||||
app.use("/api/terminal", createTerminalRoutes());
|
||||
app.use("/api/settings", createSettingsRoutes(settingsService));
|
||||
|
||||
// Create HTTP server
|
||||
const server = createServer(app);
|
||||
|
||||
183
apps/server/src/lib/worktree-metadata.ts
Normal file
183
apps/server/src/lib/worktree-metadata.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Worktree metadata storage utilities
|
||||
* Stores worktree-specific data in .automaker/worktrees/:branch/worktree.json
|
||||
*/
|
||||
|
||||
import * as fs from "fs/promises";
|
||||
import * as path from "path";
|
||||
|
||||
/** Maximum length for sanitized branch names in filesystem paths */
|
||||
const MAX_SANITIZED_BRANCH_PATH_LENGTH = 200;
|
||||
|
||||
export interface WorktreePRInfo {
|
||||
number: number;
|
||||
url: string;
|
||||
title: string;
|
||||
state: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface WorktreeMetadata {
|
||||
branch: string;
|
||||
createdAt: string;
|
||||
pr?: WorktreePRInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize branch name for cross-platform filesystem safety
|
||||
*/
|
||||
function sanitizeBranchName(branch: string): string {
|
||||
// Replace characters that are invalid or problematic on various filesystems:
|
||||
// - Forward and backslashes (path separators)
|
||||
// - Windows invalid chars: : * ? " < > |
|
||||
// - Other potentially problematic chars
|
||||
let safeBranch = branch
|
||||
.replace(/[/\\:*?"<>|]/g, "-") // Replace invalid chars with dash
|
||||
.replace(/\s+/g, "_") // Replace spaces with underscores
|
||||
.replace(/\.+$/g, "") // Remove trailing dots (Windows issue)
|
||||
.replace(/-+/g, "-") // Collapse multiple dashes
|
||||
.replace(/^-|-$/g, ""); // Remove leading/trailing dashes
|
||||
|
||||
// Truncate to safe length (leave room for path components)
|
||||
safeBranch = safeBranch.substring(0, MAX_SANITIZED_BRANCH_PATH_LENGTH);
|
||||
|
||||
// Handle Windows reserved names (CON, PRN, AUX, NUL, COM1-9, LPT1-9)
|
||||
const windowsReserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i;
|
||||
if (windowsReserved.test(safeBranch) || safeBranch.length === 0) {
|
||||
safeBranch = `_${safeBranch || "branch"}`;
|
||||
}
|
||||
|
||||
return safeBranch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the worktree metadata directory
|
||||
*/
|
||||
function getWorktreeMetadataDir(projectPath: string, branch: string): string {
|
||||
const safeBranch = sanitizeBranchName(branch);
|
||||
return path.join(projectPath, ".automaker", "worktrees", safeBranch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the worktree metadata file
|
||||
*/
|
||||
function getWorktreeMetadataPath(projectPath: string, branch: string): string {
|
||||
return path.join(getWorktreeMetadataDir(projectPath, branch), "worktree.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read worktree metadata for a branch
|
||||
*/
|
||||
export async function readWorktreeMetadata(
|
||||
projectPath: string,
|
||||
branch: string
|
||||
): Promise<WorktreeMetadata | null> {
|
||||
try {
|
||||
const metadataPath = getWorktreeMetadataPath(projectPath, branch);
|
||||
const content = await fs.readFile(metadataPath, "utf-8");
|
||||
return JSON.parse(content) as WorktreeMetadata;
|
||||
} catch (error) {
|
||||
// File doesn't exist or can't be read
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write worktree metadata for a branch
|
||||
*/
|
||||
export async function writeWorktreeMetadata(
|
||||
projectPath: string,
|
||||
branch: string,
|
||||
metadata: WorktreeMetadata
|
||||
): Promise<void> {
|
||||
const metadataDir = getWorktreeMetadataDir(projectPath, branch);
|
||||
const metadataPath = getWorktreeMetadataPath(projectPath, branch);
|
||||
|
||||
// Ensure directory exists
|
||||
await fs.mkdir(metadataDir, { recursive: true });
|
||||
|
||||
// Write metadata
|
||||
await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update PR info in worktree metadata
|
||||
*/
|
||||
export async function updateWorktreePRInfo(
|
||||
projectPath: string,
|
||||
branch: string,
|
||||
prInfo: WorktreePRInfo
|
||||
): Promise<void> {
|
||||
// Read existing metadata or create new
|
||||
let metadata = await readWorktreeMetadata(projectPath, branch);
|
||||
|
||||
if (!metadata) {
|
||||
metadata = {
|
||||
branch,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Update PR info
|
||||
metadata.pr = prInfo;
|
||||
|
||||
// Write back
|
||||
await writeWorktreeMetadata(projectPath, branch, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PR info for a branch from metadata
|
||||
*/
|
||||
export async function getWorktreePRInfo(
|
||||
projectPath: string,
|
||||
branch: string
|
||||
): Promise<WorktreePRInfo | null> {
|
||||
const metadata = await readWorktreeMetadata(projectPath, branch);
|
||||
return metadata?.pr || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all worktree metadata for a project
|
||||
*/
|
||||
export async function readAllWorktreeMetadata(
|
||||
projectPath: string
|
||||
): Promise<Map<string, WorktreeMetadata>> {
|
||||
const result = new Map<string, WorktreeMetadata>();
|
||||
const worktreesDir = path.join(projectPath, ".automaker", "worktrees");
|
||||
|
||||
try {
|
||||
const dirs = await fs.readdir(worktreesDir, { withFileTypes: true });
|
||||
|
||||
for (const dir of dirs) {
|
||||
if (dir.isDirectory()) {
|
||||
const metadataPath = path.join(worktreesDir, dir.name, "worktree.json");
|
||||
try {
|
||||
const content = await fs.readFile(metadataPath, "utf-8");
|
||||
const metadata = JSON.parse(content) as WorktreeMetadata;
|
||||
result.set(metadata.branch, metadata);
|
||||
} catch {
|
||||
// Skip if file doesn't exist or can't be read
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory doesn't exist
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete worktree metadata for a branch
|
||||
*/
|
||||
export async function deleteWorktreeMetadata(
|
||||
projectPath: string,
|
||||
branch: string
|
||||
): Promise<void> {
|
||||
const metadataDir = getWorktreeMetadataDir(projectPath, branch);
|
||||
try {
|
||||
await fs.rm(metadataDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore errors if directory doesn't exist
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { createCreateHandler } from "./routes/create.js";
|
||||
import { createUpdateHandler } from "./routes/update.js";
|
||||
import { createDeleteHandler } from "./routes/delete.js";
|
||||
import { createAgentOutputHandler } from "./routes/agent-output.js";
|
||||
import { createGenerateTitleHandler } from "./routes/generate-title.js";
|
||||
|
||||
export function createFeaturesRoutes(featureLoader: FeatureLoader): Router {
|
||||
const router = Router();
|
||||
@@ -20,6 +21,7 @@ export function createFeaturesRoutes(featureLoader: FeatureLoader): Router {
|
||||
router.post("/update", createUpdateHandler(featureLoader));
|
||||
router.post("/delete", createDeleteHandler(featureLoader));
|
||||
router.post("/agent-output", createAgentOutputHandler(featureLoader));
|
||||
router.post("/generate-title", createGenerateTitleHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
137
apps/server/src/routes/features/routes/generate-title.ts
Normal file
137
apps/server/src/routes/features/routes/generate-title.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* POST /features/generate-title endpoint - Generate a concise title from description
|
||||
*
|
||||
* Uses Claude Haiku to generate a short, descriptive title from feature description.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { createLogger } from "../../../lib/logger.js";
|
||||
import { CLAUDE_MODEL_MAP } from "../../../lib/model-resolver.js";
|
||||
|
||||
const logger = createLogger("GenerateTitle");
|
||||
|
||||
interface GenerateTitleRequestBody {
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface GenerateTitleSuccessResponse {
|
||||
success: true;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface GenerateTitleErrorResponse {
|
||||
success: false;
|
||||
error: string;
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = `You are a title generator. Your task is to create a concise, descriptive title (5-10 words max) for a software feature based on its description.
|
||||
|
||||
Rules:
|
||||
- Output ONLY the title, nothing else
|
||||
- Keep it short and action-oriented (e.g., "Add dark mode toggle", "Fix login validation")
|
||||
- Start with a verb when possible (Add, Fix, Update, Implement, Create, etc.)
|
||||
- No quotes, periods, or extra formatting
|
||||
- Capture the essence of the feature in a scannable way`;
|
||||
|
||||
async function extractTextFromStream(
|
||||
stream: AsyncIterable<{
|
||||
type: string;
|
||||
subtype?: string;
|
||||
result?: string;
|
||||
message?: {
|
||||
content?: Array<{ type: string; text?: string }>;
|
||||
};
|
||||
}>
|
||||
): Promise<string> {
|
||||
let responseText = "";
|
||||
|
||||
for await (const msg of stream) {
|
||||
if (msg.type === "assistant" && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === "text" && block.text) {
|
||||
responseText += block.text;
|
||||
}
|
||||
}
|
||||
} else if (msg.type === "result" && msg.subtype === "success") {
|
||||
responseText = msg.result || responseText;
|
||||
}
|
||||
}
|
||||
|
||||
return responseText;
|
||||
}
|
||||
|
||||
export function createGenerateTitleHandler(): (
|
||||
req: Request,
|
||||
res: Response
|
||||
) => Promise<void> {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { description } = req.body as GenerateTitleRequestBody;
|
||||
|
||||
if (!description || typeof description !== "string") {
|
||||
const response: GenerateTitleErrorResponse = {
|
||||
success: false,
|
||||
error: "description is required and must be a string",
|
||||
};
|
||||
res.status(400).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedDescription = description.trim();
|
||||
if (trimmedDescription.length === 0) {
|
||||
const response: GenerateTitleErrorResponse = {
|
||||
success: false,
|
||||
error: "description cannot be empty",
|
||||
};
|
||||
res.status(400).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Generating title for description: ${trimmedDescription.substring(0, 50)}...`);
|
||||
|
||||
const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`;
|
||||
|
||||
const stream = query({
|
||||
prompt: userPrompt,
|
||||
options: {
|
||||
model: CLAUDE_MODEL_MAP.haiku,
|
||||
systemPrompt: SYSTEM_PROMPT,
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
permissionMode: "acceptEdits",
|
||||
},
|
||||
});
|
||||
|
||||
const title = await extractTextFromStream(stream);
|
||||
|
||||
if (!title || title.trim().length === 0) {
|
||||
logger.warn("Received empty response from Claude");
|
||||
const response: GenerateTitleErrorResponse = {
|
||||
success: false,
|
||||
error: "Failed to generate title - empty response",
|
||||
};
|
||||
res.status(500).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Generated title: ${title.trim()}`);
|
||||
|
||||
const response: GenerateTitleSuccessResponse = {
|
||||
success: true,
|
||||
title: title.trim(),
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error occurred";
|
||||
logger.error("Title generation failed:", errorMessage);
|
||||
|
||||
const response: GenerateTitleErrorResponse = {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
};
|
||||
res.status(500).json(response);
|
||||
}
|
||||
};
|
||||
}
|
||||
29
apps/server/src/routes/settings/common.ts
Normal file
29
apps/server/src/routes/settings/common.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Common utilities for settings routes
|
||||
*
|
||||
* Provides logger and error handling utilities shared across all settings endpoints.
|
||||
* Re-exports error handling helpers from the parent routes module.
|
||||
*/
|
||||
|
||||
import { createLogger } from "../../lib/logger.js";
|
||||
import {
|
||||
getErrorMessage as getErrorMessageShared,
|
||||
createLogError,
|
||||
} from "../common.js";
|
||||
|
||||
/** Logger instance for settings-related operations */
|
||||
export const logger = createLogger("Settings");
|
||||
|
||||
/**
|
||||
* Extract user-friendly error message from error objects
|
||||
*
|
||||
* Re-exported from parent routes common module for consistency.
|
||||
*/
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
|
||||
/**
|
||||
* Log error with automatic logger binding
|
||||
*
|
||||
* Convenience function for logging errors with the Settings logger.
|
||||
*/
|
||||
export const logError = createLogError(logger);
|
||||
67
apps/server/src/routes/settings/index.ts
Normal file
67
apps/server/src/routes/settings/index.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Settings routes - HTTP API for persistent file-based settings
|
||||
*
|
||||
* Provides endpoints for:
|
||||
* - Status checking (migration readiness)
|
||||
* - Global settings CRUD
|
||||
* - Credentials management
|
||||
* - Project-specific settings
|
||||
* - localStorage to file migration
|
||||
*
|
||||
* All endpoints use handler factories that receive the SettingsService instance.
|
||||
* Mounted at /api/settings in the main server.
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import type { SettingsService } from "../../services/settings-service.js";
|
||||
import { createGetGlobalHandler } from "./routes/get-global.js";
|
||||
import { createUpdateGlobalHandler } from "./routes/update-global.js";
|
||||
import { createGetCredentialsHandler } from "./routes/get-credentials.js";
|
||||
import { createUpdateCredentialsHandler } from "./routes/update-credentials.js";
|
||||
import { createGetProjectHandler } from "./routes/get-project.js";
|
||||
import { createUpdateProjectHandler } from "./routes/update-project.js";
|
||||
import { createMigrateHandler } from "./routes/migrate.js";
|
||||
import { createStatusHandler } from "./routes/status.js";
|
||||
|
||||
/**
|
||||
* Create settings router with all endpoints
|
||||
*
|
||||
* Registers handlers for all settings-related HTTP endpoints.
|
||||
* Each handler is created with the provided SettingsService instance.
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /status - Check migration status and data availability
|
||||
* - GET /global - Get global settings
|
||||
* - PUT /global - Update global settings
|
||||
* - GET /credentials - Get masked credentials (safe for UI)
|
||||
* - PUT /credentials - Update API keys
|
||||
* - POST /project - Get project settings (requires projectPath in body)
|
||||
* - PUT /project - Update project settings
|
||||
* - POST /migrate - Migrate settings from localStorage
|
||||
*
|
||||
* @param settingsService - Instance of SettingsService for file I/O
|
||||
* @returns Express Router configured with all settings endpoints
|
||||
*/
|
||||
export function createSettingsRoutes(settingsService: SettingsService): Router {
|
||||
const router = Router();
|
||||
|
||||
// Status endpoint (check if migration needed)
|
||||
router.get("/status", createStatusHandler(settingsService));
|
||||
|
||||
// Global settings
|
||||
router.get("/global", createGetGlobalHandler(settingsService));
|
||||
router.put("/global", createUpdateGlobalHandler(settingsService));
|
||||
|
||||
// Credentials (separate for security)
|
||||
router.get("/credentials", createGetCredentialsHandler(settingsService));
|
||||
router.put("/credentials", createUpdateCredentialsHandler(settingsService));
|
||||
|
||||
// Project settings
|
||||
router.post("/project", createGetProjectHandler(settingsService));
|
||||
router.put("/project", createUpdateProjectHandler(settingsService));
|
||||
|
||||
// Migration from localStorage
|
||||
router.post("/migrate", createMigrateHandler(settingsService));
|
||||
|
||||
return router;
|
||||
}
|
||||
35
apps/server/src/routes/settings/routes/get-credentials.ts
Normal file
35
apps/server/src/routes/settings/routes/get-credentials.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* GET /api/settings/credentials - Get API key status (masked for security)
|
||||
*
|
||||
* Returns masked credentials showing which providers have keys configured.
|
||||
* Each provider shows: `{ configured: boolean, masked: string }`
|
||||
* Masked shows first 4 and last 4 characters for verification.
|
||||
*
|
||||
* Response: `{ "success": true, "credentials": { anthropic, google, openai } }`
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { SettingsService } from "../../../services/settings-service.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
/**
|
||||
* Create handler factory for GET /api/settings/credentials
|
||||
*
|
||||
* @param settingsService - Instance of SettingsService for file I/O
|
||||
* @returns Express request handler
|
||||
*/
|
||||
export function createGetCredentialsHandler(settingsService: SettingsService) {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const credentials = await settingsService.getMaskedCredentials();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
credentials,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, "Get credentials failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
34
apps/server/src/routes/settings/routes/get-global.ts
Normal file
34
apps/server/src/routes/settings/routes/get-global.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* GET /api/settings/global - Retrieve global user settings
|
||||
*
|
||||
* Returns the complete GlobalSettings object with all user preferences,
|
||||
* keyboard shortcuts, AI profiles, and project history.
|
||||
*
|
||||
* Response: `{ "success": true, "settings": GlobalSettings }`
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { SettingsService } from "../../../services/settings-service.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
/**
|
||||
* Create handler factory for GET /api/settings/global
|
||||
*
|
||||
* @param settingsService - Instance of SettingsService for file I/O
|
||||
* @returns Express request handler
|
||||
*/
|
||||
export function createGetGlobalHandler(settingsService: SettingsService) {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
settings,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, "Get global settings failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
45
apps/server/src/routes/settings/routes/get-project.ts
Normal file
45
apps/server/src/routes/settings/routes/get-project.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* POST /api/settings/project - Get project-specific settings
|
||||
*
|
||||
* Retrieves settings overrides for a specific project. Uses POST because
|
||||
* projectPath may contain special characters that don't work well in URLs.
|
||||
*
|
||||
* Request body: `{ projectPath: string }`
|
||||
* Response: `{ "success": true, "settings": ProjectSettings }`
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { SettingsService } from "../../../services/settings-service.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
/**
|
||||
* Create handler factory for POST /api/settings/project
|
||||
*
|
||||
* @param settingsService - Instance of SettingsService for file I/O
|
||||
* @returns Express request handler
|
||||
*/
|
||||
export function createGetProjectHandler(settingsService: SettingsService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath?: string };
|
||||
|
||||
if (!projectPath || typeof projectPath !== "string") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "projectPath is required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = await settingsService.getProjectSettings(projectPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
settings,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, "Get project settings failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
88
apps/server/src/routes/settings/routes/migrate.ts
Normal file
88
apps/server/src/routes/settings/routes/migrate.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* POST /api/settings/migrate - Migrate settings from localStorage to file storage
|
||||
*
|
||||
* Called during onboarding when UI detects localStorage data but no settings files.
|
||||
* Extracts settings from various localStorage keys and writes to new file structure.
|
||||
* Collects errors but continues on partial failures (graceful degradation).
|
||||
*
|
||||
* Request body:
|
||||
* ```json
|
||||
* {
|
||||
* "data": {
|
||||
* "automaker-storage"?: string,
|
||||
* "automaker-setup"?: string,
|
||||
* "worktree-panel-collapsed"?: string,
|
||||
* "file-browser-recent-folders"?: string,
|
||||
* "automaker:lastProjectDir"?: string
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Response:
|
||||
* ```json
|
||||
* {
|
||||
* "success": boolean,
|
||||
* "migratedGlobalSettings": boolean,
|
||||
* "migratedCredentials": boolean,
|
||||
* "migratedProjectCount": number,
|
||||
* "errors": string[]
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { SettingsService } from "../../../services/settings-service.js";
|
||||
import { getErrorMessage, logError, logger } from "../common.js";
|
||||
|
||||
/**
|
||||
* Create handler factory for POST /api/settings/migrate
|
||||
*
|
||||
* @param settingsService - Instance of SettingsService for file I/O
|
||||
* @returns Express request handler
|
||||
*/
|
||||
export function createMigrateHandler(settingsService: SettingsService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { data } = req.body as {
|
||||
data?: {
|
||||
"automaker-storage"?: string;
|
||||
"automaker-setup"?: string;
|
||||
"worktree-panel-collapsed"?: string;
|
||||
"file-browser-recent-folders"?: string;
|
||||
"automaker:lastProjectDir"?: string;
|
||||
};
|
||||
};
|
||||
|
||||
if (!data || typeof data !== "object") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "data object is required containing localStorage data",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("Starting settings migration from localStorage");
|
||||
|
||||
const result = await settingsService.migrateFromLocalStorage(data);
|
||||
|
||||
if (result.success) {
|
||||
logger.info(
|
||||
`Migration successful: ${result.migratedProjectCount} projects migrated`
|
||||
);
|
||||
} else {
|
||||
logger.warn(`Migration completed with errors: ${result.errors.join(", ")}`);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: result.success,
|
||||
migratedGlobalSettings: result.migratedGlobalSettings,
|
||||
migratedCredentials: result.migratedCredentials,
|
||||
migratedProjectCount: result.migratedProjectCount,
|
||||
errors: result.errors,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, "Migration failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
47
apps/server/src/routes/settings/routes/status.ts
Normal file
47
apps/server/src/routes/settings/routes/status.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* GET /api/settings/status - Get settings migration and availability status
|
||||
*
|
||||
* Checks which settings files exist to determine if migration from localStorage
|
||||
* is needed. Used by UI during onboarding to decide whether to show migration flow.
|
||||
*
|
||||
* Response:
|
||||
* ```json
|
||||
* {
|
||||
* "success": true,
|
||||
* "hasGlobalSettings": boolean,
|
||||
* "hasCredentials": boolean,
|
||||
* "dataDir": string,
|
||||
* "needsMigration": boolean
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { SettingsService } from "../../../services/settings-service.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
/**
|
||||
* Create handler factory for GET /api/settings/status
|
||||
*
|
||||
* @param settingsService - Instance of SettingsService for file I/O
|
||||
* @returns Express request handler
|
||||
*/
|
||||
export function createStatusHandler(settingsService: SettingsService) {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const hasGlobalSettings = await settingsService.hasGlobalSettings();
|
||||
const hasCredentials = await settingsService.hasCredentials();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
hasGlobalSettings,
|
||||
hasCredentials,
|
||||
dataDir: settingsService.getDataDir(),
|
||||
needsMigration: !hasGlobalSettings,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, "Get settings status failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
51
apps/server/src/routes/settings/routes/update-credentials.ts
Normal file
51
apps/server/src/routes/settings/routes/update-credentials.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* PUT /api/settings/credentials - Update API credentials
|
||||
*
|
||||
* Updates API keys for Anthropic, Google, or OpenAI. Partial updates supported.
|
||||
* Returns masked credentials for verification without exposing full keys.
|
||||
*
|
||||
* Request body: `Partial<Credentials>` (usually just apiKeys)
|
||||
* Response: `{ "success": true, "credentials": { anthropic, google, openai } }`
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { SettingsService } from "../../../services/settings-service.js";
|
||||
import type { Credentials } from "../../../types/settings.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
/**
|
||||
* Create handler factory for PUT /api/settings/credentials
|
||||
*
|
||||
* @param settingsService - Instance of SettingsService for file I/O
|
||||
* @returns Express request handler
|
||||
*/
|
||||
export function createUpdateCredentialsHandler(
|
||||
settingsService: SettingsService
|
||||
) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const updates = req.body as Partial<Credentials>;
|
||||
|
||||
if (!updates || typeof updates !== "object") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "Invalid request body - expected credentials object",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await settingsService.updateCredentials(updates);
|
||||
|
||||
// Return masked credentials for confirmation
|
||||
const masked = await settingsService.getMaskedCredentials();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
credentials: masked,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, "Update credentials failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
46
apps/server/src/routes/settings/routes/update-global.ts
Normal file
46
apps/server/src/routes/settings/routes/update-global.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* PUT /api/settings/global - Update global user settings
|
||||
*
|
||||
* Accepts partial GlobalSettings update. Fields provided are merged into
|
||||
* existing settings (not replaced). Returns updated settings.
|
||||
*
|
||||
* Request body: `Partial<GlobalSettings>`
|
||||
* Response: `{ "success": true, "settings": GlobalSettings }`
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { SettingsService } from "../../../services/settings-service.js";
|
||||
import type { GlobalSettings } from "../../../types/settings.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
/**
|
||||
* Create handler factory for PUT /api/settings/global
|
||||
*
|
||||
* @param settingsService - Instance of SettingsService for file I/O
|
||||
* @returns Express request handler
|
||||
*/
|
||||
export function createUpdateGlobalHandler(settingsService: SettingsService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const updates = req.body as Partial<GlobalSettings>;
|
||||
|
||||
if (!updates || typeof updates !== "object") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "Invalid request body - expected settings object",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = await settingsService.updateGlobalSettings(updates);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
settings,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, "Update global settings failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
60
apps/server/src/routes/settings/routes/update-project.ts
Normal file
60
apps/server/src/routes/settings/routes/update-project.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* PUT /api/settings/project - Update project-specific settings
|
||||
*
|
||||
* Updates settings for a specific project. Partial updates supported.
|
||||
* Project settings override global settings when present.
|
||||
*
|
||||
* Request body: `{ projectPath: string, updates: Partial<ProjectSettings> }`
|
||||
* Response: `{ "success": true, "settings": ProjectSettings }`
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { SettingsService } from "../../../services/settings-service.js";
|
||||
import type { ProjectSettings } from "../../../types/settings.js";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
/**
|
||||
* Create handler factory for PUT /api/settings/project
|
||||
*
|
||||
* @param settingsService - Instance of SettingsService for file I/O
|
||||
* @returns Express request handler
|
||||
*/
|
||||
export function createUpdateProjectHandler(settingsService: SettingsService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, updates } = req.body as {
|
||||
projectPath?: string;
|
||||
updates?: Partial<ProjectSettings>;
|
||||
};
|
||||
|
||||
if (!projectPath || typeof projectPath !== "string") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "projectPath is required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!updates || typeof updates !== "object") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "updates object is required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = await settingsService.updateProjectSettings(
|
||||
projectPath,
|
||||
updates
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
settings,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, "Update project settings failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -14,9 +14,87 @@ import {
|
||||
import { FeatureLoader } from "../../services/feature-loader.js";
|
||||
|
||||
const logger = createLogger("Worktree");
|
||||
const execAsync = promisify(exec);
|
||||
export const execAsync = promisify(exec);
|
||||
const featureLoader = new FeatureLoader();
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
/** Maximum allowed length for git branch names */
|
||||
export const MAX_BRANCH_NAME_LENGTH = 250;
|
||||
|
||||
// ============================================================================
|
||||
// Extended PATH configuration for Electron apps
|
||||
// ============================================================================
|
||||
|
||||
const pathSeparator = process.platform === "win32" ? ";" : ":";
|
||||
const additionalPaths: string[] = [];
|
||||
|
||||
if (process.platform === "win32") {
|
||||
// Windows paths
|
||||
if (process.env.LOCALAPPDATA) {
|
||||
additionalPaths.push(`${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`);
|
||||
}
|
||||
if (process.env.PROGRAMFILES) {
|
||||
additionalPaths.push(`${process.env.PROGRAMFILES}\\Git\\cmd`);
|
||||
}
|
||||
if (process.env["ProgramFiles(x86)"]) {
|
||||
additionalPaths.push(`${process.env["ProgramFiles(x86)"]}\\Git\\cmd`);
|
||||
}
|
||||
} else {
|
||||
// Unix/Mac paths
|
||||
additionalPaths.push(
|
||||
"/opt/homebrew/bin", // Homebrew on Apple Silicon
|
||||
"/usr/local/bin", // Homebrew on Intel Mac, common Linux location
|
||||
"/home/linuxbrew/.linuxbrew/bin", // Linuxbrew
|
||||
`${process.env.HOME}/.local/bin`, // pipx, other user installs
|
||||
);
|
||||
}
|
||||
|
||||
const extendedPath = [
|
||||
process.env.PATH,
|
||||
...additionalPaths.filter(Boolean),
|
||||
].filter(Boolean).join(pathSeparator);
|
||||
|
||||
/**
|
||||
* Environment variables with extended PATH for executing shell commands.
|
||||
* Electron apps don't inherit the user's shell PATH, so we need to add
|
||||
* common tool installation locations.
|
||||
*/
|
||||
export const execEnv = {
|
||||
...process.env,
|
||||
PATH: extendedPath,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Validation utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Validate branch name to prevent command injection.
|
||||
* Git branch names cannot contain: space, ~, ^, :, ?, *, [, \, or control chars.
|
||||
* We also reject shell metacharacters for safety.
|
||||
*/
|
||||
export function isValidBranchName(name: string): boolean {
|
||||
return /^[a-zA-Z0-9._\-/]+$/.test(name) && name.length < MAX_BRANCH_NAME_LENGTH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if gh CLI is available on the system
|
||||
*/
|
||||
export async function isGhCliAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const checkCommand = process.platform === "win32"
|
||||
? "where gh"
|
||||
: "command -v gh";
|
||||
await execAsync(checkCommand, { env: execEnv });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const AUTOMAKER_INITIAL_COMMIT_MESSAGE =
|
||||
"chore: automaker initial commit";
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { createMergeHandler } from "./routes/merge.js";
|
||||
import { createCreateHandler } from "./routes/create.js";
|
||||
import { createDeleteHandler } from "./routes/delete.js";
|
||||
import { createCreatePRHandler } from "./routes/create-pr.js";
|
||||
import { createPRInfoHandler } from "./routes/pr-info.js";
|
||||
import { createCommitHandler } from "./routes/commit.js";
|
||||
import { createPushHandler } from "./routes/push.js";
|
||||
import { createPullHandler } from "./routes/pull.js";
|
||||
@@ -40,6 +41,7 @@ export function createWorktreeRoutes(): Router {
|
||||
router.post("/create", createCreateHandler());
|
||||
router.post("/delete", createDeleteHandler());
|
||||
router.post("/create-pr", createCreatePRHandler());
|
||||
router.post("/pr-info", createPRInfoHandler());
|
||||
router.post("/commit", createCommitHandler());
|
||||
router.post("/push", createPushHandler());
|
||||
router.post("/pull", createPullHandler());
|
||||
|
||||
@@ -3,53 +3,22 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { getErrorMessage, logError } from "../common.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Extended PATH to include common tool installation locations
|
||||
// This is needed because Electron apps don't inherit the user's shell PATH
|
||||
const pathSeparator = process.platform === "win32" ? ";" : ":";
|
||||
const additionalPaths: string[] = [];
|
||||
|
||||
if (process.platform === "win32") {
|
||||
// Windows paths
|
||||
if (process.env.LOCALAPPDATA) {
|
||||
additionalPaths.push(`${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`);
|
||||
}
|
||||
if (process.env.PROGRAMFILES) {
|
||||
additionalPaths.push(`${process.env.PROGRAMFILES}\\Git\\cmd`);
|
||||
}
|
||||
if (process.env["ProgramFiles(x86)"]) {
|
||||
additionalPaths.push(`${process.env["ProgramFiles(x86)"]}\\Git\\cmd`);
|
||||
}
|
||||
} else {
|
||||
// Unix/Mac paths
|
||||
additionalPaths.push(
|
||||
"/opt/homebrew/bin", // Homebrew on Apple Silicon
|
||||
"/usr/local/bin", // Homebrew on Intel Mac, common Linux location
|
||||
"/home/linuxbrew/.linuxbrew/bin", // Linuxbrew
|
||||
`${process.env.HOME}/.local/bin`, // pipx, other user installs
|
||||
);
|
||||
}
|
||||
|
||||
const extendedPath = [
|
||||
process.env.PATH,
|
||||
...additionalPaths.filter(Boolean),
|
||||
].filter(Boolean).join(pathSeparator);
|
||||
|
||||
const execEnv = {
|
||||
...process.env,
|
||||
PATH: extendedPath,
|
||||
};
|
||||
import {
|
||||
getErrorMessage,
|
||||
logError,
|
||||
execAsync,
|
||||
execEnv,
|
||||
isValidBranchName,
|
||||
isGhCliAvailable,
|
||||
} from "../common.js";
|
||||
import { updateWorktreePRInfo } from "../../../lib/worktree-metadata.js";
|
||||
|
||||
export function createCreatePRHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath, commitMessage, prTitle, prBody, baseBranch, draft } = req.body as {
|
||||
const { worktreePath, projectPath, commitMessage, prTitle, prBody, baseBranch, draft } = req.body as {
|
||||
worktreePath: string;
|
||||
projectPath?: string;
|
||||
commitMessage?: string;
|
||||
prTitle?: string;
|
||||
prBody?: string;
|
||||
@@ -65,6 +34,10 @@ export function createCreatePRHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use projectPath if provided, otherwise derive from worktreePath
|
||||
// For worktrees, projectPath is needed to store metadata in the main project's .automaker folder
|
||||
const effectiveProjectPath = projectPath || worktreePath;
|
||||
|
||||
// Get current branch name
|
||||
const { stdout: branchOutput } = await execAsync(
|
||||
"git rev-parse --abbrev-ref HEAD",
|
||||
@@ -72,6 +45,15 @@ export function createCreatePRHandler() {
|
||||
);
|
||||
const branchName = branchOutput.trim();
|
||||
|
||||
// Validate branch name for security
|
||||
if (!isValidBranchName(branchName)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "Invalid branch name contains unsafe characters",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for uncommitted changes
|
||||
const { stdout: status } = await execAsync("git status --porcelain", {
|
||||
cwd: worktreePath,
|
||||
@@ -143,18 +125,8 @@ export function createCreatePRHandler() {
|
||||
let browserUrl: string | null = null;
|
||||
let ghCliAvailable = false;
|
||||
|
||||
// Check if gh CLI is available (cross-platform)
|
||||
try {
|
||||
const checkCommand = process.platform === "win32"
|
||||
? "where gh"
|
||||
: "command -v gh";
|
||||
await execAsync(checkCommand, { env: execEnv });
|
||||
ghCliAvailable = true;
|
||||
} catch {
|
||||
ghCliAvailable = false;
|
||||
}
|
||||
|
||||
// Get repository URL for browser fallback
|
||||
// Get repository URL and detect fork workflow FIRST
|
||||
// This is needed for both the existing PR check and PR creation
|
||||
let repoUrl: string | null = null;
|
||||
let upstreamRepo: string | null = null;
|
||||
let originOwner: string | null = null;
|
||||
@@ -180,7 +152,7 @@ export function createCreatePRHandler() {
|
||||
// Try HTTPS format: https://github.com/owner/repo.git
|
||||
match = line.match(/^(\w+)\s+https?:\/\/[^/]+\/([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/);
|
||||
}
|
||||
|
||||
|
||||
if (match) {
|
||||
const [, remoteName, owner, repo] = match;
|
||||
if (remoteName === "upstream") {
|
||||
@@ -206,7 +178,7 @@ export function createCreatePRHandler() {
|
||||
env: execEnv,
|
||||
});
|
||||
const url = originUrl.trim();
|
||||
|
||||
|
||||
// Parse URL to extract owner/repo
|
||||
// Handle both SSH (git@github.com:owner/repo.git) and HTTPS (https://github.com/owner/repo.git)
|
||||
let match = url.match(/[:/]([^/]+)\/([^/\s]+?)(?:\.git)?$/);
|
||||
@@ -220,6 +192,9 @@ export function createCreatePRHandler() {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if gh CLI is available (cross-platform)
|
||||
ghCliAvailable = await isGhCliAvailable();
|
||||
|
||||
// Construct browser URL for PR creation
|
||||
if (repoUrl) {
|
||||
const encodedTitle = encodeURIComponent(title);
|
||||
@@ -234,32 +209,136 @@ export function createCreatePRHandler() {
|
||||
}
|
||||
}
|
||||
|
||||
let prNumber: number | undefined;
|
||||
let prAlreadyExisted = false;
|
||||
|
||||
if (ghCliAvailable) {
|
||||
// First, check if a PR already exists for this branch using gh pr list
|
||||
// This is more reliable than gh pr view as it explicitly searches by branch name
|
||||
// For forks, we need to use owner:branch format for the head parameter
|
||||
const headRef = upstreamRepo && originOwner ? `${originOwner}:${branchName}` : branchName;
|
||||
const repoArg = upstreamRepo ? ` --repo "${upstreamRepo}"` : "";
|
||||
|
||||
console.log(`[CreatePR] Checking for existing PR for branch: ${branchName} (headRef: ${headRef})`);
|
||||
try {
|
||||
// Build gh pr create command
|
||||
let prCmd = `gh pr create --base "${base}"`;
|
||||
|
||||
// If this is a fork (has upstream remote), specify the repo and head
|
||||
if (upstreamRepo && originOwner) {
|
||||
// For forks: --repo specifies where to create PR, --head specifies source
|
||||
prCmd += ` --repo "${upstreamRepo}" --head "${originOwner}:${branchName}"`;
|
||||
} else {
|
||||
// Not a fork, just specify the head branch
|
||||
prCmd += ` --head "${branchName}"`;
|
||||
}
|
||||
|
||||
prCmd += ` --title "${title.replace(/"/g, '\\"')}" --body "${body.replace(/"/g, '\\"')}" ${draftFlag}`;
|
||||
prCmd = prCmd.trim();
|
||||
|
||||
const { stdout: prOutput } = await execAsync(prCmd, {
|
||||
const listCmd = `gh pr list${repoArg} --head "${headRef}" --json number,title,url,state --limit 1`;
|
||||
console.log(`[CreatePR] Running: ${listCmd}`);
|
||||
const { stdout: existingPrOutput } = await execAsync(listCmd, {
|
||||
cwd: worktreePath,
|
||||
env: execEnv,
|
||||
});
|
||||
prUrl = prOutput.trim();
|
||||
} catch (ghError: unknown) {
|
||||
// gh CLI failed
|
||||
const err = ghError as { stderr?: string; message?: string };
|
||||
prError = err.stderr || err.message || "PR creation failed";
|
||||
console.log(`[CreatePR] gh pr list output: ${existingPrOutput}`);
|
||||
|
||||
const existingPrs = JSON.parse(existingPrOutput);
|
||||
|
||||
if (Array.isArray(existingPrs) && existingPrs.length > 0) {
|
||||
const existingPr = existingPrs[0];
|
||||
// PR already exists - use it and store metadata
|
||||
console.log(`[CreatePR] PR already exists for branch ${branchName}: PR #${existingPr.number}`);
|
||||
prUrl = existingPr.url;
|
||||
prNumber = existingPr.number;
|
||||
prAlreadyExisted = true;
|
||||
|
||||
// Store the existing PR info in metadata
|
||||
await updateWorktreePRInfo(effectiveProjectPath, branchName, {
|
||||
number: existingPr.number,
|
||||
url: existingPr.url,
|
||||
title: existingPr.title || title,
|
||||
state: existingPr.state || "open",
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
console.log(`[CreatePR] Stored existing PR info for branch ${branchName}: PR #${existingPr.number}`);
|
||||
} else {
|
||||
console.log(`[CreatePR] No existing PR found for branch ${branchName}`);
|
||||
}
|
||||
} catch (listError) {
|
||||
// gh pr list failed - log but continue to try creating
|
||||
console.log(`[CreatePR] gh pr list failed (this is ok, will try to create):`, listError);
|
||||
}
|
||||
|
||||
// Only create a new PR if one doesn't already exist
|
||||
if (!prUrl) {
|
||||
try {
|
||||
// Build gh pr create command
|
||||
let prCmd = `gh pr create --base "${base}"`;
|
||||
|
||||
// If this is a fork (has upstream remote), specify the repo and head
|
||||
if (upstreamRepo && originOwner) {
|
||||
// For forks: --repo specifies where to create PR, --head specifies source
|
||||
prCmd += ` --repo "${upstreamRepo}" --head "${originOwner}:${branchName}"`;
|
||||
} else {
|
||||
// Not a fork, just specify the head branch
|
||||
prCmd += ` --head "${branchName}"`;
|
||||
}
|
||||
|
||||
prCmd += ` --title "${title.replace(/"/g, '\\"')}" --body "${body.replace(/"/g, '\\"')}" ${draftFlag}`;
|
||||
prCmd = prCmd.trim();
|
||||
|
||||
console.log(`[CreatePR] Creating PR with command: ${prCmd}`);
|
||||
const { stdout: prOutput } = await execAsync(prCmd, {
|
||||
cwd: worktreePath,
|
||||
env: execEnv,
|
||||
});
|
||||
prUrl = prOutput.trim();
|
||||
console.log(`[CreatePR] PR created: ${prUrl}`);
|
||||
|
||||
// Extract PR number and store metadata for newly created PR
|
||||
if (prUrl) {
|
||||
const prMatch = prUrl.match(/\/pull\/(\d+)/);
|
||||
prNumber = prMatch ? parseInt(prMatch[1], 10) : undefined;
|
||||
|
||||
if (prNumber) {
|
||||
try {
|
||||
await updateWorktreePRInfo(effectiveProjectPath, branchName, {
|
||||
number: prNumber,
|
||||
url: prUrl,
|
||||
title,
|
||||
state: draft ? "draft" : "open",
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
console.log(`[CreatePR] Stored PR info for branch ${branchName}: PR #${prNumber}`);
|
||||
} catch (metadataError) {
|
||||
console.error("[CreatePR] Failed to store PR metadata:", metadataError);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ghError: unknown) {
|
||||
// gh CLI failed - check if it's "already exists" error and try to fetch the PR
|
||||
const err = ghError as { stderr?: string; message?: string };
|
||||
const errorMessage = err.stderr || err.message || "PR creation failed";
|
||||
console.log(`[CreatePR] gh pr create failed: ${errorMessage}`);
|
||||
|
||||
// If error indicates PR already exists, try to fetch it
|
||||
if (errorMessage.toLowerCase().includes("already exists")) {
|
||||
console.log(`[CreatePR] PR already exists error - trying to fetch existing PR`);
|
||||
try {
|
||||
const { stdout: viewOutput } = await execAsync(
|
||||
`gh pr view --json number,title,url,state`,
|
||||
{ cwd: worktreePath, env: execEnv }
|
||||
);
|
||||
const existingPr = JSON.parse(viewOutput);
|
||||
if (existingPr.url) {
|
||||
prUrl = existingPr.url;
|
||||
prNumber = existingPr.number;
|
||||
prAlreadyExisted = true;
|
||||
|
||||
await updateWorktreePRInfo(effectiveProjectPath, branchName, {
|
||||
number: existingPr.number,
|
||||
url: existingPr.url,
|
||||
title: existingPr.title || title,
|
||||
state: existingPr.state || "open",
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
console.log(`[CreatePR] Fetched and stored existing PR: #${existingPr.number}`);
|
||||
}
|
||||
} catch (viewError) {
|
||||
console.error("[CreatePR] Failed to fetch existing PR:", viewError);
|
||||
prError = errorMessage;
|
||||
}
|
||||
} else {
|
||||
prError = errorMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
prError = "gh_cli_not_available";
|
||||
@@ -274,7 +353,9 @@ export function createCreatePRHandler() {
|
||||
commitHash,
|
||||
pushed: true,
|
||||
prUrl,
|
||||
prNumber,
|
||||
prCreated: !!prUrl,
|
||||
prAlreadyExisted,
|
||||
prError: prError || undefined,
|
||||
browserUrl: browserUrl || undefined,
|
||||
ghCliAvailable,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { promisify } from "util";
|
||||
import { existsSync } from "fs";
|
||||
import { isGitRepo } from "@automaker/git-utils";
|
||||
import { getErrorMessage, logError, normalizePath } from "../common.js";
|
||||
import { readAllWorktreeMetadata, type WorktreePRInfo } from "../../../lib/worktree-metadata.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@@ -22,6 +23,7 @@ interface WorktreeInfo {
|
||||
hasWorktree: boolean; // Always true for items in this list
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
pr?: WorktreePRInfo; // PR info if a PR has been created for this branch
|
||||
}
|
||||
|
||||
async function getCurrentBranch(cwd: string): Promise<string> {
|
||||
@@ -107,6 +109,9 @@ export function createListHandler() {
|
||||
}
|
||||
}
|
||||
|
||||
// Read all worktree metadata to get PR info
|
||||
const allMetadata = await readAllWorktreeMetadata(projectPath);
|
||||
|
||||
// If includeDetails is requested, fetch change status for each worktree
|
||||
if (includeDetails) {
|
||||
for (const worktree of worktrees) {
|
||||
@@ -128,6 +133,14 @@ export function createListHandler() {
|
||||
}
|
||||
}
|
||||
|
||||
// Add PR info from metadata for each worktree
|
||||
for (const worktree of worktrees) {
|
||||
const metadata = allMetadata.get(worktree.branch);
|
||||
if (metadata?.pr) {
|
||||
worktree.pr = metadata.pr;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
worktrees,
|
||||
|
||||
269
apps/server/src/routes/worktree/routes/pr-info.ts
Normal file
269
apps/server/src/routes/worktree/routes/pr-info.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* POST /pr-info endpoint - Get PR info and comments for a branch
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import {
|
||||
getErrorMessage,
|
||||
logError,
|
||||
execAsync,
|
||||
execEnv,
|
||||
isValidBranchName,
|
||||
isGhCliAvailable,
|
||||
} from "../common.js";
|
||||
|
||||
export interface PRComment {
|
||||
id: number;
|
||||
author: string;
|
||||
body: string;
|
||||
path?: string;
|
||||
line?: number;
|
||||
createdAt: string;
|
||||
isReviewComment: boolean;
|
||||
}
|
||||
|
||||
export interface PRInfo {
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
state: string;
|
||||
author: string;
|
||||
body: string;
|
||||
comments: PRComment[];
|
||||
reviewComments: PRComment[];
|
||||
}
|
||||
|
||||
export function createPRInfoHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath, branchName } = req.body as {
|
||||
worktreePath: string;
|
||||
branchName: string;
|
||||
};
|
||||
|
||||
if (!worktreePath || !branchName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "worktreePath and branchName required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate branch name to prevent command injection
|
||||
if (!isValidBranchName(branchName)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "Invalid branch name contains unsafe characters",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if gh CLI is available
|
||||
const ghCliAvailable = await isGhCliAvailable();
|
||||
|
||||
if (!ghCliAvailable) {
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
hasPR: false,
|
||||
ghCliAvailable: false,
|
||||
error: "gh CLI not available",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect repository information (supports fork workflows)
|
||||
let upstreamRepo: string | null = null;
|
||||
let originOwner: string | null = null;
|
||||
let originRepo: string | null = null;
|
||||
|
||||
try {
|
||||
const { stdout: remotes } = await execAsync("git remote -v", {
|
||||
cwd: worktreePath,
|
||||
env: execEnv,
|
||||
});
|
||||
|
||||
const lines = remotes.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
let match =
|
||||
line.match(
|
||||
/^(\w+)\s+.*[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\s+\(fetch\)/
|
||||
) ||
|
||||
line.match(
|
||||
/^(\w+)\s+git@[^:]+:([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/
|
||||
) ||
|
||||
line.match(
|
||||
/^(\w+)\s+https?:\/\/[^/]+\/([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/
|
||||
);
|
||||
|
||||
if (match) {
|
||||
const [, remoteName, owner, repo] = match;
|
||||
if (remoteName === "upstream") {
|
||||
upstreamRepo = `${owner}/${repo}`;
|
||||
} else if (remoteName === "origin") {
|
||||
originOwner = owner;
|
||||
originRepo = repo;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore remote parsing errors
|
||||
}
|
||||
|
||||
if (!originOwner || !originRepo) {
|
||||
try {
|
||||
const { stdout: originUrl } = await execAsync(
|
||||
"git config --get remote.origin.url",
|
||||
{
|
||||
cwd: worktreePath,
|
||||
env: execEnv,
|
||||
}
|
||||
);
|
||||
const match = originUrl
|
||||
.trim()
|
||||
.match(/[:/]([^/]+)\/([^/\s]+?)(?:\.git)?$/);
|
||||
if (match) {
|
||||
if (!originOwner) {
|
||||
originOwner = match[1];
|
||||
}
|
||||
if (!originRepo) {
|
||||
originRepo = match[2];
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore fallback errors
|
||||
}
|
||||
}
|
||||
|
||||
const targetRepo =
|
||||
upstreamRepo || (originOwner && originRepo
|
||||
? `${originOwner}/${originRepo}`
|
||||
: null);
|
||||
const repoFlag = targetRepo ? ` --repo "${targetRepo}"` : "";
|
||||
const headRef =
|
||||
upstreamRepo && originOwner ? `${originOwner}:${branchName}` : branchName;
|
||||
|
||||
// Get PR info for the branch using gh CLI
|
||||
try {
|
||||
// First, find the PR associated with this branch
|
||||
const listCmd = `gh pr list${repoFlag} --head "${headRef}" --json number,title,url,state,author,body --limit 1`;
|
||||
const { stdout: prListOutput } = await execAsync(
|
||||
listCmd,
|
||||
{ cwd: worktreePath, env: execEnv }
|
||||
);
|
||||
|
||||
const prList = JSON.parse(prListOutput);
|
||||
|
||||
if (prList.length === 0) {
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
hasPR: false,
|
||||
ghCliAvailable: true,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const pr = prList[0];
|
||||
const prNumber = pr.number;
|
||||
|
||||
// Get regular PR comments (issue comments)
|
||||
let comments: PRComment[] = [];
|
||||
try {
|
||||
const viewCmd = `gh pr view ${prNumber}${repoFlag} --json comments`;
|
||||
const { stdout: commentsOutput } = await execAsync(
|
||||
viewCmd,
|
||||
{ cwd: worktreePath, env: execEnv }
|
||||
);
|
||||
const commentsData = JSON.parse(commentsOutput);
|
||||
comments = (commentsData.comments || []).map((c: {
|
||||
id: number;
|
||||
author: { login: string };
|
||||
body: string;
|
||||
createdAt: string;
|
||||
}) => ({
|
||||
id: c.id,
|
||||
author: c.author?.login || "unknown",
|
||||
body: c.body,
|
||||
createdAt: c.createdAt,
|
||||
isReviewComment: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.warn("[PRInfo] Failed to fetch PR comments:", error);
|
||||
}
|
||||
|
||||
// Get review comments (inline code comments)
|
||||
let reviewComments: PRComment[] = [];
|
||||
// Only fetch review comments if we have repository info
|
||||
if (targetRepo) {
|
||||
try {
|
||||
const reviewsEndpoint = `repos/${targetRepo}/pulls/${prNumber}/comments`;
|
||||
const reviewsCmd = `gh api ${reviewsEndpoint}`;
|
||||
const { stdout: reviewsOutput } = await execAsync(
|
||||
reviewsCmd,
|
||||
{ cwd: worktreePath, env: execEnv }
|
||||
);
|
||||
const reviewsData = JSON.parse(reviewsOutput);
|
||||
reviewComments = reviewsData.map((c: {
|
||||
id: number;
|
||||
user: { login: string };
|
||||
body: string;
|
||||
path: string;
|
||||
line?: number;
|
||||
original_line?: number;
|
||||
created_at: string;
|
||||
}) => ({
|
||||
id: c.id,
|
||||
author: c.user?.login || "unknown",
|
||||
body: c.body,
|
||||
path: c.path,
|
||||
line: c.line || c.original_line,
|
||||
createdAt: c.created_at,
|
||||
isReviewComment: true,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.warn("[PRInfo] Failed to fetch review comments:", error);
|
||||
}
|
||||
} else {
|
||||
console.warn("[PRInfo] Cannot fetch review comments: repository info not available");
|
||||
}
|
||||
|
||||
const prInfo: PRInfo = {
|
||||
number: prNumber,
|
||||
title: pr.title,
|
||||
url: pr.url,
|
||||
state: pr.state,
|
||||
author: pr.author?.login || "unknown",
|
||||
body: pr.body || "",
|
||||
comments,
|
||||
reviewComments,
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
hasPR: true,
|
||||
ghCliAvailable: true,
|
||||
prInfo,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// gh CLI failed - might not be authenticated or no remote
|
||||
logError(error, "Failed to get PR info");
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
hasPR: false,
|
||||
ghCliAvailable: true,
|
||||
error: getErrorMessage(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, "PR info handler failed");
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
628
apps/server/src/services/settings-service.ts
Normal file
628
apps/server/src/services/settings-service.ts
Normal file
@@ -0,0 +1,628 @@
|
||||
/**
|
||||
* Settings Service - Handles reading/writing settings to JSON files
|
||||
*
|
||||
* Provides persistent storage for:
|
||||
* - Global settings (DATA_DIR/settings.json)
|
||||
* - Credentials (DATA_DIR/credentials.json)
|
||||
* - Per-project settings ({projectPath}/.automaker/settings.json)
|
||||
*/
|
||||
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { createLogger } from "../lib/logger.js";
|
||||
import {
|
||||
getGlobalSettingsPath,
|
||||
getCredentialsPath,
|
||||
getProjectSettingsPath,
|
||||
ensureDataDir,
|
||||
ensureAutomakerDir,
|
||||
} from "../lib/automaker-paths.js";
|
||||
import type {
|
||||
GlobalSettings,
|
||||
Credentials,
|
||||
ProjectSettings,
|
||||
KeyboardShortcuts,
|
||||
AIProfile,
|
||||
ProjectRef,
|
||||
TrashedProjectRef,
|
||||
BoardBackgroundSettings,
|
||||
WorktreeInfo,
|
||||
} from "../types/settings.js";
|
||||
import {
|
||||
DEFAULT_GLOBAL_SETTINGS,
|
||||
DEFAULT_CREDENTIALS,
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
SETTINGS_VERSION,
|
||||
CREDENTIALS_VERSION,
|
||||
PROJECT_SETTINGS_VERSION,
|
||||
} from "../types/settings.js";
|
||||
|
||||
const logger = createLogger("SettingsService");
|
||||
|
||||
/**
|
||||
* Atomic file write - write to temp file then rename
|
||||
*/
|
||||
async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
|
||||
const tempPath = `${filePath}.tmp.${Date.now()}`;
|
||||
const content = JSON.stringify(data, null, 2);
|
||||
|
||||
try {
|
||||
await fs.writeFile(tempPath, content, "utf-8");
|
||||
await fs.rename(tempPath, filePath);
|
||||
} catch (error) {
|
||||
// Clean up temp file if it exists
|
||||
try {
|
||||
await fs.unlink(tempPath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely read JSON file with fallback to default
|
||||
*/
|
||||
async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf-8");
|
||||
return JSON.parse(content) as T;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return defaultValue;
|
||||
}
|
||||
logger.error(`Error reading ${filePath}:`, error);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file exists
|
||||
*/
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SettingsService - Manages persistent storage of user settings and credentials
|
||||
*
|
||||
* Handles reading and writing settings to JSON files with atomic operations
|
||||
* for reliability. Provides three levels of settings:
|
||||
* - Global settings: shared preferences in {dataDir}/settings.json
|
||||
* - Credentials: sensitive API keys in {dataDir}/credentials.json
|
||||
* - Project settings: per-project overrides in {projectPath}/.automaker/settings.json
|
||||
*
|
||||
* All operations are atomic (write to temp file, then rename) to prevent corruption.
|
||||
* Missing files are treated as empty and return defaults on read.
|
||||
* Updates use deep merge for nested objects like keyboardShortcuts and apiKeys.
|
||||
*/
|
||||
export class SettingsService {
|
||||
private dataDir: string;
|
||||
|
||||
/**
|
||||
* Create a new SettingsService instance
|
||||
*
|
||||
* @param dataDir - Absolute path to global data directory (e.g., ~/.automaker)
|
||||
*/
|
||||
constructor(dataDir: string) {
|
||||
this.dataDir = dataDir;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Global Settings
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get global settings with defaults applied for any missing fields
|
||||
*
|
||||
* Reads from {dataDir}/settings.json. If file doesn't exist, returns defaults.
|
||||
* Missing fields are filled in from DEFAULT_GLOBAL_SETTINGS for forward/backward
|
||||
* compatibility during schema migrations.
|
||||
*
|
||||
* @returns Promise resolving to complete GlobalSettings object
|
||||
*/
|
||||
async getGlobalSettings(): Promise<GlobalSettings> {
|
||||
const settingsPath = getGlobalSettingsPath(this.dataDir);
|
||||
const settings = await readJsonFile<GlobalSettings>(
|
||||
settingsPath,
|
||||
DEFAULT_GLOBAL_SETTINGS
|
||||
);
|
||||
|
||||
// Apply any missing defaults (for backwards compatibility)
|
||||
return {
|
||||
...DEFAULT_GLOBAL_SETTINGS,
|
||||
...settings,
|
||||
keyboardShortcuts: {
|
||||
...DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
|
||||
...settings.keyboardShortcuts,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update global settings with partial changes
|
||||
*
|
||||
* Performs a deep merge: nested objects like keyboardShortcuts are merged,
|
||||
* not replaced. Updates are written atomically. Creates dataDir if needed.
|
||||
*
|
||||
* @param updates - Partial GlobalSettings to merge (only provided fields are updated)
|
||||
* @returns Promise resolving to complete updated GlobalSettings
|
||||
*/
|
||||
async updateGlobalSettings(
|
||||
updates: Partial<GlobalSettings>
|
||||
): Promise<GlobalSettings> {
|
||||
await ensureDataDir(this.dataDir);
|
||||
const settingsPath = getGlobalSettingsPath(this.dataDir);
|
||||
|
||||
const current = await this.getGlobalSettings();
|
||||
const updated: GlobalSettings = {
|
||||
...current,
|
||||
...updates,
|
||||
version: SETTINGS_VERSION,
|
||||
};
|
||||
|
||||
// Deep merge keyboard shortcuts if provided
|
||||
if (updates.keyboardShortcuts) {
|
||||
updated.keyboardShortcuts = {
|
||||
...current.keyboardShortcuts,
|
||||
...updates.keyboardShortcuts,
|
||||
};
|
||||
}
|
||||
|
||||
await atomicWriteJson(settingsPath, updated);
|
||||
logger.info("Global settings updated");
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if global settings file exists
|
||||
*
|
||||
* Used to determine if user has previously configured settings.
|
||||
*
|
||||
* @returns Promise resolving to true if {dataDir}/settings.json exists
|
||||
*/
|
||||
async hasGlobalSettings(): Promise<boolean> {
|
||||
const settingsPath = getGlobalSettingsPath(this.dataDir);
|
||||
return fileExists(settingsPath);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Credentials
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get credentials with defaults applied
|
||||
*
|
||||
* Reads from {dataDir}/credentials.json. If file doesn't exist, returns
|
||||
* defaults (empty API keys). Used primarily by backend for API authentication.
|
||||
* UI should use getMaskedCredentials() instead.
|
||||
*
|
||||
* @returns Promise resolving to complete Credentials object
|
||||
*/
|
||||
async getCredentials(): Promise<Credentials> {
|
||||
const credentialsPath = getCredentialsPath(this.dataDir);
|
||||
const credentials = await readJsonFile<Credentials>(
|
||||
credentialsPath,
|
||||
DEFAULT_CREDENTIALS
|
||||
);
|
||||
|
||||
return {
|
||||
...DEFAULT_CREDENTIALS,
|
||||
...credentials,
|
||||
apiKeys: {
|
||||
...DEFAULT_CREDENTIALS.apiKeys,
|
||||
...credentials.apiKeys,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update credentials with partial changes
|
||||
*
|
||||
* Updates individual API keys. Uses deep merge for apiKeys object.
|
||||
* Creates dataDir if needed. Credentials are written atomically.
|
||||
* WARNING: Use only in secure contexts - keys are unencrypted.
|
||||
*
|
||||
* @param updates - Partial Credentials (usually just apiKeys)
|
||||
* @returns Promise resolving to complete updated Credentials object
|
||||
*/
|
||||
async updateCredentials(
|
||||
updates: Partial<Credentials>
|
||||
): Promise<Credentials> {
|
||||
await ensureDataDir(this.dataDir);
|
||||
const credentialsPath = getCredentialsPath(this.dataDir);
|
||||
|
||||
const current = await this.getCredentials();
|
||||
const updated: Credentials = {
|
||||
...current,
|
||||
...updates,
|
||||
version: CREDENTIALS_VERSION,
|
||||
};
|
||||
|
||||
// Deep merge api keys if provided
|
||||
if (updates.apiKeys) {
|
||||
updated.apiKeys = {
|
||||
...current.apiKeys,
|
||||
...updates.apiKeys,
|
||||
};
|
||||
}
|
||||
|
||||
await atomicWriteJson(credentialsPath, updated);
|
||||
logger.info("Credentials updated");
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get masked credentials safe for UI display
|
||||
*
|
||||
* Returns API keys masked for security (first 4 and last 4 chars visible).
|
||||
* Use this for showing credential status in UI without exposing full keys.
|
||||
* Each key includes a 'configured' boolean and masked string representation.
|
||||
*
|
||||
* @returns Promise resolving to masked credentials object with each provider's status
|
||||
*/
|
||||
async getMaskedCredentials(): Promise<{
|
||||
anthropic: { configured: boolean; masked: string };
|
||||
google: { configured: boolean; masked: string };
|
||||
openai: { configured: boolean; masked: string };
|
||||
}> {
|
||||
const credentials = await this.getCredentials();
|
||||
|
||||
const maskKey = (key: string): string => {
|
||||
if (!key || key.length < 8) return "";
|
||||
return `${key.substring(0, 4)}...${key.substring(key.length - 4)}`;
|
||||
};
|
||||
|
||||
return {
|
||||
anthropic: {
|
||||
configured: !!credentials.apiKeys.anthropic,
|
||||
masked: maskKey(credentials.apiKeys.anthropic),
|
||||
},
|
||||
google: {
|
||||
configured: !!credentials.apiKeys.google,
|
||||
masked: maskKey(credentials.apiKeys.google),
|
||||
},
|
||||
openai: {
|
||||
configured: !!credentials.apiKeys.openai,
|
||||
masked: maskKey(credentials.apiKeys.openai),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if credentials file exists
|
||||
*
|
||||
* Used to determine if user has configured any API keys.
|
||||
*
|
||||
* @returns Promise resolving to true if {dataDir}/credentials.json exists
|
||||
*/
|
||||
async hasCredentials(): Promise<boolean> {
|
||||
const credentialsPath = getCredentialsPath(this.dataDir);
|
||||
return fileExists(credentialsPath);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Project Settings
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get project-specific settings with defaults applied
|
||||
*
|
||||
* Reads from {projectPath}/.automaker/settings.json. If file doesn't exist,
|
||||
* returns defaults. Project settings are optional - missing values fall back
|
||||
* to global settings on the UI side.
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @returns Promise resolving to complete ProjectSettings object
|
||||
*/
|
||||
async getProjectSettings(projectPath: string): Promise<ProjectSettings> {
|
||||
const settingsPath = getProjectSettingsPath(projectPath);
|
||||
const settings = await readJsonFile<ProjectSettings>(
|
||||
settingsPath,
|
||||
DEFAULT_PROJECT_SETTINGS
|
||||
);
|
||||
|
||||
return {
|
||||
...DEFAULT_PROJECT_SETTINGS,
|
||||
...settings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update project-specific settings with partial changes
|
||||
*
|
||||
* Performs a deep merge on boardBackground. Creates .automaker directory
|
||||
* in project if needed. Updates are written atomically.
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @param updates - Partial ProjectSettings to merge
|
||||
* @returns Promise resolving to complete updated ProjectSettings
|
||||
*/
|
||||
async updateProjectSettings(
|
||||
projectPath: string,
|
||||
updates: Partial<ProjectSettings>
|
||||
): Promise<ProjectSettings> {
|
||||
await ensureAutomakerDir(projectPath);
|
||||
const settingsPath = getProjectSettingsPath(projectPath);
|
||||
|
||||
const current = await this.getProjectSettings(projectPath);
|
||||
const updated: ProjectSettings = {
|
||||
...current,
|
||||
...updates,
|
||||
version: PROJECT_SETTINGS_VERSION,
|
||||
};
|
||||
|
||||
// Deep merge board background if provided
|
||||
if (updates.boardBackground) {
|
||||
updated.boardBackground = {
|
||||
...current.boardBackground,
|
||||
...updates.boardBackground,
|
||||
};
|
||||
}
|
||||
|
||||
await atomicWriteJson(settingsPath, updated);
|
||||
logger.info(`Project settings updated for ${projectPath}`);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if project settings file exists
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @returns Promise resolving to true if {projectPath}/.automaker/settings.json exists
|
||||
*/
|
||||
async hasProjectSettings(projectPath: string): Promise<boolean> {
|
||||
const settingsPath = getProjectSettingsPath(projectPath);
|
||||
return fileExists(settingsPath);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Migration
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Migrate settings from localStorage to file-based storage
|
||||
*
|
||||
* Called during onboarding when UI detects localStorage data but no settings files.
|
||||
* Extracts global settings, credentials, and per-project settings from various
|
||||
* localStorage keys and writes them to the new file-based storage.
|
||||
* Collects errors but continues on partial failures.
|
||||
*
|
||||
* @param localStorageData - Object containing localStorage key/value pairs to migrate
|
||||
* @returns Promise resolving to migration result with success status and error list
|
||||
*/
|
||||
async migrateFromLocalStorage(localStorageData: {
|
||||
"automaker-storage"?: string;
|
||||
"automaker-setup"?: string;
|
||||
"worktree-panel-collapsed"?: string;
|
||||
"file-browser-recent-folders"?: string;
|
||||
"automaker:lastProjectDir"?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
migratedGlobalSettings: boolean;
|
||||
migratedCredentials: boolean;
|
||||
migratedProjectCount: number;
|
||||
errors: string[];
|
||||
}> {
|
||||
const errors: string[] = [];
|
||||
let migratedGlobalSettings = false;
|
||||
let migratedCredentials = false;
|
||||
let migratedProjectCount = 0;
|
||||
|
||||
try {
|
||||
// Parse the main automaker-storage
|
||||
let appState: Record<string, unknown> = {};
|
||||
if (localStorageData["automaker-storage"]) {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorageData["automaker-storage"]);
|
||||
appState = parsed.state || parsed;
|
||||
} catch (e) {
|
||||
errors.push(`Failed to parse automaker-storage: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract global settings
|
||||
const globalSettings: Partial<GlobalSettings> = {
|
||||
theme: (appState.theme as GlobalSettings["theme"]) || "dark",
|
||||
sidebarOpen:
|
||||
appState.sidebarOpen !== undefined
|
||||
? (appState.sidebarOpen as boolean)
|
||||
: true,
|
||||
chatHistoryOpen: (appState.chatHistoryOpen as boolean) || false,
|
||||
kanbanCardDetailLevel:
|
||||
(appState.kanbanCardDetailLevel as GlobalSettings["kanbanCardDetailLevel"]) ||
|
||||
"standard",
|
||||
maxConcurrency: (appState.maxConcurrency as number) || 3,
|
||||
defaultSkipTests:
|
||||
appState.defaultSkipTests !== undefined
|
||||
? (appState.defaultSkipTests as boolean)
|
||||
: true,
|
||||
enableDependencyBlocking:
|
||||
appState.enableDependencyBlocking !== undefined
|
||||
? (appState.enableDependencyBlocking as boolean)
|
||||
: true,
|
||||
useWorktrees: (appState.useWorktrees as boolean) || false,
|
||||
showProfilesOnly: (appState.showProfilesOnly as boolean) || false,
|
||||
defaultPlanningMode:
|
||||
(appState.defaultPlanningMode as GlobalSettings["defaultPlanningMode"]) ||
|
||||
"skip",
|
||||
defaultRequirePlanApproval:
|
||||
(appState.defaultRequirePlanApproval as boolean) || false,
|
||||
defaultAIProfileId:
|
||||
(appState.defaultAIProfileId as string | null) || null,
|
||||
muteDoneSound: (appState.muteDoneSound as boolean) || false,
|
||||
enhancementModel:
|
||||
(appState.enhancementModel as GlobalSettings["enhancementModel"]) ||
|
||||
"sonnet",
|
||||
keyboardShortcuts:
|
||||
(appState.keyboardShortcuts as KeyboardShortcuts) ||
|
||||
DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
|
||||
aiProfiles: (appState.aiProfiles as AIProfile[]) || [],
|
||||
projects: (appState.projects as ProjectRef[]) || [],
|
||||
trashedProjects:
|
||||
(appState.trashedProjects as TrashedProjectRef[]) || [],
|
||||
projectHistory: (appState.projectHistory as string[]) || [],
|
||||
projectHistoryIndex: (appState.projectHistoryIndex as number) || -1,
|
||||
lastSelectedSessionByProject:
|
||||
(appState.lastSelectedSessionByProject as Record<string, string>) ||
|
||||
{},
|
||||
};
|
||||
|
||||
// Add direct localStorage values
|
||||
if (localStorageData["automaker:lastProjectDir"]) {
|
||||
globalSettings.lastProjectDir =
|
||||
localStorageData["automaker:lastProjectDir"];
|
||||
}
|
||||
|
||||
if (localStorageData["file-browser-recent-folders"]) {
|
||||
try {
|
||||
globalSettings.recentFolders = JSON.parse(
|
||||
localStorageData["file-browser-recent-folders"]
|
||||
);
|
||||
} catch {
|
||||
globalSettings.recentFolders = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (localStorageData["worktree-panel-collapsed"]) {
|
||||
globalSettings.worktreePanelCollapsed =
|
||||
localStorageData["worktree-panel-collapsed"] === "true";
|
||||
}
|
||||
|
||||
// Save global settings
|
||||
await this.updateGlobalSettings(globalSettings);
|
||||
migratedGlobalSettings = true;
|
||||
logger.info("Migrated global settings from localStorage");
|
||||
|
||||
// Extract and save credentials
|
||||
if (appState.apiKeys) {
|
||||
const apiKeys = appState.apiKeys as {
|
||||
anthropic?: string;
|
||||
google?: string;
|
||||
openai?: string;
|
||||
};
|
||||
await this.updateCredentials({
|
||||
apiKeys: {
|
||||
anthropic: apiKeys.anthropic || "",
|
||||
google: apiKeys.google || "",
|
||||
openai: apiKeys.openai || "",
|
||||
},
|
||||
});
|
||||
migratedCredentials = true;
|
||||
logger.info("Migrated credentials from localStorage");
|
||||
}
|
||||
|
||||
// Migrate per-project settings
|
||||
const boardBackgroundByProject = appState.boardBackgroundByProject as
|
||||
| Record<string, BoardBackgroundSettings>
|
||||
| undefined;
|
||||
const currentWorktreeByProject = appState.currentWorktreeByProject as
|
||||
| Record<string, { path: string | null; branch: string }>
|
||||
| undefined;
|
||||
const worktreesByProject = appState.worktreesByProject as
|
||||
| Record<string, WorktreeInfo[]>
|
||||
| undefined;
|
||||
|
||||
// Get unique project paths that have per-project settings
|
||||
const projectPaths = new Set<string>();
|
||||
if (boardBackgroundByProject) {
|
||||
Object.keys(boardBackgroundByProject).forEach((p) =>
|
||||
projectPaths.add(p)
|
||||
);
|
||||
}
|
||||
if (currentWorktreeByProject) {
|
||||
Object.keys(currentWorktreeByProject).forEach((p) =>
|
||||
projectPaths.add(p)
|
||||
);
|
||||
}
|
||||
if (worktreesByProject) {
|
||||
Object.keys(worktreesByProject).forEach((p) => projectPaths.add(p));
|
||||
}
|
||||
|
||||
// Also check projects list for theme settings
|
||||
const projects = (appState.projects as ProjectRef[]) || [];
|
||||
for (const project of projects) {
|
||||
if (project.theme) {
|
||||
projectPaths.add(project.path);
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate each project's settings
|
||||
for (const projectPath of projectPaths) {
|
||||
try {
|
||||
const projectSettings: Partial<ProjectSettings> = {};
|
||||
|
||||
// Get theme from project object
|
||||
const project = projects.find((p) => p.path === projectPath);
|
||||
if (project?.theme) {
|
||||
projectSettings.theme =
|
||||
project.theme as ProjectSettings["theme"];
|
||||
}
|
||||
|
||||
if (boardBackgroundByProject?.[projectPath]) {
|
||||
projectSettings.boardBackground =
|
||||
boardBackgroundByProject[projectPath];
|
||||
}
|
||||
|
||||
if (currentWorktreeByProject?.[projectPath]) {
|
||||
projectSettings.currentWorktree =
|
||||
currentWorktreeByProject[projectPath];
|
||||
}
|
||||
|
||||
if (worktreesByProject?.[projectPath]) {
|
||||
projectSettings.worktrees = worktreesByProject[projectPath];
|
||||
}
|
||||
|
||||
if (Object.keys(projectSettings).length > 0) {
|
||||
await this.updateProjectSettings(projectPath, projectSettings);
|
||||
migratedProjectCount++;
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push(`Failed to migrate project settings for ${projectPath}: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Migration complete: ${migratedProjectCount} projects migrated`
|
||||
);
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
migratedGlobalSettings,
|
||||
migratedCredentials,
|
||||
migratedProjectCount,
|
||||
errors,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Migration failed:", error);
|
||||
errors.push(`Migration failed: ${error}`);
|
||||
return {
|
||||
success: false,
|
||||
migratedGlobalSettings,
|
||||
migratedCredentials,
|
||||
migratedProjectCount,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the data directory path
|
||||
*
|
||||
* Returns the absolute path to the directory where global settings and
|
||||
* credentials are stored. Useful for logging, debugging, and validation.
|
||||
*
|
||||
* @returns Absolute path to data directory
|
||||
*/
|
||||
getDataDir(): string {
|
||||
return this.dataDir;
|
||||
}
|
||||
}
|
||||
428
apps/server/src/types/settings.ts
Normal file
428
apps/server/src/types/settings.ts
Normal file
@@ -0,0 +1,428 @@
|
||||
/**
|
||||
* Settings Types - Shared types for file-based settings storage
|
||||
*
|
||||
* Defines the structure for global settings, credentials, and per-project settings
|
||||
* that are persisted to disk in JSON format. These types are used by both the server
|
||||
* (for file I/O via SettingsService) and the UI (for state management and sync).
|
||||
*/
|
||||
|
||||
/**
|
||||
* ThemeMode - Available color themes for the UI
|
||||
*
|
||||
* Includes system theme and multiple color schemes:
|
||||
* - System: Respects OS dark/light mode preference
|
||||
* - Light/Dark: Basic light and dark variants
|
||||
* - Color Schemes: Retro, Dracula, Nord, Monokai, Tokyo Night, Solarized, Gruvbox,
|
||||
* Catppuccin, OneDark, Synthwave, Red, Cream, Sunset, Gray
|
||||
*/
|
||||
export type ThemeMode =
|
||||
| "light"
|
||||
| "dark"
|
||||
| "system"
|
||||
| "retro"
|
||||
| "dracula"
|
||||
| "nord"
|
||||
| "monokai"
|
||||
| "tokyonight"
|
||||
| "solarized"
|
||||
| "gruvbox"
|
||||
| "catppuccin"
|
||||
| "onedark"
|
||||
| "synthwave"
|
||||
| "red"
|
||||
| "cream"
|
||||
| "sunset"
|
||||
| "gray";
|
||||
|
||||
/** KanbanCardDetailLevel - Controls how much information is displayed on kanban cards */
|
||||
export type KanbanCardDetailLevel = "minimal" | "standard" | "detailed";
|
||||
|
||||
/** AgentModel - Available Claude models for feature generation and planning */
|
||||
export type AgentModel = "opus" | "sonnet" | "haiku";
|
||||
|
||||
/** PlanningMode - Planning levels for feature generation workflows */
|
||||
export type PlanningMode = "skip" | "lite" | "spec" | "full";
|
||||
|
||||
/** ThinkingLevel - Extended thinking levels for Claude models (reasoning intensity) */
|
||||
export type ThinkingLevel = "none" | "low" | "medium" | "high" | "ultrathink";
|
||||
|
||||
/** ModelProvider - AI model provider for credentials and API key management */
|
||||
export type ModelProvider = "claude";
|
||||
|
||||
/**
|
||||
* KeyboardShortcuts - User-configurable keyboard bindings for common actions
|
||||
*
|
||||
* Each property maps an action to a keyboard shortcut string
|
||||
* (e.g., "Ctrl+K", "Alt+N", "Shift+P")
|
||||
*/
|
||||
export interface KeyboardShortcuts {
|
||||
/** Open board view */
|
||||
board: string;
|
||||
/** Open agent panel */
|
||||
agent: string;
|
||||
/** Open feature spec editor */
|
||||
spec: string;
|
||||
/** Open context files panel */
|
||||
context: string;
|
||||
/** Open settings */
|
||||
settings: string;
|
||||
/** Open AI profiles */
|
||||
profiles: string;
|
||||
/** Open terminal */
|
||||
terminal: string;
|
||||
/** Toggle sidebar visibility */
|
||||
toggleSidebar: string;
|
||||
/** Add new feature */
|
||||
addFeature: string;
|
||||
/** Add context file */
|
||||
addContextFile: string;
|
||||
/** Start next feature generation */
|
||||
startNext: string;
|
||||
/** Create new chat session */
|
||||
newSession: string;
|
||||
/** Open project picker */
|
||||
openProject: string;
|
||||
/** Open project picker (alternate) */
|
||||
projectPicker: string;
|
||||
/** Cycle to previous project */
|
||||
cyclePrevProject: string;
|
||||
/** Cycle to next project */
|
||||
cycleNextProject: string;
|
||||
/** Add new AI profile */
|
||||
addProfile: string;
|
||||
/** Split terminal right */
|
||||
splitTerminalRight: string;
|
||||
/** Split terminal down */
|
||||
splitTerminalDown: string;
|
||||
/** Close current terminal */
|
||||
closeTerminal: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AIProfile - Configuration for an AI model with specific parameters
|
||||
*
|
||||
* Profiles can be built-in defaults or user-created. They define which model to use,
|
||||
* thinking level, and other parameters for feature generation tasks.
|
||||
*/
|
||||
export interface AIProfile {
|
||||
/** Unique identifier for the profile */
|
||||
id: string;
|
||||
/** Display name for the profile */
|
||||
name: string;
|
||||
/** User-friendly description */
|
||||
description: string;
|
||||
/** Which Claude model to use (opus, sonnet, haiku) */
|
||||
model: AgentModel;
|
||||
/** Extended thinking level for reasoning-based tasks */
|
||||
thinkingLevel: ThinkingLevel;
|
||||
/** Provider (currently only "claude") */
|
||||
provider: ModelProvider;
|
||||
/** Whether this is a built-in default profile */
|
||||
isBuiltIn: boolean;
|
||||
/** Optional icon identifier or emoji */
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ProjectRef - Minimal reference to a project stored in global settings
|
||||
*
|
||||
* Used for the projects list and project history. Full project data is loaded separately.
|
||||
*/
|
||||
export interface ProjectRef {
|
||||
/** Unique identifier */
|
||||
id: string;
|
||||
/** Display name */
|
||||
name: string;
|
||||
/** Absolute filesystem path to project directory */
|
||||
path: string;
|
||||
/** ISO timestamp of last time project was opened */
|
||||
lastOpened?: string;
|
||||
/** Project-specific theme override (or undefined to use global) */
|
||||
theme?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TrashedProjectRef - Reference to a project in the trash/recycle bin
|
||||
*
|
||||
* Extends ProjectRef with deletion metadata. User can permanently delete or restore.
|
||||
*/
|
||||
export interface TrashedProjectRef extends ProjectRef {
|
||||
/** ISO timestamp when project was moved to trash */
|
||||
trashedAt: string;
|
||||
/** Whether project folder was deleted from disk */
|
||||
deletedFromDisk?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* ChatSessionRef - Minimal reference to a chat session
|
||||
*
|
||||
* Used for session lists and history. Full session content is stored separately.
|
||||
*/
|
||||
export interface ChatSessionRef {
|
||||
/** Unique session identifier */
|
||||
id: string;
|
||||
/** User-given or AI-generated title */
|
||||
title: string;
|
||||
/** Project that session belongs to */
|
||||
projectId: string;
|
||||
/** ISO timestamp of creation */
|
||||
createdAt: string;
|
||||
/** ISO timestamp of last message */
|
||||
updatedAt: string;
|
||||
/** Whether session is archived */
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* GlobalSettings - User preferences and state stored globally in {DATA_DIR}/settings.json
|
||||
*
|
||||
* This is the main settings file that persists user preferences across sessions.
|
||||
* Includes theme, UI state, feature defaults, keyboard shortcuts, AI profiles, and projects.
|
||||
* Format: JSON with version field for migration support.
|
||||
*/
|
||||
export interface GlobalSettings {
|
||||
/** Version number for schema migration */
|
||||
version: number;
|
||||
|
||||
// Theme Configuration
|
||||
/** Currently selected theme */
|
||||
theme: ThemeMode;
|
||||
|
||||
// UI State Preferences
|
||||
/** Whether sidebar is currently open */
|
||||
sidebarOpen: boolean;
|
||||
/** Whether chat history panel is open */
|
||||
chatHistoryOpen: boolean;
|
||||
/** How much detail to show on kanban cards */
|
||||
kanbanCardDetailLevel: KanbanCardDetailLevel;
|
||||
|
||||
// Feature Generation Defaults
|
||||
/** Max features to generate concurrently */
|
||||
maxConcurrency: number;
|
||||
/** Default: skip tests during feature generation */
|
||||
defaultSkipTests: boolean;
|
||||
/** Default: enable dependency blocking */
|
||||
enableDependencyBlocking: boolean;
|
||||
/** Default: use git worktrees for feature branches */
|
||||
useWorktrees: boolean;
|
||||
/** Default: only show AI profiles (hide other settings) */
|
||||
showProfilesOnly: boolean;
|
||||
/** Default: planning approach (skip/lite/spec/full) */
|
||||
defaultPlanningMode: PlanningMode;
|
||||
/** Default: require manual approval before generating */
|
||||
defaultRequirePlanApproval: boolean;
|
||||
/** ID of currently selected AI profile (null = use built-in) */
|
||||
defaultAIProfileId: string | null;
|
||||
|
||||
// Audio Preferences
|
||||
/** Mute completion notification sound */
|
||||
muteDoneSound: boolean;
|
||||
|
||||
// AI Model Selection
|
||||
/** Which model to use for feature name/description enhancement */
|
||||
enhancementModel: AgentModel;
|
||||
|
||||
// Input Configuration
|
||||
/** User's keyboard shortcut bindings */
|
||||
keyboardShortcuts: KeyboardShortcuts;
|
||||
|
||||
// AI Profiles
|
||||
/** User-created AI profiles */
|
||||
aiProfiles: AIProfile[];
|
||||
|
||||
// Project Management
|
||||
/** List of active projects */
|
||||
projects: ProjectRef[];
|
||||
/** Projects in trash/recycle bin */
|
||||
trashedProjects: TrashedProjectRef[];
|
||||
/** History of recently opened project IDs */
|
||||
projectHistory: string[];
|
||||
/** Current position in project history for navigation */
|
||||
projectHistoryIndex: number;
|
||||
|
||||
// File Browser and UI Preferences
|
||||
/** Last directory opened in file picker */
|
||||
lastProjectDir?: string;
|
||||
/** Recently accessed folders for quick access */
|
||||
recentFolders: string[];
|
||||
/** Whether worktree panel is collapsed in current view */
|
||||
worktreePanelCollapsed: boolean;
|
||||
|
||||
// Session Tracking
|
||||
/** Maps project path -> last selected session ID in that project */
|
||||
lastSelectedSessionByProject: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Credentials - API keys stored in {DATA_DIR}/credentials.json
|
||||
*
|
||||
* Sensitive data stored separately from general settings.
|
||||
* Keys should never be exposed in UI or logs.
|
||||
*/
|
||||
export interface Credentials {
|
||||
/** Version number for schema migration */
|
||||
version: number;
|
||||
/** API keys for various providers */
|
||||
apiKeys: {
|
||||
/** Anthropic Claude API key */
|
||||
anthropic: string;
|
||||
/** Google API key (for embeddings or other services) */
|
||||
google: string;
|
||||
/** OpenAI API key (for compatibility or alternative providers) */
|
||||
openai: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* BoardBackgroundSettings - Kanban board appearance customization
|
||||
*
|
||||
* Controls background images, opacity, borders, and visual effects for the board.
|
||||
*/
|
||||
export interface BoardBackgroundSettings {
|
||||
/** Path to background image file (null = no image) */
|
||||
imagePath: string | null;
|
||||
/** Version/timestamp of image for cache busting */
|
||||
imageVersion?: number;
|
||||
/** Opacity of cards (0-1) */
|
||||
cardOpacity: number;
|
||||
/** Opacity of columns (0-1) */
|
||||
columnOpacity: number;
|
||||
/** Show border around columns */
|
||||
columnBorderEnabled: boolean;
|
||||
/** Apply glassmorphism effect to cards */
|
||||
cardGlassmorphism: boolean;
|
||||
/** Show border around cards */
|
||||
cardBorderEnabled: boolean;
|
||||
/** Opacity of card borders (0-1) */
|
||||
cardBorderOpacity: number;
|
||||
/** Hide scrollbar in board view */
|
||||
hideScrollbar: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* WorktreeInfo - Information about a git worktree
|
||||
*
|
||||
* Tracks worktree location, branch, and dirty state for project management.
|
||||
*/
|
||||
export interface WorktreeInfo {
|
||||
/** Absolute path to worktree directory */
|
||||
path: string;
|
||||
/** Branch checked out in this worktree */
|
||||
branch: string;
|
||||
/** Whether this is the main worktree */
|
||||
isMain: boolean;
|
||||
/** Whether worktree has uncommitted changes */
|
||||
hasChanges?: boolean;
|
||||
/** Number of files with changes */
|
||||
changedFilesCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* ProjectSettings - Project-specific overrides stored in {projectPath}/.automaker/settings.json
|
||||
*
|
||||
* Allows per-project customization without affecting global settings.
|
||||
* All fields are optional - missing values fall back to global settings.
|
||||
*/
|
||||
export interface ProjectSettings {
|
||||
/** Version number for schema migration */
|
||||
version: number;
|
||||
|
||||
// Theme Configuration (project-specific override)
|
||||
/** Project theme (undefined = use global setting) */
|
||||
theme?: ThemeMode;
|
||||
|
||||
// Worktree Management
|
||||
/** Project-specific worktree preference override */
|
||||
useWorktrees?: boolean;
|
||||
/** Current worktree being used in this project */
|
||||
currentWorktree?: { path: string | null; branch: string };
|
||||
/** List of worktrees available in this project */
|
||||
worktrees?: WorktreeInfo[];
|
||||
|
||||
// Board Customization
|
||||
/** Project-specific board background settings */
|
||||
boardBackground?: BoardBackgroundSettings;
|
||||
|
||||
// Session Tracking
|
||||
/** Last chat session selected in this project */
|
||||
lastSelectedSessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default values and constants
|
||||
*/
|
||||
|
||||
/** Default keyboard shortcut bindings */
|
||||
export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
|
||||
board: "K",
|
||||
agent: "A",
|
||||
spec: "D",
|
||||
context: "C",
|
||||
settings: "S",
|
||||
profiles: "M",
|
||||
terminal: "T",
|
||||
toggleSidebar: "`",
|
||||
addFeature: "N",
|
||||
addContextFile: "N",
|
||||
startNext: "G",
|
||||
newSession: "N",
|
||||
openProject: "O",
|
||||
projectPicker: "P",
|
||||
cyclePrevProject: "Q",
|
||||
cycleNextProject: "E",
|
||||
addProfile: "N",
|
||||
splitTerminalRight: "Alt+D",
|
||||
splitTerminalDown: "Alt+S",
|
||||
closeTerminal: "Alt+W",
|
||||
};
|
||||
|
||||
/** Default global settings used when no settings file exists */
|
||||
export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
|
||||
version: 1,
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
chatHistoryOpen: false,
|
||||
kanbanCardDetailLevel: "standard",
|
||||
maxConcurrency: 3,
|
||||
defaultSkipTests: true,
|
||||
enableDependencyBlocking: true,
|
||||
useWorktrees: false,
|
||||
showProfilesOnly: false,
|
||||
defaultPlanningMode: "skip",
|
||||
defaultRequirePlanApproval: false,
|
||||
defaultAIProfileId: null,
|
||||
muteDoneSound: false,
|
||||
enhancementModel: "sonnet",
|
||||
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS,
|
||||
aiProfiles: [],
|
||||
projects: [],
|
||||
trashedProjects: [],
|
||||
projectHistory: [],
|
||||
projectHistoryIndex: -1,
|
||||
lastProjectDir: undefined,
|
||||
recentFolders: [],
|
||||
worktreePanelCollapsed: false,
|
||||
lastSelectedSessionByProject: {},
|
||||
};
|
||||
|
||||
/** Default credentials (empty strings - user must provide API keys) */
|
||||
export const DEFAULT_CREDENTIALS: Credentials = {
|
||||
version: 1,
|
||||
apiKeys: {
|
||||
anthropic: "",
|
||||
google: "",
|
||||
openai: "",
|
||||
},
|
||||
};
|
||||
|
||||
/** Default project settings (empty - all settings are optional and fall back to global) */
|
||||
export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = {
|
||||
version: 1,
|
||||
};
|
||||
|
||||
/** Current version of the global settings schema */
|
||||
export const SETTINGS_VERSION = 1;
|
||||
/** Current version of the credentials schema */
|
||||
export const CREDENTIALS_VERSION = 1;
|
||||
/** Current version of the project settings schema */
|
||||
export const PROJECT_SETTINGS_VERSION = 1;
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
getAppSpecPath,
|
||||
getBranchTrackingPath,
|
||||
ensureAutomakerDir,
|
||||
getGlobalSettingsPath,
|
||||
getCredentialsPath,
|
||||
getProjectSettingsPath,
|
||||
ensureDataDir,
|
||||
} from "@automaker/platform";
|
||||
|
||||
describe("automaker-paths.ts", () => {
|
||||
@@ -136,4 +140,91 @@ describe("automaker-paths.ts", () => {
|
||||
expect(result).toBe(automakerDir);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getGlobalSettingsPath", () => {
|
||||
it("should return path to settings.json in data directory", () => {
|
||||
const dataDir = "/test/data";
|
||||
const result = getGlobalSettingsPath(dataDir);
|
||||
expect(result).toBe(path.join(dataDir, "settings.json"));
|
||||
});
|
||||
|
||||
it("should handle paths with trailing slashes", () => {
|
||||
const dataDir = "/test/data" + path.sep;
|
||||
const result = getGlobalSettingsPath(dataDir);
|
||||
expect(result).toBe(path.join(dataDir, "settings.json"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCredentialsPath", () => {
|
||||
it("should return path to credentials.json in data directory", () => {
|
||||
const dataDir = "/test/data";
|
||||
const result = getCredentialsPath(dataDir);
|
||||
expect(result).toBe(path.join(dataDir, "credentials.json"));
|
||||
});
|
||||
|
||||
it("should handle paths with trailing slashes", () => {
|
||||
const dataDir = "/test/data" + path.sep;
|
||||
const result = getCredentialsPath(dataDir);
|
||||
expect(result).toBe(path.join(dataDir, "credentials.json"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("getProjectSettingsPath", () => {
|
||||
it("should return path to settings.json in project .automaker directory", () => {
|
||||
const projectPath = "/test/project";
|
||||
const result = getProjectSettingsPath(projectPath);
|
||||
expect(result).toBe(
|
||||
path.join(projectPath, ".automaker", "settings.json")
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle paths with trailing slashes", () => {
|
||||
const projectPath = "/test/project" + path.sep;
|
||||
const result = getProjectSettingsPath(projectPath);
|
||||
expect(result).toBe(
|
||||
path.join(projectPath, ".automaker", "settings.json")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureDataDir", () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = path.join(os.tmpdir(), `data-dir-test-${Date.now()}`);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
it("should create data directory and return path", async () => {
|
||||
const result = await ensureDataDir(testDir);
|
||||
|
||||
expect(result).toBe(testDir);
|
||||
const stats = await fs.stat(testDir);
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it("should succeed if directory already exists", async () => {
|
||||
await fs.mkdir(testDir, { recursive: true });
|
||||
|
||||
const result = await ensureDataDir(testDir);
|
||||
|
||||
expect(result).toBe(testDir);
|
||||
});
|
||||
|
||||
it("should create nested directories", async () => {
|
||||
const nestedDir = path.join(testDir, "nested", "deep");
|
||||
const result = await ensureDataDir(nestedDir);
|
||||
|
||||
expect(result).toBe(nestedDir);
|
||||
const stats = await fs.stat(nestedDir);
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
isAbortError,
|
||||
isAuthenticationError,
|
||||
isCancellationError,
|
||||
classifyError,
|
||||
getUserFriendlyErrorMessage,
|
||||
type ErrorType,
|
||||
@@ -32,6 +33,34 @@ describe("error-handler.ts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCancellationError", () => {
|
||||
it("should detect 'cancelled' message", () => {
|
||||
expect(isCancellationError("Operation was cancelled")).toBe(true);
|
||||
});
|
||||
|
||||
it("should detect 'canceled' message", () => {
|
||||
expect(isCancellationError("Request was canceled")).toBe(true);
|
||||
});
|
||||
|
||||
it("should detect 'stopped' message", () => {
|
||||
expect(isCancellationError("Process was stopped")).toBe(true);
|
||||
});
|
||||
|
||||
it("should detect 'aborted' message", () => {
|
||||
expect(isCancellationError("Task was aborted")).toBe(true);
|
||||
});
|
||||
|
||||
it("should be case insensitive", () => {
|
||||
expect(isCancellationError("CANCELLED")).toBe(true);
|
||||
expect(isCancellationError("Canceled")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for non-cancellation errors", () => {
|
||||
expect(isCancellationError("File not found")).toBe(false);
|
||||
expect(isCancellationError("Network error")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAuthenticationError", () => {
|
||||
it("should detect 'Authentication failed' message", () => {
|
||||
expect(isAuthenticationError("Authentication failed")).toBe(true);
|
||||
@@ -91,6 +120,42 @@ describe("error-handler.ts", () => {
|
||||
expect(result.isAbort).toBe(true); // Still detected as abort too
|
||||
});
|
||||
|
||||
it("should classify cancellation errors", () => {
|
||||
const error = new Error("Operation was cancelled");
|
||||
const result = classifyError(error);
|
||||
|
||||
expect(result.type).toBe("cancellation");
|
||||
expect(result.isCancellation).toBe(true);
|
||||
expect(result.isAbort).toBe(false);
|
||||
expect(result.isAuth).toBe(false);
|
||||
});
|
||||
|
||||
it("should prioritize abort over cancellation if both match", () => {
|
||||
const error = new Error("Operation aborted");
|
||||
error.name = "AbortError";
|
||||
const result = classifyError(error);
|
||||
|
||||
expect(result.type).toBe("abort");
|
||||
expect(result.isAbort).toBe(true);
|
||||
expect(result.isCancellation).toBe(true); // Still detected as cancellation too
|
||||
});
|
||||
|
||||
it("should classify cancellation errors with 'canceled' spelling", () => {
|
||||
const error = new Error("Request was canceled");
|
||||
const result = classifyError(error);
|
||||
|
||||
expect(result.type).toBe("cancellation");
|
||||
expect(result.isCancellation).toBe(true);
|
||||
});
|
||||
|
||||
it("should classify cancellation errors with 'stopped' message", () => {
|
||||
const error = new Error("Process was stopped");
|
||||
const result = classifyError(error);
|
||||
|
||||
expect(result.type).toBe("cancellation");
|
||||
expect(result.isCancellation).toBe(true);
|
||||
});
|
||||
|
||||
it("should classify generic Error as execution error", () => {
|
||||
const error = new Error("Something went wrong");
|
||||
const result = classifyError(error);
|
||||
|
||||
@@ -65,6 +65,47 @@ describe("fs-utils.ts", () => {
|
||||
// Should not throw
|
||||
await expect(mkdirSafe(symlinkPath)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle ELOOP error gracefully when checking path", async () => {
|
||||
// Mock lstat to throw ELOOP error
|
||||
const originalLstat = fs.lstat;
|
||||
const mkdirSafePath = path.join(testDir, "eloop-path");
|
||||
|
||||
vi.spyOn(fs, "lstat").mockRejectedValueOnce({ code: "ELOOP" });
|
||||
|
||||
// Should not throw, should return gracefully
|
||||
await expect(mkdirSafe(mkdirSafePath)).resolves.toBeUndefined();
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should handle EEXIST error gracefully when creating directory", async () => {
|
||||
const newDir = path.join(testDir, "race-condition-dir");
|
||||
|
||||
// Mock lstat to return ENOENT (path doesn't exist)
|
||||
// Then mock mkdir to throw EEXIST (race condition)
|
||||
vi.spyOn(fs, "lstat").mockRejectedValueOnce({ code: "ENOENT" });
|
||||
vi.spyOn(fs, "mkdir").mockRejectedValueOnce({ code: "EEXIST" });
|
||||
|
||||
// Should not throw, should return gracefully
|
||||
await expect(mkdirSafe(newDir)).resolves.toBeUndefined();
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should handle ELOOP error gracefully when creating directory", async () => {
|
||||
const newDir = path.join(testDir, "eloop-create-dir");
|
||||
|
||||
// Mock lstat to return ENOENT (path doesn't exist)
|
||||
// Then mock mkdir to throw ELOOP
|
||||
vi.spyOn(fs, "lstat").mockRejectedValueOnce({ code: "ENOENT" });
|
||||
vi.spyOn(fs, "mkdir").mockRejectedValueOnce({ code: "ELOOP" });
|
||||
|
||||
// Should not throw, should return gracefully
|
||||
await expect(mkdirSafe(newDir)).resolves.toBeUndefined();
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
describe("existsSafe", () => {
|
||||
@@ -109,5 +150,24 @@ describe("fs-utils.ts", () => {
|
||||
const exists = await existsSafe(symlinkPath);
|
||||
expect(exists).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for ELOOP error (symlink loop)", async () => {
|
||||
// Mock lstat to throw ELOOP error
|
||||
vi.spyOn(fs, "lstat").mockRejectedValueOnce({ code: "ELOOP" });
|
||||
|
||||
const exists = await existsSafe("/some/path/with/loop");
|
||||
expect(exists).toBe(true);
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should throw for other errors", async () => {
|
||||
// Mock lstat to throw a non-ENOENT, non-ELOOP error
|
||||
vi.spyOn(fs, "lstat").mockRejectedValueOnce({ code: "EACCES" });
|
||||
|
||||
await expect(existsSafe("/some/path")).rejects.toMatchObject({ code: "EACCES" });
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -144,6 +144,40 @@ describe("sdk-options.ts", () => {
|
||||
expect(options.maxTurns).toBe(MAX_TURNS.extended);
|
||||
expect(options.allowedTools).toEqual([...TOOL_PRESETS.readOnly]);
|
||||
});
|
||||
|
||||
it("should include systemPrompt when provided", async () => {
|
||||
const { createSuggestionsOptions } = await import("@/lib/sdk-options.js");
|
||||
|
||||
const options = createSuggestionsOptions({
|
||||
cwd: "/test/path",
|
||||
systemPrompt: "Custom prompt",
|
||||
});
|
||||
|
||||
expect(options.systemPrompt).toBe("Custom prompt");
|
||||
});
|
||||
|
||||
it("should include abortController when provided", async () => {
|
||||
const { createSuggestionsOptions } = await import("@/lib/sdk-options.js");
|
||||
|
||||
const abortController = new AbortController();
|
||||
const options = createSuggestionsOptions({
|
||||
cwd: "/test/path",
|
||||
abortController,
|
||||
});
|
||||
|
||||
expect(options.abortController).toBe(abortController);
|
||||
});
|
||||
|
||||
it("should include outputFormat when provided", async () => {
|
||||
const { createSuggestionsOptions } = await import("@/lib/sdk-options.js");
|
||||
|
||||
const options = createSuggestionsOptions({
|
||||
cwd: "/test/path",
|
||||
outputFormat: { type: "json" },
|
||||
});
|
||||
|
||||
expect(options.outputFormat).toEqual({ type: "json" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("createChatOptions", () => {
|
||||
@@ -205,6 +239,29 @@ describe("sdk-options.ts", () => {
|
||||
autoAllowBashIfSandboxed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should include systemPrompt when provided", async () => {
|
||||
const { createAutoModeOptions } = await import("@/lib/sdk-options.js");
|
||||
|
||||
const options = createAutoModeOptions({
|
||||
cwd: "/test/path",
|
||||
systemPrompt: "Custom prompt",
|
||||
});
|
||||
|
||||
expect(options.systemPrompt).toBe("Custom prompt");
|
||||
});
|
||||
|
||||
it("should include abortController when provided", async () => {
|
||||
const { createAutoModeOptions } = await import("@/lib/sdk-options.js");
|
||||
|
||||
const abortController = new AbortController();
|
||||
const options = createAutoModeOptions({
|
||||
cwd: "/test/path",
|
||||
abortController,
|
||||
});
|
||||
|
||||
expect(options.abortController).toBe(abortController);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createCustomOptions", () => {
|
||||
@@ -234,5 +291,42 @@ describe("sdk-options.ts", () => {
|
||||
expect(options.maxTurns).toBe(MAX_TURNS.maximum);
|
||||
expect(options.allowedTools).toEqual([...TOOL_PRESETS.readOnly]);
|
||||
});
|
||||
|
||||
it("should include sandbox when provided", async () => {
|
||||
const { createCustomOptions } = await import("@/lib/sdk-options.js");
|
||||
|
||||
const options = createCustomOptions({
|
||||
cwd: "/test/path",
|
||||
sandbox: { enabled: true, autoAllowBashIfSandboxed: false },
|
||||
});
|
||||
|
||||
expect(options.sandbox).toEqual({
|
||||
enabled: true,
|
||||
autoAllowBashIfSandboxed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should include systemPrompt when provided", async () => {
|
||||
const { createCustomOptions } = await import("@/lib/sdk-options.js");
|
||||
|
||||
const options = createCustomOptions({
|
||||
cwd: "/test/path",
|
||||
systemPrompt: "Custom prompt",
|
||||
});
|
||||
|
||||
expect(options.systemPrompt).toBe("Custom prompt");
|
||||
});
|
||||
|
||||
it("should include abortController when provided", async () => {
|
||||
const { createCustomOptions } = await import("@/lib/sdk-options.js");
|
||||
|
||||
const abortController = new AbortController();
|
||||
const options = createCustomOptions({
|
||||
cwd: "/test/path",
|
||||
abortController,
|
||||
});
|
||||
|
||||
expect(options.abortController).toBe(abortController);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,9 +53,24 @@ describe("security.ts", () => {
|
||||
expect(allowed).toContain(path.resolve("/data/dir"));
|
||||
});
|
||||
|
||||
it("should include WORKSPACE_DIR if set", async () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = "";
|
||||
process.env.DATA_DIR = "";
|
||||
process.env.WORKSPACE_DIR = "/workspace/dir";
|
||||
|
||||
const { initAllowedPaths, getAllowedPaths } = await import(
|
||||
"@/lib/security.js"
|
||||
);
|
||||
initAllowedPaths();
|
||||
|
||||
const allowed = getAllowedPaths();
|
||||
expect(allowed).toContain(path.resolve("/workspace/dir"));
|
||||
});
|
||||
|
||||
it("should handle empty ALLOWED_PROJECT_DIRS", async () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = "";
|
||||
process.env.DATA_DIR = "/data";
|
||||
delete process.env.WORKSPACE_DIR;
|
||||
|
||||
const { initAllowedPaths, getAllowedPaths } = await import(
|
||||
"@automaker/platform"
|
||||
@@ -70,6 +85,7 @@ describe("security.ts", () => {
|
||||
it("should skip empty entries in comma list", async () => {
|
||||
process.env.ALLOWED_PROJECT_DIRS = "/path1,,/path2, ,/path3";
|
||||
process.env.DATA_DIR = "";
|
||||
delete process.env.WORKSPACE_DIR;
|
||||
|
||||
const { initAllowedPaths, getAllowedPaths } = await import(
|
||||
"@automaker/platform"
|
||||
|
||||
394
apps/server/tests/unit/lib/worktree-metadata.test.ts
Normal file
394
apps/server/tests/unit/lib/worktree-metadata.test.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
readWorktreeMetadata,
|
||||
writeWorktreeMetadata,
|
||||
updateWorktreePRInfo,
|
||||
getWorktreePRInfo,
|
||||
readAllWorktreeMetadata,
|
||||
deleteWorktreeMetadata,
|
||||
type WorktreeMetadata,
|
||||
type WorktreePRInfo,
|
||||
} from "@/lib/worktree-metadata.js";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
|
||||
describe("worktree-metadata.ts", () => {
|
||||
let testProjectPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testProjectPath = path.join(os.tmpdir(), `worktree-metadata-test-${Date.now()}`);
|
||||
await fs.mkdir(testProjectPath, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await fs.rm(testProjectPath, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
describe("sanitizeBranchName", () => {
|
||||
// Test through readWorktreeMetadata and writeWorktreeMetadata
|
||||
it("should sanitize branch names with invalid characters", async () => {
|
||||
const branch = "feature/test-branch";
|
||||
const metadata: WorktreeMetadata = {
|
||||
branch,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await writeWorktreeMetadata(testProjectPath, branch, metadata);
|
||||
const result = await readWorktreeMetadata(testProjectPath, branch);
|
||||
expect(result).toEqual(metadata);
|
||||
});
|
||||
|
||||
it("should sanitize branch names with Windows invalid characters", async () => {
|
||||
const branch = "feature:test*branch?";
|
||||
const metadata: WorktreeMetadata = {
|
||||
branch,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await writeWorktreeMetadata(testProjectPath, branch, metadata);
|
||||
const result = await readWorktreeMetadata(testProjectPath, branch);
|
||||
expect(result).toEqual(metadata);
|
||||
});
|
||||
|
||||
it("should sanitize Windows reserved names", async () => {
|
||||
const branch = "CON";
|
||||
const metadata: WorktreeMetadata = {
|
||||
branch,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await writeWorktreeMetadata(testProjectPath, branch, metadata);
|
||||
const result = await readWorktreeMetadata(testProjectPath, branch);
|
||||
expect(result).toEqual(metadata);
|
||||
});
|
||||
|
||||
it("should handle empty branch name", async () => {
|
||||
const branch = "";
|
||||
const metadata: WorktreeMetadata = {
|
||||
branch: "branch",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Empty branch name should be sanitized to "_branch"
|
||||
await writeWorktreeMetadata(testProjectPath, branch, metadata);
|
||||
const result = await readWorktreeMetadata(testProjectPath, branch);
|
||||
expect(result).toEqual(metadata);
|
||||
});
|
||||
|
||||
it("should handle branch name that becomes empty after sanitization", async () => {
|
||||
// Test branch that would become empty after removing invalid chars
|
||||
const branch = "///";
|
||||
const metadata: WorktreeMetadata = {
|
||||
branch: "branch",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await writeWorktreeMetadata(testProjectPath, branch, metadata);
|
||||
const result = await readWorktreeMetadata(testProjectPath, branch);
|
||||
expect(result).toEqual(metadata);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readWorktreeMetadata", () => {
|
||||
it("should return null when metadata file doesn't exist", async () => {
|
||||
const result = await readWorktreeMetadata(testProjectPath, "nonexistent-branch");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should read existing metadata", async () => {
|
||||
const branch = "test-branch";
|
||||
const metadata: WorktreeMetadata = {
|
||||
branch,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await writeWorktreeMetadata(testProjectPath, branch, metadata);
|
||||
const result = await readWorktreeMetadata(testProjectPath, branch);
|
||||
expect(result).toEqual(metadata);
|
||||
});
|
||||
|
||||
it("should read metadata with PR info", async () => {
|
||||
const branch = "pr-branch";
|
||||
const metadata: WorktreeMetadata = {
|
||||
branch,
|
||||
createdAt: new Date().toISOString(),
|
||||
pr: {
|
||||
number: 123,
|
||||
url: "https://github.com/owner/repo/pull/123",
|
||||
title: "Test PR",
|
||||
state: "open",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
await writeWorktreeMetadata(testProjectPath, branch, metadata);
|
||||
const result = await readWorktreeMetadata(testProjectPath, branch);
|
||||
expect(result).toEqual(metadata);
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeWorktreeMetadata", () => {
|
||||
it("should create metadata directory if it doesn't exist", async () => {
|
||||
const branch = "new-branch";
|
||||
const metadata: WorktreeMetadata = {
|
||||
branch,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await writeWorktreeMetadata(testProjectPath, branch, metadata);
|
||||
const result = await readWorktreeMetadata(testProjectPath, branch);
|
||||
expect(result).toEqual(metadata);
|
||||
});
|
||||
|
||||
it("should overwrite existing metadata", async () => {
|
||||
const branch = "existing-branch";
|
||||
const metadata1: WorktreeMetadata = {
|
||||
branch,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
const metadata2: WorktreeMetadata = {
|
||||
branch,
|
||||
createdAt: new Date().toISOString(),
|
||||
pr: {
|
||||
number: 456,
|
||||
url: "https://github.com/owner/repo/pull/456",
|
||||
title: "Updated PR",
|
||||
state: "closed",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
await writeWorktreeMetadata(testProjectPath, branch, metadata1);
|
||||
await writeWorktreeMetadata(testProjectPath, branch, metadata2);
|
||||
const result = await readWorktreeMetadata(testProjectPath, branch);
|
||||
expect(result).toEqual(metadata2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateWorktreePRInfo", () => {
|
||||
it("should create new metadata if it doesn't exist", async () => {
|
||||
const branch = "new-pr-branch";
|
||||
const prInfo: WorktreePRInfo = {
|
||||
number: 789,
|
||||
url: "https://github.com/owner/repo/pull/789",
|
||||
title: "New PR",
|
||||
state: "open",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await updateWorktreePRInfo(testProjectPath, branch, prInfo);
|
||||
const result = await readWorktreeMetadata(testProjectPath, branch);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.branch).toBe(branch);
|
||||
expect(result?.pr).toEqual(prInfo);
|
||||
});
|
||||
|
||||
it("should update existing metadata with PR info", async () => {
|
||||
const branch = "existing-pr-branch";
|
||||
const metadata: WorktreeMetadata = {
|
||||
branch,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await writeWorktreeMetadata(testProjectPath, branch, metadata);
|
||||
|
||||
const prInfo: WorktreePRInfo = {
|
||||
number: 999,
|
||||
url: "https://github.com/owner/repo/pull/999",
|
||||
title: "Updated PR",
|
||||
state: "merged",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await updateWorktreePRInfo(testProjectPath, branch, prInfo);
|
||||
const result = await readWorktreeMetadata(testProjectPath, branch);
|
||||
expect(result?.pr).toEqual(prInfo);
|
||||
});
|
||||
|
||||
it("should preserve existing metadata when updating PR info", async () => {
|
||||
const branch = "preserve-branch";
|
||||
const originalCreatedAt = new Date().toISOString();
|
||||
const metadata: WorktreeMetadata = {
|
||||
branch,
|
||||
createdAt: originalCreatedAt,
|
||||
};
|
||||
|
||||
await writeWorktreeMetadata(testProjectPath, branch, metadata);
|
||||
|
||||
const prInfo: WorktreePRInfo = {
|
||||
number: 111,
|
||||
url: "https://github.com/owner/repo/pull/111",
|
||||
title: "PR",
|
||||
state: "open",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await updateWorktreePRInfo(testProjectPath, branch, prInfo);
|
||||
const result = await readWorktreeMetadata(testProjectPath, branch);
|
||||
expect(result?.createdAt).toBe(originalCreatedAt);
|
||||
expect(result?.pr).toEqual(prInfo);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getWorktreePRInfo", () => {
|
||||
it("should return null when metadata doesn't exist", async () => {
|
||||
const result = await getWorktreePRInfo(testProjectPath, "nonexistent");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when metadata exists but has no PR info", async () => {
|
||||
const branch = "no-pr-branch";
|
||||
const metadata: WorktreeMetadata = {
|
||||
branch,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await writeWorktreeMetadata(testProjectPath, branch, metadata);
|
||||
const result = await getWorktreePRInfo(testProjectPath, branch);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return PR info when it exists", async () => {
|
||||
const branch = "has-pr-branch";
|
||||
const prInfo: WorktreePRInfo = {
|
||||
number: 222,
|
||||
url: "https://github.com/owner/repo/pull/222",
|
||||
title: "Has PR",
|
||||
state: "open",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await updateWorktreePRInfo(testProjectPath, branch, prInfo);
|
||||
const result = await getWorktreePRInfo(testProjectPath, branch);
|
||||
expect(result).toEqual(prInfo);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readAllWorktreeMetadata", () => {
|
||||
it("should return empty map when worktrees directory doesn't exist", async () => {
|
||||
const result = await readAllWorktreeMetadata(testProjectPath);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should return empty map when worktrees directory is empty", async () => {
|
||||
const worktreesDir = path.join(testProjectPath, ".automaker", "worktrees");
|
||||
await fs.mkdir(worktreesDir, { recursive: true });
|
||||
|
||||
const result = await readAllWorktreeMetadata(testProjectPath);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should read all worktree metadata", async () => {
|
||||
const branch1 = "branch-1";
|
||||
const branch2 = "branch-2";
|
||||
const metadata1: WorktreeMetadata = {
|
||||
branch: branch1,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
const metadata2: WorktreeMetadata = {
|
||||
branch: branch2,
|
||||
createdAt: new Date().toISOString(),
|
||||
pr: {
|
||||
number: 333,
|
||||
url: "https://github.com/owner/repo/pull/333",
|
||||
title: "PR 3",
|
||||
state: "open",
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
await writeWorktreeMetadata(testProjectPath, branch1, metadata1);
|
||||
await writeWorktreeMetadata(testProjectPath, branch2, metadata2);
|
||||
|
||||
const result = await readAllWorktreeMetadata(testProjectPath);
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.get(branch1)).toEqual(metadata1);
|
||||
expect(result.get(branch2)).toEqual(metadata2);
|
||||
});
|
||||
|
||||
it("should skip directories without worktree.json", async () => {
|
||||
const worktreesDir = path.join(testProjectPath, ".automaker", "worktrees");
|
||||
const emptyDir = path.join(worktreesDir, "empty-dir");
|
||||
await fs.mkdir(emptyDir, { recursive: true });
|
||||
|
||||
const branch = "valid-branch";
|
||||
const metadata: WorktreeMetadata = {
|
||||
branch,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
await writeWorktreeMetadata(testProjectPath, branch, metadata);
|
||||
|
||||
const result = await readAllWorktreeMetadata(testProjectPath);
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get(branch)).toEqual(metadata);
|
||||
});
|
||||
|
||||
it("should skip files in worktrees directory", async () => {
|
||||
const worktreesDir = path.join(testProjectPath, ".automaker", "worktrees");
|
||||
await fs.mkdir(worktreesDir, { recursive: true });
|
||||
const filePath = path.join(worktreesDir, "not-a-dir.txt");
|
||||
await fs.writeFile(filePath, "content");
|
||||
|
||||
const branch = "valid-branch";
|
||||
const metadata: WorktreeMetadata = {
|
||||
branch,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
await writeWorktreeMetadata(testProjectPath, branch, metadata);
|
||||
|
||||
const result = await readAllWorktreeMetadata(testProjectPath);
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get(branch)).toEqual(metadata);
|
||||
});
|
||||
|
||||
it("should skip directories with malformed JSON", async () => {
|
||||
const worktreesDir = path.join(testProjectPath, ".automaker", "worktrees");
|
||||
const badDir = path.join(worktreesDir, "bad-dir");
|
||||
await fs.mkdir(badDir, { recursive: true });
|
||||
const badJsonPath = path.join(badDir, "worktree.json");
|
||||
await fs.writeFile(badJsonPath, "not valid json");
|
||||
|
||||
const branch = "valid-branch";
|
||||
const metadata: WorktreeMetadata = {
|
||||
branch,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
await writeWorktreeMetadata(testProjectPath, branch, metadata);
|
||||
|
||||
const result = await readAllWorktreeMetadata(testProjectPath);
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get(branch)).toEqual(metadata);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteWorktreeMetadata", () => {
|
||||
it("should delete worktree metadata directory", async () => {
|
||||
const branch = "to-delete";
|
||||
const metadata: WorktreeMetadata = {
|
||||
branch,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await writeWorktreeMetadata(testProjectPath, branch, metadata);
|
||||
let result = await readWorktreeMetadata(testProjectPath, branch);
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
await deleteWorktreeMetadata(testProjectPath, branch);
|
||||
result = await readWorktreeMetadata(testProjectPath, branch);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle deletion when metadata doesn't exist", async () => {
|
||||
// Should not throw
|
||||
await expect(
|
||||
deleteWorktreeMetadata(testProjectPath, "nonexistent")
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -234,6 +234,30 @@ describe("claude-provider.ts", () => {
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle errors during execution and rethrow", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const testError = new Error("SDK execution failed");
|
||||
|
||||
vi.mocked(sdk.query).mockReturnValue(
|
||||
(async function* () {
|
||||
throw testError;
|
||||
})()
|
||||
);
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: "Test",
|
||||
cwd: "/test",
|
||||
});
|
||||
|
||||
await expect(collectAsyncGenerator(generator)).rejects.toThrow("SDK execution failed");
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"[ClaudeProvider] executeQuery() error during execution:",
|
||||
testError
|
||||
);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectInstallation", () => {
|
||||
|
||||
643
apps/server/tests/unit/services/settings-service.test.ts
Normal file
643
apps/server/tests/unit/services/settings-service.test.ts
Normal file
@@ -0,0 +1,643 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import { SettingsService } from "@/services/settings-service.js";
|
||||
import {
|
||||
DEFAULT_GLOBAL_SETTINGS,
|
||||
DEFAULT_CREDENTIALS,
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
SETTINGS_VERSION,
|
||||
CREDENTIALS_VERSION,
|
||||
PROJECT_SETTINGS_VERSION,
|
||||
type GlobalSettings,
|
||||
type Credentials,
|
||||
type ProjectSettings,
|
||||
} from "@/types/settings.js";
|
||||
|
||||
describe("settings-service.ts", () => {
|
||||
let testDataDir: string;
|
||||
let testProjectDir: string;
|
||||
let settingsService: SettingsService;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDataDir = path.join(os.tmpdir(), `settings-test-${Date.now()}`);
|
||||
testProjectDir = path.join(os.tmpdir(), `project-test-${Date.now()}`);
|
||||
await fs.mkdir(testDataDir, { recursive: true });
|
||||
await fs.mkdir(testProjectDir, { recursive: true });
|
||||
settingsService = new SettingsService(testDataDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await fs.rm(testDataDir, { recursive: true, force: true });
|
||||
await fs.rm(testProjectDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
describe("getGlobalSettings", () => {
|
||||
it("should return default settings when file does not exist", async () => {
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
expect(settings).toEqual(DEFAULT_GLOBAL_SETTINGS);
|
||||
});
|
||||
|
||||
it("should read and return existing settings", async () => {
|
||||
const customSettings: GlobalSettings = {
|
||||
...DEFAULT_GLOBAL_SETTINGS,
|
||||
theme: "light",
|
||||
sidebarOpen: false,
|
||||
maxConcurrency: 5,
|
||||
};
|
||||
const settingsPath = path.join(testDataDir, "settings.json");
|
||||
await fs.writeFile(settingsPath, JSON.stringify(customSettings, null, 2));
|
||||
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
expect(settings.theme).toBe("light");
|
||||
expect(settings.sidebarOpen).toBe(false);
|
||||
expect(settings.maxConcurrency).toBe(5);
|
||||
});
|
||||
|
||||
it("should merge with defaults for missing properties", async () => {
|
||||
const partialSettings = {
|
||||
version: SETTINGS_VERSION,
|
||||
theme: "dark",
|
||||
};
|
||||
const settingsPath = path.join(testDataDir, "settings.json");
|
||||
await fs.writeFile(settingsPath, JSON.stringify(partialSettings, null, 2));
|
||||
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
expect(settings.theme).toBe("dark");
|
||||
expect(settings.sidebarOpen).toBe(DEFAULT_GLOBAL_SETTINGS.sidebarOpen);
|
||||
expect(settings.maxConcurrency).toBe(DEFAULT_GLOBAL_SETTINGS.maxConcurrency);
|
||||
});
|
||||
|
||||
it("should merge keyboard shortcuts deeply", async () => {
|
||||
const customSettings: GlobalSettings = {
|
||||
...DEFAULT_GLOBAL_SETTINGS,
|
||||
keyboardShortcuts: {
|
||||
...DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
|
||||
board: "B",
|
||||
},
|
||||
};
|
||||
const settingsPath = path.join(testDataDir, "settings.json");
|
||||
await fs.writeFile(settingsPath, JSON.stringify(customSettings, null, 2));
|
||||
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
expect(settings.keyboardShortcuts.board).toBe("B");
|
||||
expect(settings.keyboardShortcuts.agent).toBe(
|
||||
DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts.agent
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateGlobalSettings", () => {
|
||||
it("should create settings file with updates", async () => {
|
||||
const updates: Partial<GlobalSettings> = {
|
||||
theme: "light",
|
||||
sidebarOpen: false,
|
||||
};
|
||||
|
||||
const updated = await settingsService.updateGlobalSettings(updates);
|
||||
|
||||
expect(updated.theme).toBe("light");
|
||||
expect(updated.sidebarOpen).toBe(false);
|
||||
expect(updated.version).toBe(SETTINGS_VERSION);
|
||||
|
||||
const settingsPath = path.join(testDataDir, "settings.json");
|
||||
const fileContent = await fs.readFile(settingsPath, "utf-8");
|
||||
const saved = JSON.parse(fileContent);
|
||||
expect(saved.theme).toBe("light");
|
||||
expect(saved.sidebarOpen).toBe(false);
|
||||
});
|
||||
|
||||
it("should merge updates with existing settings", async () => {
|
||||
const initial: GlobalSettings = {
|
||||
...DEFAULT_GLOBAL_SETTINGS,
|
||||
theme: "dark",
|
||||
maxConcurrency: 3,
|
||||
};
|
||||
const settingsPath = path.join(testDataDir, "settings.json");
|
||||
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
|
||||
|
||||
const updates: Partial<GlobalSettings> = {
|
||||
theme: "light",
|
||||
};
|
||||
|
||||
const updated = await settingsService.updateGlobalSettings(updates);
|
||||
|
||||
expect(updated.theme).toBe("light");
|
||||
expect(updated.maxConcurrency).toBe(3); // Preserved from initial
|
||||
});
|
||||
|
||||
it("should deep merge keyboard shortcuts", async () => {
|
||||
const updates: Partial<GlobalSettings> = {
|
||||
keyboardShortcuts: {
|
||||
board: "B",
|
||||
},
|
||||
};
|
||||
|
||||
const updated = await settingsService.updateGlobalSettings(updates);
|
||||
|
||||
expect(updated.keyboardShortcuts.board).toBe("B");
|
||||
expect(updated.keyboardShortcuts.agent).toBe(
|
||||
DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts.agent
|
||||
);
|
||||
});
|
||||
|
||||
it("should create data directory if it does not exist", async () => {
|
||||
const newDataDir = path.join(os.tmpdir(), `new-data-dir-${Date.now()}`);
|
||||
const newService = new SettingsService(newDataDir);
|
||||
|
||||
await newService.updateGlobalSettings({ theme: "light" });
|
||||
|
||||
const stats = await fs.stat(newDataDir);
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
|
||||
await fs.rm(newDataDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasGlobalSettings", () => {
|
||||
it("should return false when settings file does not exist", async () => {
|
||||
const exists = await settingsService.hasGlobalSettings();
|
||||
expect(exists).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when settings file exists", async () => {
|
||||
await settingsService.updateGlobalSettings({ theme: "light" });
|
||||
const exists = await settingsService.hasGlobalSettings();
|
||||
expect(exists).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCredentials", () => {
|
||||
it("should return default credentials when file does not exist", async () => {
|
||||
const credentials = await settingsService.getCredentials();
|
||||
expect(credentials).toEqual(DEFAULT_CREDENTIALS);
|
||||
});
|
||||
|
||||
it("should read and return existing credentials", async () => {
|
||||
const customCredentials: Credentials = {
|
||||
...DEFAULT_CREDENTIALS,
|
||||
apiKeys: {
|
||||
anthropic: "sk-test-key",
|
||||
google: "",
|
||||
openai: "",
|
||||
},
|
||||
};
|
||||
const credentialsPath = path.join(testDataDir, "credentials.json");
|
||||
await fs.writeFile(credentialsPath, JSON.stringify(customCredentials, null, 2));
|
||||
|
||||
const credentials = await settingsService.getCredentials();
|
||||
expect(credentials.apiKeys.anthropic).toBe("sk-test-key");
|
||||
});
|
||||
|
||||
it("should merge with defaults for missing api keys", async () => {
|
||||
const partialCredentials = {
|
||||
version: CREDENTIALS_VERSION,
|
||||
apiKeys: {
|
||||
anthropic: "sk-test",
|
||||
},
|
||||
};
|
||||
const credentialsPath = path.join(testDataDir, "credentials.json");
|
||||
await fs.writeFile(credentialsPath, JSON.stringify(partialCredentials, null, 2));
|
||||
|
||||
const credentials = await settingsService.getCredentials();
|
||||
expect(credentials.apiKeys.anthropic).toBe("sk-test");
|
||||
expect(credentials.apiKeys.google).toBe("");
|
||||
expect(credentials.apiKeys.openai).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateCredentials", () => {
|
||||
it("should create credentials file with updates", async () => {
|
||||
const updates: Partial<Credentials> = {
|
||||
apiKeys: {
|
||||
anthropic: "sk-test-key",
|
||||
google: "",
|
||||
openai: "",
|
||||
},
|
||||
};
|
||||
|
||||
const updated = await settingsService.updateCredentials(updates);
|
||||
|
||||
expect(updated.apiKeys.anthropic).toBe("sk-test-key");
|
||||
expect(updated.version).toBe(CREDENTIALS_VERSION);
|
||||
|
||||
const credentialsPath = path.join(testDataDir, "credentials.json");
|
||||
const fileContent = await fs.readFile(credentialsPath, "utf-8");
|
||||
const saved = JSON.parse(fileContent);
|
||||
expect(saved.apiKeys.anthropic).toBe("sk-test-key");
|
||||
});
|
||||
|
||||
it("should merge updates with existing credentials", async () => {
|
||||
const initial: Credentials = {
|
||||
...DEFAULT_CREDENTIALS,
|
||||
apiKeys: {
|
||||
anthropic: "sk-initial",
|
||||
google: "google-key",
|
||||
openai: "",
|
||||
},
|
||||
};
|
||||
const credentialsPath = path.join(testDataDir, "credentials.json");
|
||||
await fs.writeFile(credentialsPath, JSON.stringify(initial, null, 2));
|
||||
|
||||
const updates: Partial<Credentials> = {
|
||||
apiKeys: {
|
||||
anthropic: "sk-updated",
|
||||
},
|
||||
};
|
||||
|
||||
const updated = await settingsService.updateCredentials(updates);
|
||||
|
||||
expect(updated.apiKeys.anthropic).toBe("sk-updated");
|
||||
expect(updated.apiKeys.google).toBe("google-key"); // Preserved
|
||||
});
|
||||
|
||||
it("should deep merge api keys", async () => {
|
||||
const initial: Credentials = {
|
||||
...DEFAULT_CREDENTIALS,
|
||||
apiKeys: {
|
||||
anthropic: "sk-anthropic",
|
||||
google: "google-key",
|
||||
openai: "openai-key",
|
||||
},
|
||||
};
|
||||
const credentialsPath = path.join(testDataDir, "credentials.json");
|
||||
await fs.writeFile(credentialsPath, JSON.stringify(initial, null, 2));
|
||||
|
||||
const updates: Partial<Credentials> = {
|
||||
apiKeys: {
|
||||
openai: "new-openai-key",
|
||||
},
|
||||
};
|
||||
|
||||
const updated = await settingsService.updateCredentials(updates);
|
||||
|
||||
expect(updated.apiKeys.anthropic).toBe("sk-anthropic");
|
||||
expect(updated.apiKeys.google).toBe("google-key");
|
||||
expect(updated.apiKeys.openai).toBe("new-openai-key");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMaskedCredentials", () => {
|
||||
it("should return masked credentials for empty keys", async () => {
|
||||
const masked = await settingsService.getMaskedCredentials();
|
||||
expect(masked.anthropic.configured).toBe(false);
|
||||
expect(masked.anthropic.masked).toBe("");
|
||||
expect(masked.google.configured).toBe(false);
|
||||
expect(masked.openai.configured).toBe(false);
|
||||
});
|
||||
|
||||
it("should mask keys correctly", async () => {
|
||||
await settingsService.updateCredentials({
|
||||
apiKeys: {
|
||||
anthropic: "sk-ant-api03-1234567890abcdef",
|
||||
google: "AIzaSy1234567890abcdef",
|
||||
openai: "sk-1234567890abcdef",
|
||||
},
|
||||
});
|
||||
|
||||
const masked = await settingsService.getMaskedCredentials();
|
||||
expect(masked.anthropic.configured).toBe(true);
|
||||
expect(masked.anthropic.masked).toBe("sk-a...cdef");
|
||||
expect(masked.google.configured).toBe(true);
|
||||
expect(masked.google.masked).toBe("AIza...cdef");
|
||||
expect(masked.openai.configured).toBe(true);
|
||||
expect(masked.openai.masked).toBe("sk-1...cdef");
|
||||
});
|
||||
|
||||
it("should handle short keys", async () => {
|
||||
await settingsService.updateCredentials({
|
||||
apiKeys: {
|
||||
anthropic: "short",
|
||||
google: "",
|
||||
openai: "",
|
||||
},
|
||||
});
|
||||
|
||||
const masked = await settingsService.getMaskedCredentials();
|
||||
expect(masked.anthropic.configured).toBe(true);
|
||||
expect(masked.anthropic.masked).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasCredentials", () => {
|
||||
it("should return false when credentials file does not exist", async () => {
|
||||
const exists = await settingsService.hasCredentials();
|
||||
expect(exists).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when credentials file exists", async () => {
|
||||
await settingsService.updateCredentials({
|
||||
apiKeys: { anthropic: "test", google: "", openai: "" },
|
||||
});
|
||||
const exists = await settingsService.hasCredentials();
|
||||
expect(exists).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getProjectSettings", () => {
|
||||
it("should return default settings when file does not exist", async () => {
|
||||
const settings = await settingsService.getProjectSettings(testProjectDir);
|
||||
expect(settings).toEqual(DEFAULT_PROJECT_SETTINGS);
|
||||
});
|
||||
|
||||
it("should read and return existing project settings", async () => {
|
||||
const customSettings: ProjectSettings = {
|
||||
...DEFAULT_PROJECT_SETTINGS,
|
||||
theme: "light",
|
||||
useWorktrees: true,
|
||||
};
|
||||
const automakerDir = path.join(testProjectDir, ".automaker");
|
||||
await fs.mkdir(automakerDir, { recursive: true });
|
||||
const settingsPath = path.join(automakerDir, "settings.json");
|
||||
await fs.writeFile(settingsPath, JSON.stringify(customSettings, null, 2));
|
||||
|
||||
const settings = await settingsService.getProjectSettings(testProjectDir);
|
||||
expect(settings.theme).toBe("light");
|
||||
expect(settings.useWorktrees).toBe(true);
|
||||
});
|
||||
|
||||
it("should merge with defaults for missing properties", async () => {
|
||||
const partialSettings = {
|
||||
version: PROJECT_SETTINGS_VERSION,
|
||||
theme: "dark",
|
||||
};
|
||||
const automakerDir = path.join(testProjectDir, ".automaker");
|
||||
await fs.mkdir(automakerDir, { recursive: true });
|
||||
const settingsPath = path.join(automakerDir, "settings.json");
|
||||
await fs.writeFile(settingsPath, JSON.stringify(partialSettings, null, 2));
|
||||
|
||||
const settings = await settingsService.getProjectSettings(testProjectDir);
|
||||
expect(settings.theme).toBe("dark");
|
||||
expect(settings.version).toBe(PROJECT_SETTINGS_VERSION);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateProjectSettings", () => {
|
||||
it("should create project settings file with updates", async () => {
|
||||
const updates: Partial<ProjectSettings> = {
|
||||
theme: "light",
|
||||
useWorktrees: true,
|
||||
};
|
||||
|
||||
const updated = await settingsService.updateProjectSettings(testProjectDir, updates);
|
||||
|
||||
expect(updated.theme).toBe("light");
|
||||
expect(updated.useWorktrees).toBe(true);
|
||||
expect(updated.version).toBe(PROJECT_SETTINGS_VERSION);
|
||||
|
||||
const automakerDir = path.join(testProjectDir, ".automaker");
|
||||
const settingsPath = path.join(automakerDir, "settings.json");
|
||||
const fileContent = await fs.readFile(settingsPath, "utf-8");
|
||||
const saved = JSON.parse(fileContent);
|
||||
expect(saved.theme).toBe("light");
|
||||
expect(saved.useWorktrees).toBe(true);
|
||||
});
|
||||
|
||||
it("should merge updates with existing project settings", async () => {
|
||||
const initial: ProjectSettings = {
|
||||
...DEFAULT_PROJECT_SETTINGS,
|
||||
theme: "dark",
|
||||
useWorktrees: false,
|
||||
};
|
||||
const automakerDir = path.join(testProjectDir, ".automaker");
|
||||
await fs.mkdir(automakerDir, { recursive: true });
|
||||
const settingsPath = path.join(automakerDir, "settings.json");
|
||||
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
|
||||
|
||||
const updates: Partial<ProjectSettings> = {
|
||||
theme: "light",
|
||||
};
|
||||
|
||||
const updated = await settingsService.updateProjectSettings(testProjectDir, updates);
|
||||
|
||||
expect(updated.theme).toBe("light");
|
||||
expect(updated.useWorktrees).toBe(false); // Preserved
|
||||
});
|
||||
|
||||
it("should deep merge board background", async () => {
|
||||
const initial: ProjectSettings = {
|
||||
...DEFAULT_PROJECT_SETTINGS,
|
||||
boardBackground: {
|
||||
imagePath: "/path/to/image.jpg",
|
||||
cardOpacity: 0.8,
|
||||
columnOpacity: 0.9,
|
||||
columnBorderEnabled: true,
|
||||
cardGlassmorphism: false,
|
||||
cardBorderEnabled: true,
|
||||
cardBorderOpacity: 0.5,
|
||||
hideScrollbar: false,
|
||||
},
|
||||
};
|
||||
const automakerDir = path.join(testProjectDir, ".automaker");
|
||||
await fs.mkdir(automakerDir, { recursive: true });
|
||||
const settingsPath = path.join(automakerDir, "settings.json");
|
||||
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
|
||||
|
||||
const updates: Partial<ProjectSettings> = {
|
||||
boardBackground: {
|
||||
cardOpacity: 0.9,
|
||||
},
|
||||
};
|
||||
|
||||
const updated = await settingsService.updateProjectSettings(testProjectDir, updates);
|
||||
|
||||
expect(updated.boardBackground?.imagePath).toBe("/path/to/image.jpg");
|
||||
expect(updated.boardBackground?.cardOpacity).toBe(0.9);
|
||||
expect(updated.boardBackground?.columnOpacity).toBe(0.9);
|
||||
});
|
||||
|
||||
it("should create .automaker directory if it does not exist", async () => {
|
||||
const newProjectDir = path.join(os.tmpdir(), `new-project-${Date.now()}`);
|
||||
|
||||
await settingsService.updateProjectSettings(newProjectDir, { theme: "light" });
|
||||
|
||||
const automakerDir = path.join(newProjectDir, ".automaker");
|
||||
const stats = await fs.stat(automakerDir);
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
|
||||
await fs.rm(newProjectDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasProjectSettings", () => {
|
||||
it("should return false when project settings file does not exist", async () => {
|
||||
const exists = await settingsService.hasProjectSettings(testProjectDir);
|
||||
expect(exists).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when project settings file exists", async () => {
|
||||
await settingsService.updateProjectSettings(testProjectDir, { theme: "light" });
|
||||
const exists = await settingsService.hasProjectSettings(testProjectDir);
|
||||
expect(exists).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateFromLocalStorage", () => {
|
||||
it("should migrate global settings from localStorage data", async () => {
|
||||
const localStorageData = {
|
||||
"automaker-storage": JSON.stringify({
|
||||
state: {
|
||||
theme: "light",
|
||||
sidebarOpen: false,
|
||||
maxConcurrency: 5,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const result = await settingsService.migrateFromLocalStorage(localStorageData);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.migratedGlobalSettings).toBe(true);
|
||||
expect(result.migratedCredentials).toBe(false);
|
||||
expect(result.migratedProjectCount).toBe(0);
|
||||
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
expect(settings.theme).toBe("light");
|
||||
expect(settings.sidebarOpen).toBe(false);
|
||||
expect(settings.maxConcurrency).toBe(5);
|
||||
});
|
||||
|
||||
it("should migrate credentials from localStorage data", async () => {
|
||||
const localStorageData = {
|
||||
"automaker-storage": JSON.stringify({
|
||||
state: {
|
||||
apiKeys: {
|
||||
anthropic: "sk-test-key",
|
||||
google: "google-key",
|
||||
openai: "openai-key",
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const result = await settingsService.migrateFromLocalStorage(localStorageData);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.migratedCredentials).toBe(true);
|
||||
|
||||
const credentials = await settingsService.getCredentials();
|
||||
expect(credentials.apiKeys.anthropic).toBe("sk-test-key");
|
||||
expect(credentials.apiKeys.google).toBe("google-key");
|
||||
expect(credentials.apiKeys.openai).toBe("openai-key");
|
||||
});
|
||||
|
||||
it("should migrate project settings from localStorage data", async () => {
|
||||
const localStorageData = {
|
||||
"automaker-storage": JSON.stringify({
|
||||
state: {
|
||||
projects: [
|
||||
{
|
||||
id: "proj1",
|
||||
name: "Project 1",
|
||||
path: testProjectDir,
|
||||
theme: "light",
|
||||
},
|
||||
],
|
||||
boardBackgroundByProject: {
|
||||
[testProjectDir]: {
|
||||
imagePath: "/path/to/image.jpg",
|
||||
cardOpacity: 0.8,
|
||||
columnOpacity: 0.9,
|
||||
columnBorderEnabled: true,
|
||||
cardGlassmorphism: false,
|
||||
cardBorderEnabled: true,
|
||||
cardBorderOpacity: 0.5,
|
||||
hideScrollbar: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const result = await settingsService.migrateFromLocalStorage(localStorageData);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.migratedProjectCount).toBe(1);
|
||||
|
||||
const projectSettings = await settingsService.getProjectSettings(testProjectDir);
|
||||
expect(projectSettings.theme).toBe("light");
|
||||
expect(projectSettings.boardBackground?.imagePath).toBe("/path/to/image.jpg");
|
||||
});
|
||||
|
||||
it("should handle direct localStorage values", async () => {
|
||||
const localStorageData = {
|
||||
"automaker:lastProjectDir": "/path/to/project",
|
||||
"file-browser-recent-folders": JSON.stringify(["/path1", "/path2"]),
|
||||
"worktree-panel-collapsed": "true",
|
||||
};
|
||||
|
||||
const result = await settingsService.migrateFromLocalStorage(localStorageData);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
expect(settings.lastProjectDir).toBe("/path/to/project");
|
||||
expect(settings.recentFolders).toEqual(["/path1", "/path2"]);
|
||||
expect(settings.worktreePanelCollapsed).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle invalid JSON gracefully", async () => {
|
||||
const localStorageData = {
|
||||
"automaker-storage": "invalid json",
|
||||
"file-browser-recent-folders": "invalid json",
|
||||
};
|
||||
|
||||
const result = await settingsService.migrateFromLocalStorage(localStorageData);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should handle migration errors gracefully", async () => {
|
||||
// Create a read-only directory to cause write errors
|
||||
const readOnlyDir = path.join(os.tmpdir(), `readonly-${Date.now()}`);
|
||||
await fs.mkdir(readOnlyDir, { recursive: true });
|
||||
await fs.chmod(readOnlyDir, 0o444);
|
||||
|
||||
const readOnlyService = new SettingsService(readOnlyDir);
|
||||
const localStorageData = {
|
||||
"automaker-storage": JSON.stringify({
|
||||
state: { theme: "light" },
|
||||
}),
|
||||
};
|
||||
|
||||
const result = await readOnlyService.migrateFromLocalStorage(localStorageData);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
|
||||
await fs.chmod(readOnlyDir, 0o755);
|
||||
await fs.rm(readOnlyDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDataDir", () => {
|
||||
it("should return the data directory path", () => {
|
||||
const dataDir = settingsService.getDataDir();
|
||||
expect(dataDir).toBe(testDataDir);
|
||||
});
|
||||
});
|
||||
|
||||
describe("atomicWriteJson", () => {
|
||||
it("should handle write errors and clean up temp file", async () => {
|
||||
// Create a read-only directory to cause write errors
|
||||
const readOnlyDir = path.join(os.tmpdir(), `readonly-${Date.now()}`);
|
||||
await fs.mkdir(readOnlyDir, { recursive: true });
|
||||
await fs.chmod(readOnlyDir, 0o444);
|
||||
|
||||
const readOnlyService = new SettingsService(readOnlyDir);
|
||||
|
||||
await expect(
|
||||
readOnlyService.updateGlobalSettings({ theme: "light" })
|
||||
).rejects.toThrow();
|
||||
|
||||
await fs.chmod(readOnlyDir, 0o755);
|
||||
await fs.rm(readOnlyDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
43
apps/ui/Dockerfile
Normal file
43
apps/ui/Dockerfile
Normal file
@@ -0,0 +1,43 @@
|
||||
# Automaker UI
|
||||
# Multi-stage build for minimal production image
|
||||
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
COPY apps/ui/package*.json ./apps/ui/
|
||||
COPY scripts ./scripts
|
||||
|
||||
# Install dependencies (skip electron postinstall)
|
||||
RUN npm ci --workspace=apps/ui --ignore-scripts
|
||||
|
||||
# Copy source
|
||||
COPY apps/ui ./apps/ui
|
||||
|
||||
# Build for web (skip electron)
|
||||
# VITE_SERVER_URL tells the UI where to find the API server
|
||||
# Using localhost:3008 since both containers expose ports to the host
|
||||
# Use ARG to allow overriding at build time: --build-arg VITE_SERVER_URL=http://api.example.com
|
||||
ARG VITE_SERVER_URL=http://localhost:3008
|
||||
ENV VITE_SKIP_ELECTRON=true
|
||||
ENV VITE_SERVER_URL=${VITE_SERVER_URL}
|
||||
RUN npm run build --workspace=apps/ui
|
||||
|
||||
# Production stage - serve with nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built files
|
||||
COPY --from=builder /app/apps/ui/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx config for SPA routing
|
||||
COPY apps/ui/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
10
apps/ui/nginx.conf
Normal file
10
apps/ui/nginx.conf
Normal file
@@ -0,0 +1,10 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,35 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { RouterProvider } from "@tanstack/react-router";
|
||||
import { router } from "./utils/router";
|
||||
import { SplashScreen } from "./components/splash-screen";
|
||||
import { useSettingsMigration } from "./hooks/use-settings-migration";
|
||||
import "./styles/global.css";
|
||||
import "./styles/theme-imports";
|
||||
|
||||
export default function App() {
|
||||
return <RouterProvider router={router} />;
|
||||
const [showSplash, setShowSplash] = useState(() => {
|
||||
// Only show splash once per session
|
||||
if (sessionStorage.getItem("automaker-splash-shown")) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Run settings migration on startup (localStorage -> file storage)
|
||||
const migrationState = useSettingsMigration();
|
||||
if (migrationState.migrated) {
|
||||
console.log("[App] Settings migrated to file storage");
|
||||
}
|
||||
|
||||
const handleSplashComplete = useCallback(() => {
|
||||
sessionStorage.setItem("automaker-splash-shown", "true");
|
||||
setShowSplash(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<RouterProvider router={router} />
|
||||
{showSplash && <SplashScreen onComplete={handleSplashComplete} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAppStore, defaultBackgroundSettings } from "@/store/app-store";
|
||||
import { getHttpApiClient } from "@/lib/http-api-client";
|
||||
import { useBoardBackgroundSettings } from "@/hooks/use-board-background-settings";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const ACCEPTED_IMAGE_TYPES = [
|
||||
@@ -35,9 +36,8 @@ export function BoardBackgroundModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: BoardBackgroundModalProps) {
|
||||
const { currentProject, boardBackgroundByProject } = useAppStore();
|
||||
const {
|
||||
currentProject,
|
||||
boardBackgroundByProject,
|
||||
setBoardBackground,
|
||||
setCardOpacity,
|
||||
setColumnOpacity,
|
||||
@@ -47,7 +47,7 @@ export function BoardBackgroundModal({
|
||||
setCardBorderOpacity,
|
||||
setHideScrollbar,
|
||||
clearBoardBackground,
|
||||
} = useAppStore();
|
||||
} = useBoardBackgroundSettings();
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -139,8 +139,8 @@ export function BoardBackgroundModal({
|
||||
);
|
||||
|
||||
if (result.success && result.path) {
|
||||
// Update store with the relative path (live update)
|
||||
setBoardBackground(currentProject.path, result.path);
|
||||
// Update store and persist to server
|
||||
await setBoardBackground(currentProject.path, result.path);
|
||||
toast.success("Background image saved");
|
||||
} else {
|
||||
toast.error(result.error || "Failed to save background image");
|
||||
@@ -214,7 +214,7 @@ export function BoardBackgroundModal({
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
clearBoardBackground(currentProject.path);
|
||||
await clearBoardBackground(currentProject.path);
|
||||
setPreviewImage(null);
|
||||
toast.success("Background image cleared");
|
||||
} else {
|
||||
@@ -228,59 +228,59 @@ export function BoardBackgroundModal({
|
||||
}
|
||||
}, [currentProject, clearBoardBackground]);
|
||||
|
||||
// Live update opacity when sliders change
|
||||
// Live update opacity when sliders change (with persistence)
|
||||
const handleCardOpacityChange = useCallback(
|
||||
(value: number[]) => {
|
||||
async (value: number[]) => {
|
||||
if (!currentProject) return;
|
||||
setCardOpacity(currentProject.path, value[0]);
|
||||
await setCardOpacity(currentProject.path, value[0]);
|
||||
},
|
||||
[currentProject, setCardOpacity]
|
||||
);
|
||||
|
||||
const handleColumnOpacityChange = useCallback(
|
||||
(value: number[]) => {
|
||||
async (value: number[]) => {
|
||||
if (!currentProject) return;
|
||||
setColumnOpacity(currentProject.path, value[0]);
|
||||
await setColumnOpacity(currentProject.path, value[0]);
|
||||
},
|
||||
[currentProject, setColumnOpacity]
|
||||
);
|
||||
|
||||
const handleColumnBorderToggle = useCallback(
|
||||
(checked: boolean) => {
|
||||
async (checked: boolean) => {
|
||||
if (!currentProject) return;
|
||||
setColumnBorderEnabled(currentProject.path, checked);
|
||||
await setColumnBorderEnabled(currentProject.path, checked);
|
||||
},
|
||||
[currentProject, setColumnBorderEnabled]
|
||||
);
|
||||
|
||||
const handleCardGlassmorphismToggle = useCallback(
|
||||
(checked: boolean) => {
|
||||
async (checked: boolean) => {
|
||||
if (!currentProject) return;
|
||||
setCardGlassmorphism(currentProject.path, checked);
|
||||
await setCardGlassmorphism(currentProject.path, checked);
|
||||
},
|
||||
[currentProject, setCardGlassmorphism]
|
||||
);
|
||||
|
||||
const handleCardBorderToggle = useCallback(
|
||||
(checked: boolean) => {
|
||||
async (checked: boolean) => {
|
||||
if (!currentProject) return;
|
||||
setCardBorderEnabled(currentProject.path, checked);
|
||||
await setCardBorderEnabled(currentProject.path, checked);
|
||||
},
|
||||
[currentProject, setCardBorderEnabled]
|
||||
);
|
||||
|
||||
const handleCardBorderOpacityChange = useCallback(
|
||||
(value: number[]) => {
|
||||
async (value: number[]) => {
|
||||
if (!currentProject) return;
|
||||
setCardBorderOpacity(currentProject.path, value[0]);
|
||||
await setCardBorderOpacity(currentProject.path, value[0]);
|
||||
},
|
||||
[currentProject, setCardBorderOpacity]
|
||||
);
|
||||
|
||||
const handleHideScrollbarToggle = useCallback(
|
||||
(checked: boolean) => {
|
||||
async (checked: boolean) => {
|
||||
if (!currentProject) return;
|
||||
setHideScrollbar(currentProject.path, checked);
|
||||
await setHideScrollbar(currentProject.path, checked);
|
||||
},
|
||||
[currentProject, setHideScrollbar]
|
||||
);
|
||||
|
||||
@@ -208,13 +208,31 @@ export function FileBrowserDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = () => {
|
||||
const handleSelect = useCallback(() => {
|
||||
if (currentPath) {
|
||||
addRecentFolder(currentPath);
|
||||
onSelect(currentPath);
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
}, [currentPath, onSelect, onOpenChange]);
|
||||
|
||||
// Handle Command/Ctrl+Enter keyboard shortcut to select current folder
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Check for Command+Enter (Mac) or Ctrl+Enter (Windows/Linux)
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
if (currentPath && !loading) {
|
||||
handleSelect();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [open, currentPath, loading, handleSelect]);
|
||||
|
||||
// Helper to get folder name from path
|
||||
const getFolderName = (path: string) => {
|
||||
@@ -399,9 +417,12 @@ export function FileBrowserDialog({
|
||||
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSelect} disabled={!currentPath || loading}>
|
||||
<Button size="sm" onClick={handleSelect} disabled={!currentPath || loading} title="Select current folder (Cmd+Enter / Ctrl+Enter)">
|
||||
<FolderOpen className="w-3.5 h-3.5 mr-1.5" />
|
||||
Select Current Folder
|
||||
<kbd className="ml-2 px-1.5 py-0.5 text-[10px] bg-background/50 rounded border border-border">
|
||||
{typeof navigator !== "undefined" && navigator.platform?.includes("Mac") ? "⌘" : "Ctrl"}+↵
|
||||
</kbd>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1029,12 +1029,6 @@ export function Sidebar() {
|
||||
icon: UserCircle,
|
||||
shortcut: shortcuts.profiles,
|
||||
},
|
||||
{
|
||||
id: "terminal",
|
||||
label: "Terminal",
|
||||
icon: Terminal,
|
||||
shortcut: shortcuts.terminal,
|
||||
},
|
||||
];
|
||||
|
||||
// Filter out hidden items
|
||||
@@ -1048,29 +1042,39 @@ export function Sidebar() {
|
||||
if (item.id === "profiles" && hideAiProfiles) {
|
||||
return false;
|
||||
}
|
||||
if (item.id === "terminal" && hideTerminal) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Build project items - Terminal is conditionally included
|
||||
const projectItems: NavItem[] = [
|
||||
{
|
||||
id: "board",
|
||||
label: "Kanban Board",
|
||||
icon: LayoutGrid,
|
||||
shortcut: shortcuts.board,
|
||||
},
|
||||
{
|
||||
id: "agent",
|
||||
label: "Agent Runner",
|
||||
icon: Bot,
|
||||
shortcut: shortcuts.agent,
|
||||
},
|
||||
];
|
||||
|
||||
// Add Terminal to Project section if not hidden
|
||||
if (!hideTerminal) {
|
||||
projectItems.push({
|
||||
id: "terminal",
|
||||
label: "Terminal",
|
||||
icon: Terminal,
|
||||
shortcut: shortcuts.terminal,
|
||||
});
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: "Project",
|
||||
items: [
|
||||
{
|
||||
id: "board",
|
||||
label: "Kanban Board",
|
||||
icon: LayoutGrid,
|
||||
shortcut: shortcuts.board,
|
||||
},
|
||||
{
|
||||
id: "agent",
|
||||
label: "Agent Runner",
|
||||
icon: Bot,
|
||||
shortcut: shortcuts.agent,
|
||||
},
|
||||
],
|
||||
items: projectItems,
|
||||
},
|
||||
{
|
||||
label: "Tools",
|
||||
|
||||
309
apps/ui/src/components/splash-screen.tsx
Normal file
309
apps/ui/src/components/splash-screen.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
|
||||
const TOTAL_DURATION = 2300; // Total animation duration in ms (tightened from 4000)
|
||||
const LOGO_ENTER_DURATION = 500; // Tightened from 1200
|
||||
const PARTICLES_ENTER_DELAY = 100; // Tightened from 400
|
||||
const EXIT_START = 1800; // Adjusted for shorter duration
|
||||
|
||||
interface Particle {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
delay: number;
|
||||
angle: number;
|
||||
distance: number;
|
||||
opacity: number;
|
||||
floatDuration: number;
|
||||
}
|
||||
|
||||
function generateParticles(count: number): Particle[] {
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const angle = (i / count) * 360 + Math.random() * 30;
|
||||
const distance = 60 + Math.random() * 80; // Increased spread
|
||||
return {
|
||||
id: i,
|
||||
x: Math.cos((angle * Math.PI) / 180) * distance,
|
||||
y: Math.sin((angle * Math.PI) / 180) * distance,
|
||||
size: 3 + Math.random() * 6, // Slightly smaller range for more subtle look
|
||||
delay: Math.random() * 400,
|
||||
angle,
|
||||
distance: 300 + Math.random() * 200,
|
||||
opacity: 0.4 + Math.random() * 0.6,
|
||||
floatDuration: 3000 + Math.random() * 4000,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function SplashScreen({ onComplete }: { onComplete: () => void }) {
|
||||
const [phase, setPhase] = useState<"enter" | "hold" | "exit" | "done">(
|
||||
"enter"
|
||||
);
|
||||
|
||||
const particles = useMemo(() => generateParticles(50), []);
|
||||
|
||||
useEffect(() => {
|
||||
const timers: NodeJS.Timeout[] = [];
|
||||
|
||||
// Phase transitions
|
||||
timers.push(setTimeout(() => setPhase("hold"), LOGO_ENTER_DURATION));
|
||||
timers.push(setTimeout(() => setPhase("exit"), EXIT_START));
|
||||
timers.push(
|
||||
setTimeout(() => {
|
||||
setPhase("done");
|
||||
onComplete();
|
||||
}, TOTAL_DURATION)
|
||||
);
|
||||
|
||||
return () => timers.forEach(clearTimeout);
|
||||
}, [onComplete]);
|
||||
|
||||
if (phase === "done") return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
fixed inset-0 z-[9999] flex items-center justify-center
|
||||
bg-background
|
||||
transition-opacity duration-500 ease-out
|
||||
${phase === "exit" ? "opacity-0" : "opacity-100"}
|
||||
`}
|
||||
style={{
|
||||
pointerEvents: phase === "exit" ? "none" : "auto",
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes spin-slow {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@keyframes spin-slow-reverse {
|
||||
from { transform: rotate(360deg); }
|
||||
to { transform: rotate(0deg); }
|
||||
}
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
50% { transform: translate(6px, -6px); }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Subtle gradient background */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-30"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle at center, var(--brand-500) 0%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Particle container 1 - Clockwise */}
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center overflow-hidden"
|
||||
style={{ animation: "spin-slow 60s linear infinite" }}
|
||||
>
|
||||
{particles.slice(0, 25).map((particle) => (
|
||||
<div
|
||||
key={particle.id}
|
||||
className="absolute"
|
||||
style={{
|
||||
transform:
|
||||
phase === "exit"
|
||||
? `translate(${Math.cos((particle.angle * Math.PI) / 180) * particle.distance}px, ${Math.sin((particle.angle * Math.PI) / 180) * particle.distance}px)`
|
||||
: `translate(${particle.x}px, ${particle.y}px)`,
|
||||
transition:
|
||||
phase === "enter"
|
||||
? `all 600ms ease-out ${PARTICLES_ENTER_DELAY + particle.delay}ms`
|
||||
: phase === "exit"
|
||||
? `all 800ms cubic-bezier(0.4, 0, 1, 1) ${particle.delay * 0.3}ms`
|
||||
: "all 300ms ease-out",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rounded-full"
|
||||
style={{
|
||||
width: particle.size,
|
||||
height: particle.size,
|
||||
background: `linear-gradient(135deg, var(--brand-400), var(--brand-600))`,
|
||||
boxShadow: `0 0 ${particle.size * 2}px var(--brand-500)`,
|
||||
opacity:
|
||||
phase === "enter"
|
||||
? 0
|
||||
: phase === "hold"
|
||||
? particle.opacity
|
||||
: 0,
|
||||
transform: phase === "exit" ? "scale(0)" : "scale(1)",
|
||||
animation: `float ${particle.floatDuration}ms ease-in-out infinite`,
|
||||
transition: "opacity 300ms ease-out, transform 300ms ease-out",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Particle container 2 - Counter-Clockwise */}
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center overflow-hidden"
|
||||
style={{ animation: "spin-slow-reverse 75s linear infinite" }}
|
||||
>
|
||||
{particles.slice(25).map((particle) => (
|
||||
<div
|
||||
key={particle.id}
|
||||
className="absolute"
|
||||
style={{
|
||||
transform:
|
||||
phase === "exit"
|
||||
? `translate(${Math.cos((particle.angle * Math.PI) / 180) * particle.distance}px, ${Math.sin((particle.angle * Math.PI) / 180) * particle.distance}px)`
|
||||
: `translate(${particle.x}px, ${particle.y}px)`,
|
||||
transition:
|
||||
phase === "enter"
|
||||
? `all 600ms ease-out ${PARTICLES_ENTER_DELAY + particle.delay}ms`
|
||||
: phase === "exit"
|
||||
? `all 800ms cubic-bezier(0.4, 0, 1, 1) ${particle.delay * 0.3}ms`
|
||||
: "all 300ms ease-out",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rounded-full"
|
||||
style={{
|
||||
width: particle.size,
|
||||
height: particle.size,
|
||||
background: `linear-gradient(135deg, var(--brand-400), var(--brand-600))`,
|
||||
boxShadow: `0 0 ${particle.size * 2}px var(--brand-500)`,
|
||||
opacity:
|
||||
phase === "enter"
|
||||
? 0
|
||||
: phase === "hold"
|
||||
? particle.opacity
|
||||
: 0,
|
||||
transform: phase === "exit" ? "scale(0)" : "scale(1)",
|
||||
animation: `float ${particle.floatDuration}ms ease-in-out infinite`,
|
||||
animationDelay: `${particle.delay}ms`,
|
||||
transition: "opacity 300ms ease-out, transform 300ms ease-out",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Logo container */}
|
||||
<div
|
||||
className="relative z-10"
|
||||
style={{
|
||||
opacity: phase === "enter" ? 0 : phase === "exit" ? 0 : 1,
|
||||
transform:
|
||||
phase === "enter"
|
||||
? "scale(0.3) rotate(-20deg)"
|
||||
: phase === "exit"
|
||||
? "scale(2.5) translateY(-100px)"
|
||||
: "scale(1) rotate(0deg)",
|
||||
transition:
|
||||
phase === "enter"
|
||||
? `all ${LOGO_ENTER_DURATION}ms cubic-bezier(0.34, 1.56, 0.64, 1)`
|
||||
: phase === "exit"
|
||||
? "all 600ms cubic-bezier(0.4, 0, 1, 1)"
|
||||
: "all 300ms ease-out",
|
||||
}}
|
||||
>
|
||||
{/* Glow effect behind logo */}
|
||||
<div
|
||||
className="absolute inset-0 blur-3xl"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle, var(--brand-500) 0%, transparent 70%)",
|
||||
transform: "scale(2.5)",
|
||||
opacity: phase === "hold" ? 0.6 : 0,
|
||||
transition: "opacity 500ms ease-out",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* The logo */}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
role="img"
|
||||
aria-label="Automaker Logo"
|
||||
className="relative z-10"
|
||||
style={{
|
||||
width: 120,
|
||||
height: 120,
|
||||
filter: "drop-shadow(0 0 30px var(--brand-500))",
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="splash-bg"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="256"
|
||||
y2="256"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" style={{ stopColor: "var(--brand-400)" }} />
|
||||
<stop offset="100%" style={{ stopColor: "var(--brand-600)" }} />
|
||||
</linearGradient>
|
||||
<filter
|
||||
id="splash-shadow"
|
||||
x="-20%"
|
||||
y="-20%"
|
||||
width="140%"
|
||||
height="140%"
|
||||
>
|
||||
<feDropShadow
|
||||
dx="0"
|
||||
dy="4"
|
||||
stdDeviation="4"
|
||||
floodColor="#000000"
|
||||
floodOpacity="0.25"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect
|
||||
x="16"
|
||||
y="16"
|
||||
width="224"
|
||||
height="224"
|
||||
rx="56"
|
||||
fill="url(#splash-bg)"
|
||||
/>
|
||||
<g
|
||||
fill="none"
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth="20"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
filter="url(#splash-shadow)"
|
||||
>
|
||||
<path d="M92 92 L52 128 L92 164" />
|
||||
<path d="M144 72 L116 184" />
|
||||
<path d="M164 92 L204 128 L164 164" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Automaker text that fades in below the logo */}
|
||||
<div
|
||||
className="absolute flex items-center gap-1"
|
||||
style={{
|
||||
top: "calc(50% + 80px)",
|
||||
opacity: phase === "enter" ? 0 : phase === "exit" ? 0 : 1,
|
||||
transform:
|
||||
phase === "enter"
|
||||
? "translateY(20px)"
|
||||
: phase === "exit"
|
||||
? "translateY(-30px) scale(1.2)"
|
||||
: "translateY(0)",
|
||||
transition:
|
||||
phase === "enter"
|
||||
? `all 600ms ease-out ${LOGO_ENTER_DURATION - 200}ms`
|
||||
: phase === "exit"
|
||||
? "all 500ms cubic-bezier(0.4, 0, 1, 1)"
|
||||
: "all 300ms ease-out",
|
||||
}}
|
||||
>
|
||||
<span className="font-bold text-foreground text-4xl tracking-tight leading-none">
|
||||
automaker<span className="text-brand-500">.</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,11 +11,12 @@ function Card({ className, gradient = false, ...props }: CardProps) {
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border border-white/10 backdrop-blur-md py-6",
|
||||
"bg-card text-card-foreground flex flex-col gap-1 rounded-xl border border-white/10 backdrop-blur-md py-6",
|
||||
// Premium layered shadow
|
||||
"shadow-[0_1px_2px_rgba(0,0,0,0.05),0_4px_6px_rgba(0,0,0,0.05),0_10px_20px_rgba(0,0,0,0.04)]",
|
||||
// Gradient border option
|
||||
gradient && "relative before:absolute before:inset-0 before:rounded-xl before:p-[1px] before:bg-gradient-to-br before:from-white/20 before:to-transparent before:pointer-events-none before:-z-10",
|
||||
gradient &&
|
||||
"relative before:absolute before:inset-0 before:rounded-xl before:p-[1px] before:bg-gradient-to-br before:from-white/20 before:to-transparent before:pointer-events-none before:-z-10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { Tag } from "lucide-react";
|
||||
import { Autocomplete } from "@/components/ui/autocomplete";
|
||||
|
||||
interface CategoryAutocompleteProps {
|
||||
@@ -9,6 +9,7 @@ interface CategoryAutocompleteProps {
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
error?: boolean;
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
@@ -19,6 +20,7 @@ export function CategoryAutocomplete({
|
||||
placeholder = "Select or type a category...",
|
||||
className,
|
||||
disabled = false,
|
||||
error = false,
|
||||
"data-testid": testId,
|
||||
}: CategoryAutocompleteProps) {
|
||||
return (
|
||||
@@ -27,10 +29,14 @@ export function CategoryAutocomplete({
|
||||
onChange={onChange}
|
||||
options={suggestions}
|
||||
placeholder={placeholder}
|
||||
searchPlaceholder="Search category..."
|
||||
searchPlaceholder="Search or type new category..."
|
||||
emptyMessage="No category found."
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
error={error}
|
||||
icon={Tag}
|
||||
allowCreate
|
||||
createLabel={(v) => `Create "${v}"`}
|
||||
data-testid={testId}
|
||||
itemTestIdPrefix="category-option"
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
|
||||
import {
|
||||
PointerSensor,
|
||||
@@ -39,6 +38,7 @@ import { CommitWorktreeDialog } from "./board-view/dialogs/commit-worktree-dialo
|
||||
import { CreatePRDialog } from "./board-view/dialogs/create-pr-dialog";
|
||||
import { CreateBranchDialog } from "./board-view/dialogs/create-branch-dialog";
|
||||
import { WorktreePanel } from "./board-view/worktree-panel";
|
||||
import type { PRInfo, WorktreeInfo } from "./board-view/worktree-panel/types";
|
||||
import { COLUMNS } from "./board-view/constants";
|
||||
import {
|
||||
useBoardFeatures,
|
||||
@@ -58,6 +58,9 @@ const EMPTY_WORKTREES: ReturnType<
|
||||
ReturnType<typeof useAppStore.getState>["getWorktrees"]
|
||||
> = [];
|
||||
|
||||
/** Delay before starting a newly created feature to allow state to settle */
|
||||
const FEATURE_CREATION_SETTLE_DELAY_MS = 500;
|
||||
|
||||
export function BoardView() {
|
||||
const {
|
||||
currentProject,
|
||||
@@ -271,13 +274,16 @@ export function BoardView() {
|
||||
|
||||
// Calculate unarchived card counts per branch
|
||||
const branchCardCounts = useMemo(() => {
|
||||
return hookFeatures.reduce((counts, feature) => {
|
||||
if (feature.status !== "completed") {
|
||||
const branch = feature.branchName ?? "main";
|
||||
counts[branch] = (counts[branch] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}, {} as Record<string, number>);
|
||||
return hookFeatures.reduce(
|
||||
(counts, feature) => {
|
||||
if (feature.status !== "completed") {
|
||||
const branch = feature.branchName ?? "main";
|
||||
counts[branch] = (counts[branch] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
}, [hookFeatures]);
|
||||
|
||||
// Custom collision detection that prioritizes columns over cards
|
||||
@@ -340,7 +346,7 @@ export function BoardView() {
|
||||
const worktrees = useMemo(
|
||||
() =>
|
||||
currentProject
|
||||
? worktreesByProject[currentProject.path] ?? EMPTY_WORKTREES
|
||||
? (worktreesByProject[currentProject.path] ?? EMPTY_WORKTREES)
|
||||
: EMPTY_WORKTREES,
|
||||
[currentProject, worktreesByProject]
|
||||
);
|
||||
@@ -412,9 +418,124 @@ export function BoardView() {
|
||||
outputFeature,
|
||||
projectPath: currentProject?.path || null,
|
||||
onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1),
|
||||
onWorktreeAutoSelect: (newWorktree) => {
|
||||
if (!currentProject) return;
|
||||
// Check if worktree already exists in the store (by branch name)
|
||||
const currentWorktrees = getWorktrees(currentProject.path);
|
||||
const existingWorktree = currentWorktrees.find(
|
||||
(w) => w.branch === newWorktree.branch
|
||||
);
|
||||
|
||||
// Only add if it doesn't already exist (to avoid duplicates)
|
||||
if (!existingWorktree) {
|
||||
const newWorktreeInfo = {
|
||||
path: newWorktree.path,
|
||||
branch: newWorktree.branch,
|
||||
isMain: false,
|
||||
isCurrent: false,
|
||||
hasWorktree: true,
|
||||
};
|
||||
setWorktrees(currentProject.path, [
|
||||
...currentWorktrees,
|
||||
newWorktreeInfo,
|
||||
]);
|
||||
}
|
||||
// Select the worktree (whether it existed or was just added)
|
||||
setCurrentWorktree(
|
||||
currentProject.path,
|
||||
newWorktree.path,
|
||||
newWorktree.branch
|
||||
);
|
||||
},
|
||||
currentWorktreeBranch,
|
||||
});
|
||||
|
||||
// Handler for addressing PR comments - creates a feature and starts it automatically
|
||||
const handleAddressPRComments = useCallback(
|
||||
async (worktree: WorktreeInfo, prInfo: PRInfo) => {
|
||||
// Use a simple prompt that instructs the agent to read and address PR feedback
|
||||
// The agent will fetch the PR comments directly, which is more reliable and up-to-date
|
||||
const prNumber = prInfo.number;
|
||||
const description = `Read the review requests on PR #${prNumber} and address any feedback the best you can.`;
|
||||
|
||||
// Create the feature
|
||||
const featureData = {
|
||||
category: "PR Review",
|
||||
description,
|
||||
steps: [],
|
||||
images: [],
|
||||
imagePaths: [],
|
||||
skipTests: defaultSkipTests,
|
||||
model: "opus" as const,
|
||||
thinkingLevel: "none" as const,
|
||||
branchName: worktree.branch,
|
||||
priority: 1, // High priority for PR feedback
|
||||
planningMode: "skip" as const,
|
||||
requirePlanApproval: false,
|
||||
};
|
||||
|
||||
await handleAddFeature(featureData);
|
||||
|
||||
// Find the newly created feature and start it
|
||||
// We need to wait a moment for the feature to be created
|
||||
setTimeout(async () => {
|
||||
const latestFeatures = useAppStore.getState().features;
|
||||
const newFeature = latestFeatures.find(
|
||||
(f) =>
|
||||
f.branchName === worktree.branch &&
|
||||
f.status === "backlog" &&
|
||||
f.description.includes(`PR #${prNumber}`)
|
||||
);
|
||||
|
||||
if (newFeature) {
|
||||
await handleStartImplementation(newFeature);
|
||||
}
|
||||
}, FEATURE_CREATION_SETTLE_DELAY_MS);
|
||||
},
|
||||
[handleAddFeature, handleStartImplementation, defaultSkipTests]
|
||||
);
|
||||
|
||||
// Handler for resolving conflicts - creates a feature to pull from origin/main and resolve conflicts
|
||||
const handleResolveConflicts = useCallback(
|
||||
async (worktree: WorktreeInfo) => {
|
||||
const description = `Pull latest from origin/main and resolve conflicts. Merge origin/main into the current branch (${worktree.branch}), resolving any merge conflicts that arise. After resolving conflicts, ensure the code compiles and tests pass.`;
|
||||
|
||||
// Create the feature
|
||||
const featureData = {
|
||||
category: "Maintenance",
|
||||
description,
|
||||
steps: [],
|
||||
images: [],
|
||||
imagePaths: [],
|
||||
skipTests: defaultSkipTests,
|
||||
model: "opus" as const,
|
||||
thinkingLevel: "none" as const,
|
||||
branchName: worktree.branch,
|
||||
priority: 1, // High priority for conflict resolution
|
||||
planningMode: "skip" as const,
|
||||
requirePlanApproval: false,
|
||||
};
|
||||
|
||||
await handleAddFeature(featureData);
|
||||
|
||||
// Find the newly created feature and start it
|
||||
setTimeout(async () => {
|
||||
const latestFeatures = useAppStore.getState().features;
|
||||
const newFeature = latestFeatures.find(
|
||||
(f) =>
|
||||
f.branchName === worktree.branch &&
|
||||
f.status === "backlog" &&
|
||||
f.description.includes("Pull latest from origin/main")
|
||||
);
|
||||
|
||||
if (newFeature) {
|
||||
await handleStartImplementation(newFeature);
|
||||
}
|
||||
}, FEATURE_CREATION_SETTLE_DELAY_MS);
|
||||
},
|
||||
[handleAddFeature, handleStartImplementation, defaultSkipTests]
|
||||
);
|
||||
|
||||
// Client-side auto mode: periodically check for backlog items and move them to in-progress
|
||||
// Use a ref to track the latest auto mode state so async operations always check the current value
|
||||
const autoModeRunningRef = useRef(autoMode.isRunning);
|
||||
@@ -835,6 +956,7 @@ export function BoardView() {
|
||||
<BoardHeader
|
||||
projectName={currentProject.name}
|
||||
maxConcurrency={maxConcurrency}
|
||||
runningAgentsCount={runningAutoTasks.length}
|
||||
onConcurrencyChange={setMaxConcurrency}
|
||||
isAutoModeRunning={autoMode.isRunning}
|
||||
onAutoModeToggle={(enabled) => {
|
||||
@@ -874,6 +996,8 @@ export function BoardView() {
|
||||
setSelectedWorktreeForAction(worktree);
|
||||
setShowCreateBranchDialog(true);
|
||||
}}
|
||||
onAddressPRComments={handleAddressPRComments}
|
||||
onResolveConflicts={handleResolveConflicts}
|
||||
onRemovedWorktrees={handleRemovedWorktrees}
|
||||
runningFeatureIds={runningAutoTasks}
|
||||
branchCardCounts={branchCardCounts}
|
||||
@@ -1153,7 +1277,25 @@ export function BoardView() {
|
||||
open={showCreatePRDialog}
|
||||
onOpenChange={setShowCreatePRDialog}
|
||||
worktree={selectedWorktreeForAction}
|
||||
onCreated={() => {
|
||||
projectPath={currentProject?.path || null}
|
||||
onCreated={(prUrl) => {
|
||||
// If a PR was created and we have the worktree branch, update all features on that branch with the PR URL
|
||||
if (prUrl && selectedWorktreeForAction?.branch) {
|
||||
const branchName = selectedWorktreeForAction.branch;
|
||||
const featuresToUpdate = hookFeatures.filter((f) => f.branchName === branchName);
|
||||
|
||||
// Update local state synchronously
|
||||
featuresToUpdate.forEach((feature) => {
|
||||
updateFeature(feature.id, { prUrl });
|
||||
});
|
||||
|
||||
// Persist changes asynchronously and in parallel
|
||||
Promise.all(
|
||||
featuresToUpdate.map((feature) =>
|
||||
persistFeatureUpdate(feature.id, { prUrl })
|
||||
)
|
||||
).catch(console.error);
|
||||
}
|
||||
setWorktreeRefreshKey((k) => k + 1);
|
||||
setSelectedWorktreeForAction(null);
|
||||
}}
|
||||
|
||||
@@ -4,12 +4,13 @@ import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Plus, Users } from "lucide-react";
|
||||
import { Plus, Bot } from "lucide-react";
|
||||
import { KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts";
|
||||
|
||||
interface BoardHeaderProps {
|
||||
projectName: string;
|
||||
maxConcurrency: number;
|
||||
runningAgentsCount: number;
|
||||
onConcurrencyChange: (value: number) => void;
|
||||
isAutoModeRunning: boolean;
|
||||
onAutoModeToggle: (enabled: boolean) => void;
|
||||
@@ -21,6 +22,7 @@ interface BoardHeaderProps {
|
||||
export function BoardHeader({
|
||||
projectName,
|
||||
maxConcurrency,
|
||||
runningAgentsCount,
|
||||
onConcurrencyChange,
|
||||
isAutoModeRunning,
|
||||
onAutoModeToggle,
|
||||
@@ -41,7 +43,8 @@ export function BoardHeader({
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border"
|
||||
data-testid="concurrency-slider-container"
|
||||
>
|
||||
<Users className="w-4 h-4 text-muted-foreground" />
|
||||
<Bot className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Agents</span>
|
||||
<Slider
|
||||
value={[maxConcurrency]}
|
||||
onValueChange={(value) => onConcurrencyChange(value[0])}
|
||||
@@ -52,10 +55,10 @@ export function BoardHeader({
|
||||
data-testid="concurrency-slider"
|
||||
/>
|
||||
<span
|
||||
className="text-sm text-muted-foreground min-w-[2ch] text-center"
|
||||
className="text-sm text-muted-foreground min-w-[5ch] text-center"
|
||||
data-testid="concurrency-value"
|
||||
>
|
||||
{maxConcurrency}
|
||||
{runningAgentsCount} / {maxConcurrency}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { KanbanCard } from "./kanban-card";
|
||||
export { KanbanCard } from "./kanban-card/kanban-card";
|
||||
export { KanbanColumn } from "./kanban-column";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,283 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Feature, ThinkingLevel, useAppStore } from "@/store/app-store";
|
||||
import {
|
||||
AgentTaskInfo,
|
||||
parseAgentContext,
|
||||
formatModelName,
|
||||
DEFAULT_MODEL,
|
||||
} from "@/lib/agent-context-parser";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Cpu,
|
||||
Brain,
|
||||
ListTodo,
|
||||
Sparkles,
|
||||
Expand,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Loader2,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { SummaryDialog } from "./summary-dialog";
|
||||
|
||||
/**
|
||||
* Formats thinking level for compact display
|
||||
*/
|
||||
function formatThinkingLevel(level: ThinkingLevel | undefined): string {
|
||||
if (!level || level === "none") return "";
|
||||
const labels: Record<ThinkingLevel, string> = {
|
||||
none: "",
|
||||
low: "Low",
|
||||
medium: "Med",
|
||||
high: "High",
|
||||
ultrathink: "Ultra",
|
||||
};
|
||||
return labels[level];
|
||||
}
|
||||
|
||||
interface AgentInfoPanelProps {
|
||||
feature: Feature;
|
||||
contextContent?: string;
|
||||
summary?: string;
|
||||
isCurrentAutoTask?: boolean;
|
||||
}
|
||||
|
||||
export function AgentInfoPanel({
|
||||
feature,
|
||||
contextContent,
|
||||
summary,
|
||||
isCurrentAutoTask,
|
||||
}: AgentInfoPanelProps) {
|
||||
const { kanbanCardDetailLevel } = useAppStore();
|
||||
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
|
||||
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
|
||||
|
||||
const showAgentInfo = kanbanCardDetailLevel === "detailed";
|
||||
|
||||
useEffect(() => {
|
||||
const loadContext = async () => {
|
||||
if (contextContent) {
|
||||
const info = parseAgentContext(contextContent);
|
||||
setAgentInfo(info);
|
||||
return;
|
||||
}
|
||||
|
||||
if (feature.status === "backlog") {
|
||||
setAgentInfo(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, no-undef
|
||||
const currentProject = (window as any).__currentProject;
|
||||
if (!currentProject?.path) return;
|
||||
|
||||
if (api.features) {
|
||||
const result = await api.features.getAgentOutput(
|
||||
currentProject.path,
|
||||
feature.id
|
||||
);
|
||||
|
||||
if (result.success && result.content) {
|
||||
const info = parseAgentContext(result.content);
|
||||
setAgentInfo(info);
|
||||
}
|
||||
} else {
|
||||
const contextPath = `${currentProject.path}/.automaker/features/${feature.id}/agent-output.md`;
|
||||
const result = await api.readFile(contextPath);
|
||||
|
||||
if (result.success && result.content) {
|
||||
const info = parseAgentContext(result.content);
|
||||
setAgentInfo(info);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// eslint-disable-next-line no-undef
|
||||
console.debug("[KanbanCard] No context file for feature:", feature.id);
|
||||
}
|
||||
};
|
||||
|
||||
loadContext();
|
||||
|
||||
if (isCurrentAutoTask) {
|
||||
// eslint-disable-next-line no-undef
|
||||
const interval = setInterval(loadContext, 3000);
|
||||
return () => {
|
||||
// eslint-disable-next-line no-undef
|
||||
clearInterval(interval);
|
||||
};
|
||||
}
|
||||
}, [feature.id, feature.status, contextContent, isCurrentAutoTask]);
|
||||
// Model/Preset Info for Backlog Cards
|
||||
if (showAgentInfo && feature.status === "backlog") {
|
||||
return (
|
||||
<div className="mb-3 space-y-2 overflow-hidden">
|
||||
<div className="flex items-center gap-2 text-[11px] flex-wrap">
|
||||
<div className="flex items-center gap-1 text-[var(--status-info)]">
|
||||
<Cpu className="w-3 h-3" />
|
||||
<span className="font-medium">
|
||||
{formatModelName(feature.model ?? DEFAULT_MODEL)}
|
||||
</span>
|
||||
</div>
|
||||
{feature.thinkingLevel && feature.thinkingLevel !== "none" && (
|
||||
<div className="flex items-center gap-1 text-purple-400">
|
||||
<Brain className="w-3 h-3" />
|
||||
<span className="font-medium">
|
||||
{formatThinkingLevel(feature.thinkingLevel)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Agent Info Panel for non-backlog cards
|
||||
if (showAgentInfo && feature.status !== "backlog" && agentInfo) {
|
||||
return (
|
||||
<div className="mb-3 space-y-2 overflow-hidden">
|
||||
{/* Model & Phase */}
|
||||
<div className="flex items-center gap-2 text-[11px] flex-wrap">
|
||||
<div className="flex items-center gap-1 text-[var(--status-info)]">
|
||||
<Cpu className="w-3 h-3" />
|
||||
<span className="font-medium">
|
||||
{formatModelName(feature.model ?? DEFAULT_MODEL)}
|
||||
</span>
|
||||
</div>
|
||||
{agentInfo.currentPhase && (
|
||||
<div
|
||||
className={cn(
|
||||
"px-1.5 py-0.5 rounded-md text-[10px] font-medium",
|
||||
agentInfo.currentPhase === "planning" &&
|
||||
"bg-[var(--status-info-bg)] text-[var(--status-info)]",
|
||||
agentInfo.currentPhase === "action" &&
|
||||
"bg-[var(--status-warning-bg)] text-[var(--status-warning)]",
|
||||
agentInfo.currentPhase === "verification" &&
|
||||
"bg-[var(--status-success-bg)] text-[var(--status-success)]"
|
||||
)}
|
||||
>
|
||||
{agentInfo.currentPhase}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task List Progress */}
|
||||
{agentInfo.todos.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
|
||||
<ListTodo className="w-3 h-3" />
|
||||
<span>
|
||||
{agentInfo.todos.filter((t) => t.status === "completed").length}
|
||||
/{agentInfo.todos.length} tasks
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-0.5 max-h-16 overflow-y-auto">
|
||||
{agentInfo.todos.slice(0, 3).map((todo, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center gap-1.5 text-[10px]"
|
||||
>
|
||||
{todo.status === "completed" ? (
|
||||
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
|
||||
) : todo.status === "in_progress" ? (
|
||||
<Loader2 className="w-2.5 h-2.5 text-[var(--status-warning)] animate-spin shrink-0" />
|
||||
) : (
|
||||
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"break-words hyphens-auto line-clamp-2 leading-relaxed",
|
||||
todo.status === "completed" &&
|
||||
"text-muted-foreground/60 line-through",
|
||||
todo.status === "in_progress" &&
|
||||
"text-[var(--status-warning)]",
|
||||
todo.status === "pending" && "text-muted-foreground/80"
|
||||
)}
|
||||
>
|
||||
{todo.content}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{agentInfo.todos.length > 3 && (
|
||||
<p className="text-[10px] text-muted-foreground/60 pl-4">
|
||||
+{agentInfo.todos.length - 3} more
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary for waiting_approval and verified */}
|
||||
{(feature.status === "waiting_approval" ||
|
||||
feature.status === "verified") && (
|
||||
<>
|
||||
{(feature.summary || summary || agentInfo.summary) && (
|
||||
<div className="space-y-1.5 pt-2 border-t border-border/30 overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1 text-[10px] text-[var(--status-success)] min-w-0">
|
||||
<Sparkles className="w-3 h-3 shrink-0" />
|
||||
<span className="truncate font-medium">Summary</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsSummaryDialogOpen(true);
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
className="p-0.5 rounded-md hover:bg-muted/80 transition-colors text-muted-foreground/60 hover:text-muted-foreground shrink-0"
|
||||
title="View full summary"
|
||||
data-testid={`expand-summary-${feature.id}`}
|
||||
>
|
||||
<Expand className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground/70 line-clamp-3 break-words hyphens-auto leading-relaxed overflow-hidden">
|
||||
{feature.summary || summary || agentInfo.summary}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!feature.summary &&
|
||||
!summary &&
|
||||
!agentInfo.summary &&
|
||||
agentInfo.toolCallCount > 0 && (
|
||||
<div className="flex items-center gap-2 text-[10px] text-muted-foreground/60 pt-2 border-t border-border/30">
|
||||
<span className="flex items-center gap-1">
|
||||
<Wrench className="w-2.5 h-2.5" />
|
||||
{agentInfo.toolCallCount} tool calls
|
||||
</span>
|
||||
{agentInfo.todos.length > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)]" />
|
||||
{
|
||||
agentInfo.todos.filter((t) => t.status === "completed")
|
||||
.length
|
||||
}{" "}
|
||||
tasks done
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Always render SummaryDialog if showAgentInfo is true (even if no agentInfo yet)
|
||||
// This ensures the dialog can be opened from the expand button
|
||||
return (
|
||||
<>
|
||||
{showAgentInfo && (
|
||||
<SummaryDialog
|
||||
feature={feature}
|
||||
agentInfo={agentInfo}
|
||||
summary={summary}
|
||||
isOpen={isSummaryDialogOpen}
|
||||
onOpenChange={setIsSummaryDialogOpen}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { Feature } from "@/store/app-store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Edit,
|
||||
PlayCircle,
|
||||
RotateCcw,
|
||||
StopCircle,
|
||||
CheckCircle2,
|
||||
FileText,
|
||||
Eye,
|
||||
Wand2,
|
||||
Archive,
|
||||
} from "lucide-react";
|
||||
|
||||
interface CardActionsProps {
|
||||
feature: Feature;
|
||||
isCurrentAutoTask: boolean;
|
||||
hasContext?: boolean;
|
||||
shortcutKey?: string;
|
||||
onEdit: () => void;
|
||||
onViewOutput?: () => void;
|
||||
onVerify?: () => void;
|
||||
onResume?: () => void;
|
||||
onForceStop?: () => void;
|
||||
onManualVerify?: () => void;
|
||||
onFollowUp?: () => void;
|
||||
onImplement?: () => void;
|
||||
onComplete?: () => void;
|
||||
onViewPlan?: () => void;
|
||||
onApprovePlan?: () => void;
|
||||
}
|
||||
|
||||
export function CardActions({
|
||||
feature,
|
||||
isCurrentAutoTask,
|
||||
hasContext,
|
||||
shortcutKey,
|
||||
onEdit,
|
||||
onViewOutput,
|
||||
onVerify,
|
||||
onResume,
|
||||
onForceStop,
|
||||
onManualVerify,
|
||||
onFollowUp,
|
||||
onImplement,
|
||||
onComplete,
|
||||
onViewPlan,
|
||||
onApprovePlan,
|
||||
}: CardActionsProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5 -mx-3 -mb-3 px-3 pb-3">
|
||||
{isCurrentAutoTask && (
|
||||
<>
|
||||
{/* Approve Plan button - PRIORITY: shows even when agent is "running" (paused for approval) */}
|
||||
{feature.planSpec?.status === "generated" && onApprovePlan && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1 min-w-0 h-7 text-[11px] bg-purple-600 hover:bg-purple-700 text-white animate-pulse"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onApprovePlan();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`approve-plan-running-${feature.id}`}
|
||||
>
|
||||
<FileText className="w-3 h-3 mr-1 shrink-0" />
|
||||
<span className="truncate">Approve Plan</span>
|
||||
</Button>
|
||||
)}
|
||||
{onViewOutput && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-[11px]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewOutput();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`view-output-${feature.id}`}
|
||||
>
|
||||
<FileText className="w-3 h-3 mr-1 shrink-0" />
|
||||
<span className="truncate">Logs</span>
|
||||
{shortcutKey && (
|
||||
<span
|
||||
className="ml-1.5 px-1 py-0.5 text-[9px] font-mono rounded bg-foreground/10"
|
||||
data-testid={`shortcut-key-${feature.id}`}
|
||||
>
|
||||
{shortcutKey}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{onForceStop && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-7 text-[11px] px-2 shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onForceStop();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`force-stop-${feature.id}`}
|
||||
>
|
||||
<StopCircle className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!isCurrentAutoTask && feature.status === "in_progress" && (
|
||||
<>
|
||||
{/* Approve Plan button - shows when plan is generated and waiting for approval */}
|
||||
{feature.planSpec?.status === "generated" && onApprovePlan && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-[11px] bg-purple-600 hover:bg-purple-700 text-white animate-pulse"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onApprovePlan();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`approve-plan-${feature.id}`}
|
||||
>
|
||||
<FileText className="w-3 h-3 mr-1" />
|
||||
Approve Plan
|
||||
</Button>
|
||||
)}
|
||||
{feature.skipTests && onManualVerify ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-[11px]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onManualVerify();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`manual-verify-${feature.id}`}
|
||||
>
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||
Verify
|
||||
</Button>
|
||||
) : hasContext && onResume ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-[11px] bg-[var(--status-success)] hover:bg-[var(--status-success)]/90"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onResume();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`resume-feature-${feature.id}`}
|
||||
>
|
||||
<RotateCcw className="w-3 h-3 mr-1" />
|
||||
Resume
|
||||
</Button>
|
||||
) : onVerify ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-[11px] bg-[var(--status-success)] hover:bg-[var(--status-success)]/90"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onVerify();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`verify-feature-${feature.id}`}
|
||||
>
|
||||
<PlayCircle className="w-3 h-3 mr-1" />
|
||||
Resume
|
||||
</Button>
|
||||
) : null}
|
||||
{onViewOutput && !feature.skipTests && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-7 text-[11px] px-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewOutput();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`view-output-inprogress-${feature.id}`}
|
||||
>
|
||||
<FileText className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!isCurrentAutoTask && feature.status === "verified" && (
|
||||
<>
|
||||
{/* Logs button */}
|
||||
{onViewOutput && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-xs min-w-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewOutput();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`view-output-verified-${feature.id}`}
|
||||
>
|
||||
<FileText className="w-3 h-3 mr-1 shrink-0" />
|
||||
<span className="truncate">Logs</span>
|
||||
</Button>
|
||||
)}
|
||||
{/* Complete button */}
|
||||
{onComplete && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-xs min-w-0 bg-brand-500 hover:bg-brand-600"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onComplete();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`complete-${feature.id}`}
|
||||
>
|
||||
<Archive className="w-3 h-3 mr-1 shrink-0" />
|
||||
<span className="truncate">Complete</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!isCurrentAutoTask && feature.status === "waiting_approval" && (
|
||||
<>
|
||||
{/* Refine prompt button */}
|
||||
{onFollowUp && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-[11px] min-w-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onFollowUp();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`follow-up-${feature.id}`}
|
||||
>
|
||||
<Wand2 className="w-3 h-3 mr-1 shrink-0" />
|
||||
<span className="truncate">Refine</span>
|
||||
</Button>
|
||||
)}
|
||||
{/* Show Verify button if PR was created (changes are committed), otherwise show Mark as Verified button */}
|
||||
{feature.prUrl && onManualVerify ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-[11px]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onManualVerify();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`verify-${feature.id}`}
|
||||
>
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||
Verify
|
||||
</Button>
|
||||
) : onManualVerify ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-[11px]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onManualVerify();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`mark-as-verified-${feature.id}`}
|
||||
>
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||
Mark as Verified
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{!isCurrentAutoTask && feature.status === "backlog" && (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`edit-backlog-${feature.id}`}
|
||||
>
|
||||
<Edit className="w-3 h-3 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
{feature.planSpec?.content && onViewPlan && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs px-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewPlan();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`view-plan-${feature.id}`}
|
||||
title="View Plan"
|
||||
>
|
||||
<Eye className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
{onImplement && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onImplement();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`make-${feature.id}`}
|
||||
>
|
||||
<PlayCircle className="w-3 h-3 mr-1" />
|
||||
Make
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Feature, useAppStore } from "@/store/app-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { AlertCircle, Lock, Hand, Sparkles } from "lucide-react";
|
||||
import { getBlockingDependencies } from "@/lib/dependency-resolver";
|
||||
|
||||
interface CardBadgeProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
"data-testid"?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared badge component matching the "Just Finished" badge style
|
||||
* Used for priority badges and other card badges
|
||||
*/
|
||||
function CardBadge({
|
||||
children,
|
||||
className,
|
||||
"data-testid": dataTestId,
|
||||
title,
|
||||
}: CardBadgeProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium",
|
||||
className
|
||||
)}
|
||||
data-testid={dataTestId}
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardBadgesProps {
|
||||
feature: Feature;
|
||||
}
|
||||
|
||||
export function CardBadges({ feature }: CardBadgesProps) {
|
||||
const { enableDependencyBlocking, features } = useAppStore();
|
||||
|
||||
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
|
||||
const blockingDependencies = useMemo(() => {
|
||||
if (!enableDependencyBlocking || feature.status !== "backlog") {
|
||||
return [];
|
||||
}
|
||||
return getBlockingDependencies(feature, features);
|
||||
}, [enableDependencyBlocking, feature, features]);
|
||||
|
||||
// Status badges row (error, blocked)
|
||||
const showStatusBadges =
|
||||
feature.error ||
|
||||
(blockingDependencies.length > 0 &&
|
||||
!feature.error &&
|
||||
!feature.skipTests &&
|
||||
feature.status === "backlog");
|
||||
|
||||
if (!showStatusBadges) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5 px-3 pt-1.5 min-h-[24px]">
|
||||
{/* Error badge */}
|
||||
{feature.error && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium",
|
||||
"bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]"
|
||||
)}
|
||||
data-testid={`error-badge-${feature.id}`}
|
||||
>
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
|
||||
<p>{feature.error}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{/* Blocked badge */}
|
||||
{blockingDependencies.length > 0 &&
|
||||
!feature.error &&
|
||||
!feature.skipTests &&
|
||||
feature.status === "backlog" && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border-2 px-1.5 py-0.5 text-[10px] font-bold",
|
||||
"bg-orange-500/20 border-orange-500/50 text-orange-500"
|
||||
)}
|
||||
data-testid={`blocked-badge-${feature.id}`}
|
||||
>
|
||||
<Lock className="w-3 h-3" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
|
||||
<p className="font-medium mb-1">
|
||||
Blocked by {blockingDependencies.length} incomplete{" "}
|
||||
{blockingDependencies.length === 1
|
||||
? "dependency"
|
||||
: "dependencies"}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{blockingDependencies
|
||||
.map((depId) => {
|
||||
const dep = features.find((f) => f.id === depId);
|
||||
return dep?.description || depId;
|
||||
})
|
||||
.join(", ")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PriorityBadgesProps {
|
||||
feature: Feature;
|
||||
}
|
||||
|
||||
export function PriorityBadges({ feature }: PriorityBadgesProps) {
|
||||
const [currentTime, setCurrentTime] = useState(() => Date.now());
|
||||
|
||||
const isJustFinished = useMemo(() => {
|
||||
if (
|
||||
!feature.justFinishedAt ||
|
||||
feature.status !== "waiting_approval" ||
|
||||
feature.error
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const finishedTime = new Date(feature.justFinishedAt).getTime();
|
||||
const twoMinutes = 2 * 60 * 1000;
|
||||
return currentTime - finishedTime < twoMinutes;
|
||||
}, [feature.justFinishedAt, feature.status, feature.error, currentTime]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!feature.justFinishedAt || feature.status !== "waiting_approval") {
|
||||
return;
|
||||
}
|
||||
|
||||
const finishedTime = new Date(feature.justFinishedAt).getTime();
|
||||
const twoMinutes = 2 * 60 * 1000;
|
||||
const timeRemaining = twoMinutes - (currentTime - finishedTime);
|
||||
|
||||
if (timeRemaining <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
const interval = setInterval(() => {
|
||||
setCurrentTime(Date.now());
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
// eslint-disable-next-line no-undef
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [feature.justFinishedAt, feature.status, currentTime]);
|
||||
|
||||
const showPriorityBadges =
|
||||
feature.priority ||
|
||||
(feature.skipTests && !feature.error && feature.status === "backlog") ||
|
||||
isJustFinished;
|
||||
|
||||
if (!showPriorityBadges) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute top-2 left-2 flex items-center gap-1.5">
|
||||
{/* Priority badge */}
|
||||
{feature.priority && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<CardBadge
|
||||
className={cn(
|
||||
"bg-opacity-90 border rounded-[6px] px-1.5 py-0.5 flex items-center justify-center border-[1.5px] w-5 h-5", // badge style from example
|
||||
feature.priority === 1 &&
|
||||
"bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]",
|
||||
feature.priority === 2 &&
|
||||
"bg-[var(--status-warning-bg)] border-[var(--status-warning)]/40 text-[var(--status-warning)]",
|
||||
feature.priority === 3 &&
|
||||
"bg-[var(--status-info-bg)] border-[var(--status-info)]/40 text-[var(--status-info)]"
|
||||
)}
|
||||
data-testid={`priority-badge-${feature.id}`}
|
||||
>
|
||||
{feature.priority === 1 ? (
|
||||
<span className="font-bold text-xs flex items-center gap-0.5">
|
||||
H
|
||||
</span>
|
||||
) : feature.priority === 2 ? (
|
||||
<span className="font-bold text-xs flex items-center gap-0.5">
|
||||
M
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-bold text-xs flex items-center gap-0.5">
|
||||
L
|
||||
</span>
|
||||
)}
|
||||
</CardBadge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
<p>
|
||||
{feature.priority === 1
|
||||
? "High Priority"
|
||||
: feature.priority === 2
|
||||
? "Medium Priority"
|
||||
: "Low Priority"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{/* Manual verification badge */}
|
||||
{feature.skipTests && !feature.error && feature.status === "backlog" && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<CardBadge
|
||||
className="bg-[var(--status-warning-bg)] border-[var(--status-warning)]/40 text-[var(--status-warning)]"
|
||||
data-testid={`skip-tests-badge-${feature.id}`}
|
||||
>
|
||||
<Hand className="w-3 h-3" />
|
||||
</CardBadge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
<p>Manual verification required</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{/* Just Finished badge */}
|
||||
{isJustFinished && (
|
||||
<CardBadge
|
||||
className="bg-[var(--status-success-bg)] border-[var(--status-success)]/40 text-[var(--status-success)] animate-pulse"
|
||||
data-testid={`just-finished-badge-${feature.id}`}
|
||||
title="Agent just finished working on this feature"
|
||||
>
|
||||
<Sparkles className="w-3 h-3" />
|
||||
</CardBadge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Feature } from "@/store/app-store";
|
||||
import { GitBranch, GitPullRequest, ExternalLink, CheckCircle2, Circle } from "lucide-react";
|
||||
|
||||
interface CardContentSectionsProps {
|
||||
feature: Feature;
|
||||
useWorktrees: boolean;
|
||||
showSteps: boolean;
|
||||
}
|
||||
|
||||
export function CardContentSections({
|
||||
feature,
|
||||
useWorktrees,
|
||||
showSteps,
|
||||
}: CardContentSectionsProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Target Branch Display */}
|
||||
{useWorktrees && feature.branchName && (
|
||||
<div className="mb-2 flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<GitBranch className="w-3 h-3 shrink-0" />
|
||||
<span className="font-mono truncate" title={feature.branchName}>
|
||||
{feature.branchName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PR URL Display */}
|
||||
{typeof feature.prUrl === "string" &&
|
||||
/^https?:\/\//i.test(feature.prUrl) &&
|
||||
(() => {
|
||||
const prNumber = feature.prUrl.split("/").pop();
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<a
|
||||
href={feature.prUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-1.5 text-[11px] text-purple-500 hover:text-purple-400 transition-colors"
|
||||
title={feature.prUrl}
|
||||
data-testid={`pr-url-${feature.id}`}
|
||||
>
|
||||
<GitPullRequest className="w-3 h-3 shrink-0" />
|
||||
<span className="truncate max-w-[150px]">
|
||||
{prNumber ? `Pull Request #${prNumber}` : "Pull Request"}
|
||||
</span>
|
||||
<ExternalLink className="w-2.5 h-2.5 shrink-0" />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Steps Preview */}
|
||||
{showSteps && feature.steps && feature.steps.length > 0 && (
|
||||
<div className="mb-3 space-y-1.5">
|
||||
{feature.steps.slice(0, 3).map((step, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-start gap-2 text-[11px] text-muted-foreground/80"
|
||||
>
|
||||
{feature.status === "verified" ? (
|
||||
<CheckCircle2 className="w-3 h-3 mt-0.5 text-[var(--status-success)] shrink-0" />
|
||||
) : (
|
||||
<Circle className="w-3 h-3 mt-0.5 shrink-0 text-muted-foreground/50" />
|
||||
)}
|
||||
<span className="break-words hyphens-auto line-clamp-2 leading-relaxed">
|
||||
{step}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{feature.steps.length > 3 && (
|
||||
<p className="text-[10px] text-muted-foreground/60 pl-5">
|
||||
+{feature.steps.length - 3} more
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import { useState } from "react";
|
||||
import { Feature } from "@/store/app-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
GripVertical,
|
||||
Edit,
|
||||
Loader2,
|
||||
Trash2,
|
||||
FileText,
|
||||
MoreVertical,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Cpu,
|
||||
} from "lucide-react";
|
||||
import { CountUpTimer } from "@/components/ui/count-up-timer";
|
||||
import { formatModelName, DEFAULT_MODEL } from "@/lib/agent-context-parser";
|
||||
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
||||
|
||||
interface CardHeaderProps {
|
||||
feature: Feature;
|
||||
isDraggable: boolean;
|
||||
isCurrentAutoTask: boolean;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onViewOutput?: () => void;
|
||||
}
|
||||
|
||||
export function CardHeaderSection({
|
||||
feature,
|
||||
isDraggable,
|
||||
isCurrentAutoTask,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onViewOutput,
|
||||
}: CardHeaderProps) {
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
|
||||
const handleDeleteClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
onDelete();
|
||||
};
|
||||
|
||||
return (
|
||||
<CardHeader className="p-3 pb-2 block">
|
||||
{/* Running task header */}
|
||||
{isCurrentAutoTask && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1">
|
||||
<div className="flex items-center justify-center gap-2 bg-[var(--status-in-progress)]/15 border border-[var(--status-in-progress)]/50 rounded-md px-2 py-0.5">
|
||||
<Loader2 className="w-3.5 h-3.5 text-[var(--status-in-progress)] animate-spin" />
|
||||
{feature.startedAt && (
|
||||
<CountUpTimer
|
||||
startedAt={feature.startedAt}
|
||||
className="text-[var(--status-in-progress)] text-[10px]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:bg-muted/80 rounded-md"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`menu-running-${feature.id}`}
|
||||
>
|
||||
<MoreVertical className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-36">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
data-testid={`edit-running-${feature.id}`}
|
||||
className="text-xs"
|
||||
>
|
||||
<Edit className="w-3 h-3 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
{/* Model info in dropdown */}
|
||||
<div className="px-2 py-1.5 text-[10px] text-muted-foreground border-t mt-1 pt-1.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<Cpu className="w-3 h-3" />
|
||||
<span>
|
||||
{formatModelName(feature.model ?? DEFAULT_MODEL)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Backlog header */}
|
||||
{!isCurrentAutoTask && feature.status === "backlog" && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-destructive"
|
||||
onClick={handleDeleteClick}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`delete-backlog-${feature.id}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Waiting approval / Verified header */}
|
||||
{!isCurrentAutoTask &&
|
||||
(feature.status === "waiting_approval" ||
|
||||
feature.status === "verified") && (
|
||||
<>
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`edit-${
|
||||
feature.status === "waiting_approval"
|
||||
? "waiting"
|
||||
: "verified"
|
||||
}-${feature.id}`}
|
||||
title="Edit"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
{onViewOutput && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewOutput();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`logs-${
|
||||
feature.status === "waiting_approval"
|
||||
? "waiting"
|
||||
: "verified"
|
||||
}-${feature.id}`}
|
||||
title="Logs"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-destructive"
|
||||
onClick={handleDeleteClick}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`delete-${
|
||||
feature.status === "waiting_approval"
|
||||
? "waiting"
|
||||
: "verified"
|
||||
}-${feature.id}`}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* In progress header */}
|
||||
{!isCurrentAutoTask && feature.status === "in_progress" && (
|
||||
<>
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:bg-white/10 text-muted-foreground hover:text-destructive"
|
||||
onClick={handleDeleteClick}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`delete-feature-${feature.id}`}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:bg-muted/80 rounded-md"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`menu-${feature.id}`}
|
||||
>
|
||||
<MoreVertical className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-36">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
data-testid={`edit-feature-${feature.id}`}
|
||||
className="text-xs"
|
||||
>
|
||||
<Edit className="w-3 h-3 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
{onViewOutput && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewOutput();
|
||||
}}
|
||||
data-testid={`view-logs-${feature.id}`}
|
||||
className="text-xs"
|
||||
>
|
||||
<FileText className="w-3 h-3 mr-2" />
|
||||
View Logs
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* Model info in dropdown */}
|
||||
<div className="px-2 py-1.5 text-[10px] text-muted-foreground border-t mt-1 pt-1.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<Cpu className="w-3 h-3" />
|
||||
<span>
|
||||
{formatModelName(feature.model ?? DEFAULT_MODEL)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Title and description */}
|
||||
<div className="flex items-start gap-2">
|
||||
{isDraggable && (
|
||||
<div
|
||||
className="-ml-2 -mt-1 p-2 touch-none opacity-40 hover:opacity-70 transition-opacity"
|
||||
data-testid={`drag-handle-${feature.id}`}
|
||||
>
|
||||
<GripVertical className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
{feature.titleGenerating ? (
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground italic">
|
||||
Generating title...
|
||||
</span>
|
||||
</div>
|
||||
) : feature.title ? (
|
||||
<CardTitle className="text-sm font-semibold text-foreground mb-1 line-clamp-2">
|
||||
{feature.title}
|
||||
</CardTitle>
|
||||
) : null}
|
||||
<CardDescription
|
||||
className={cn(
|
||||
"text-xs leading-snug break-words hyphens-auto overflow-hidden text-muted-foreground",
|
||||
!isDescriptionExpanded && "line-clamp-3"
|
||||
)}
|
||||
>
|
||||
{feature.description || feature.summary || feature.id}
|
||||
</CardDescription>
|
||||
{(feature.description || feature.summary || "").length > 100 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDescriptionExpanded(!isDescriptionExpanded);
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
className="flex items-center gap-0.5 text-[10px] text-muted-foreground/70 hover:text-muted-foreground mt-1.5 transition-colors"
|
||||
data-testid={`toggle-description-${feature.id}`}
|
||||
>
|
||||
{isDescriptionExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
<span>Less</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
<span>More</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<DeleteConfirmDialog
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setIsDeleteDialogOpen}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="Delete Feature"
|
||||
description="Are you sure you want to delete this feature? This action cannot be undone."
|
||||
testId="delete-confirmation-dialog"
|
||||
confirmTestId="confirm-delete-button"
|
||||
/>
|
||||
</CardHeader>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import React, { memo } from "react";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Feature, useAppStore } from "@/store/app-store";
|
||||
import { CardBadges, PriorityBadges } from "./card-badges";
|
||||
import { CardHeaderSection } from "./card-header";
|
||||
import { CardContentSections } from "./card-content-sections";
|
||||
import { AgentInfoPanel } from "./agent-info-panel";
|
||||
import { CardActions } from "./card-actions";
|
||||
|
||||
interface KanbanCardProps {
|
||||
feature: Feature;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onViewOutput?: () => void;
|
||||
onVerify?: () => void;
|
||||
onResume?: () => void;
|
||||
onForceStop?: () => void;
|
||||
onManualVerify?: () => void;
|
||||
onMoveBackToInProgress?: () => void;
|
||||
onFollowUp?: () => void;
|
||||
onImplement?: () => void;
|
||||
onComplete?: () => void;
|
||||
onViewPlan?: () => void;
|
||||
onApprovePlan?: () => void;
|
||||
hasContext?: boolean;
|
||||
isCurrentAutoTask?: boolean;
|
||||
shortcutKey?: string;
|
||||
contextContent?: string;
|
||||
summary?: string;
|
||||
opacity?: number;
|
||||
glassmorphism?: boolean;
|
||||
cardBorderEnabled?: boolean;
|
||||
cardBorderOpacity?: number;
|
||||
}
|
||||
|
||||
export const KanbanCard = memo(function KanbanCard({
|
||||
feature,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onViewOutput,
|
||||
onVerify,
|
||||
onResume,
|
||||
onForceStop,
|
||||
onManualVerify,
|
||||
onMoveBackToInProgress: _onMoveBackToInProgress,
|
||||
onFollowUp,
|
||||
onImplement,
|
||||
onComplete,
|
||||
onViewPlan,
|
||||
onApprovePlan,
|
||||
hasContext,
|
||||
isCurrentAutoTask,
|
||||
shortcutKey,
|
||||
contextContent,
|
||||
summary,
|
||||
opacity = 100,
|
||||
glassmorphism = true,
|
||||
cardBorderEnabled = true,
|
||||
cardBorderOpacity = 100,
|
||||
}: KanbanCardProps) {
|
||||
const { kanbanCardDetailLevel, useWorktrees } = useAppStore();
|
||||
|
||||
const showSteps =
|
||||
kanbanCardDetailLevel === "standard" ||
|
||||
kanbanCardDetailLevel === "detailed";
|
||||
|
||||
const isDraggable =
|
||||
feature.status === "backlog" ||
|
||||
feature.status === "waiting_approval" ||
|
||||
feature.status === "verified" ||
|
||||
(feature.status === "in_progress" && !isCurrentAutoTask);
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: feature.id,
|
||||
disabled: !isDraggable,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : undefined,
|
||||
};
|
||||
|
||||
const borderStyle: React.CSSProperties = { ...style };
|
||||
if (!cardBorderEnabled) {
|
||||
(borderStyle as Record<string, string>).borderWidth = "0px";
|
||||
(borderStyle as Record<string, string>).borderColor = "transparent";
|
||||
} else if (cardBorderOpacity !== 100) {
|
||||
(borderStyle as Record<string, string>).borderWidth = "1px";
|
||||
(borderStyle as Record<string, string>).borderColor =
|
||||
`color-mix(in oklch, var(--border) ${cardBorderOpacity}%, transparent)`;
|
||||
}
|
||||
|
||||
const cardElement = (
|
||||
<Card
|
||||
ref={setNodeRef}
|
||||
style={isCurrentAutoTask ? style : borderStyle}
|
||||
className={cn(
|
||||
"cursor-grab active:cursor-grabbing relative kanban-card-content select-none",
|
||||
"transition-all duration-200 ease-out",
|
||||
// Premium shadow system
|
||||
"shadow-sm hover:shadow-md hover:shadow-black/10",
|
||||
// Subtle lift on hover
|
||||
"hover:-translate-y-0.5",
|
||||
!isCurrentAutoTask &&
|
||||
cardBorderEnabled &&
|
||||
cardBorderOpacity === 100 &&
|
||||
"border-border/50",
|
||||
!isCurrentAutoTask &&
|
||||
cardBorderEnabled &&
|
||||
cardBorderOpacity !== 100 &&
|
||||
"border",
|
||||
!isDragging && "bg-transparent",
|
||||
!glassmorphism && "backdrop-blur-[0px]!",
|
||||
isDragging && "scale-105 shadow-xl shadow-black/20 rotate-1",
|
||||
// Error state - using CSS variable
|
||||
feature.error &&
|
||||
!isCurrentAutoTask &&
|
||||
"border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg",
|
||||
!isDraggable && "cursor-default"
|
||||
)}
|
||||
data-testid={`kanban-card-${feature.id}`}
|
||||
onDoubleClick={onEdit}
|
||||
{...attributes}
|
||||
{...(isDraggable ? listeners : {})}
|
||||
>
|
||||
{/* Background overlay with opacity */}
|
||||
{!isDragging && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 rounded-xl bg-card -z-10",
|
||||
glassmorphism && "backdrop-blur-sm"
|
||||
)}
|
||||
style={{ opacity: opacity / 100 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Status Badges Row */}
|
||||
<CardBadges feature={feature} />
|
||||
|
||||
{/* Category row */}
|
||||
<div className="px-3 pt-4">
|
||||
<span className="text-[11px] text-muted-foreground/70 font-medium">
|
||||
{feature.category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Priority and Manual Verification badges */}
|
||||
<PriorityBadges feature={feature} />
|
||||
|
||||
{/* Card Header */}
|
||||
<CardHeaderSection
|
||||
feature={feature}
|
||||
isDraggable={isDraggable}
|
||||
isCurrentAutoTask={!!isCurrentAutoTask}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onViewOutput={onViewOutput}
|
||||
/>
|
||||
|
||||
<CardContent className="px-3 pt-0 pb-0">
|
||||
{/* Content Sections */}
|
||||
<CardContentSections
|
||||
feature={feature}
|
||||
useWorktrees={useWorktrees}
|
||||
showSteps={showSteps}
|
||||
/>
|
||||
|
||||
{/* Agent Info Panel */}
|
||||
<AgentInfoPanel
|
||||
feature={feature}
|
||||
contextContent={contextContent}
|
||||
summary={summary}
|
||||
isCurrentAutoTask={isCurrentAutoTask}
|
||||
/>
|
||||
|
||||
{/* Actions */}
|
||||
<CardActions
|
||||
feature={feature}
|
||||
isCurrentAutoTask={!!isCurrentAutoTask}
|
||||
hasContext={hasContext}
|
||||
shortcutKey={shortcutKey}
|
||||
onEdit={onEdit}
|
||||
onViewOutput={onViewOutput}
|
||||
onVerify={onVerify}
|
||||
onResume={onResume}
|
||||
onForceStop={onForceStop}
|
||||
onManualVerify={onManualVerify}
|
||||
onFollowUp={onFollowUp}
|
||||
onImplement={onImplement}
|
||||
onComplete={onComplete}
|
||||
onViewPlan={onViewPlan}
|
||||
onApprovePlan={onApprovePlan}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
// Wrap with animated border when in progress
|
||||
if (isCurrentAutoTask) {
|
||||
return <div className="animated-border-wrapper">{cardElement}</div>;
|
||||
}
|
||||
|
||||
return cardElement;
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Feature } from "@/store/app-store";
|
||||
import { AgentTaskInfo } from "@/lib/agent-context-parser";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Markdown } from "@/components/ui/markdown";
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
interface SummaryDialogProps {
|
||||
feature: Feature;
|
||||
agentInfo: AgentTaskInfo | null;
|
||||
summary?: string;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function SummaryDialog({
|
||||
feature,
|
||||
agentInfo,
|
||||
summary,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
}: SummaryDialogProps) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col"
|
||||
data-testid={`summary-dialog-${feature.id}`}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 text-[var(--status-success)]" />
|
||||
Implementation Summary
|
||||
</DialogTitle>
|
||||
<DialogDescription
|
||||
className="text-sm"
|
||||
title={feature.description || feature.summary || ""}
|
||||
>
|
||||
{(() => {
|
||||
const displayText =
|
||||
feature.description || feature.summary || "No description";
|
||||
return displayText.length > 100
|
||||
? `${displayText.slice(0, 100)}...`
|
||||
: displayText;
|
||||
})()}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto p-4 bg-card rounded-lg border border-border/50">
|
||||
<Markdown>
|
||||
{feature.summary ||
|
||||
summary ||
|
||||
agentInfo?.summary ||
|
||||
"No summary available"}
|
||||
</Markdown>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
data-testid="close-summary-button"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
|
||||
import {
|
||||
@@ -58,6 +59,7 @@ interface AddFeatureDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onAdd: (feature: {
|
||||
title: string;
|
||||
category: string;
|
||||
description: string;
|
||||
steps: string[];
|
||||
@@ -99,6 +101,7 @@ export function AddFeatureDialog({
|
||||
const navigate = useNavigate();
|
||||
const [useCurrentBranch, setUseCurrentBranch] = useState(true);
|
||||
const [newFeature, setNewFeature] = useState({
|
||||
title: "",
|
||||
category: "",
|
||||
description: "",
|
||||
steps: [""],
|
||||
@@ -126,16 +129,25 @@ export function AddFeatureDialog({
|
||||
enhancementModel,
|
||||
defaultPlanningMode,
|
||||
defaultRequirePlanApproval,
|
||||
defaultAIProfileId,
|
||||
useWorktrees,
|
||||
} = useAppStore();
|
||||
|
||||
// Sync defaults when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// Find the default profile if one is set
|
||||
const defaultProfile = defaultAIProfileId
|
||||
? aiProfiles.find((p) => p.id === defaultAIProfileId)
|
||||
: null;
|
||||
|
||||
setNewFeature((prev) => ({
|
||||
...prev,
|
||||
skipTests: defaultSkipTests,
|
||||
branchName: defaultBranch || "",
|
||||
// Use default profile's model/thinkingLevel if set, else fallback to defaults
|
||||
model: defaultProfile?.model ?? "opus",
|
||||
thinkingLevel: defaultProfile?.thinkingLevel ?? "none",
|
||||
}));
|
||||
setUseCurrentBranch(true);
|
||||
setPlanningMode(defaultPlanningMode);
|
||||
@@ -147,6 +159,8 @@ export function AddFeatureDialog({
|
||||
defaultBranch,
|
||||
defaultPlanningMode,
|
||||
defaultRequirePlanApproval,
|
||||
defaultAIProfileId,
|
||||
aiProfiles,
|
||||
]);
|
||||
|
||||
const handleAdd = () => {
|
||||
@@ -175,6 +189,7 @@ export function AddFeatureDialog({
|
||||
: newFeature.branchName || "";
|
||||
|
||||
onAdd({
|
||||
title: newFeature.title,
|
||||
category,
|
||||
description: newFeature.description,
|
||||
steps: newFeature.steps.filter((s) => s.trim()),
|
||||
@@ -191,6 +206,7 @@ export function AddFeatureDialog({
|
||||
|
||||
// Reset form
|
||||
setNewFeature({
|
||||
title: "",
|
||||
category: "",
|
||||
description: "",
|
||||
steps: [""],
|
||||
@@ -339,6 +355,17 @@ export function AddFeatureDialog({
|
||||
error={descriptionError}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title (optional)</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={newFeature.title}
|
||||
onChange={(e) =>
|
||||
setNewFeature({ ...newFeature, title: e.target.value })
|
||||
}
|
||||
placeholder="Leave blank to auto-generate"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-fit items-center gap-3 select-none cursor-default">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -29,13 +29,15 @@ interface CreatePRDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
worktree: WorktreeInfo | null;
|
||||
onCreated: () => void;
|
||||
projectPath: string | null;
|
||||
onCreated: (prUrl?: string) => void;
|
||||
}
|
||||
|
||||
export function CreatePRDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
worktree,
|
||||
projectPath,
|
||||
onCreated,
|
||||
}: CreatePRDialogProps) {
|
||||
const [title, setTitle] = useState("");
|
||||
@@ -96,6 +98,7 @@ export function CreatePRDialog({
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.createPR(worktree.path, {
|
||||
projectPath: projectPath || undefined,
|
||||
commitMessage: commitMessage || undefined,
|
||||
prTitle: title || worktree.branch,
|
||||
prBody: body || `Changes from branch ${worktree.branch}`,
|
||||
@@ -108,13 +111,25 @@ export function CreatePRDialog({
|
||||
setPrUrl(result.result.prUrl);
|
||||
// Mark operation as completed for refresh on close
|
||||
operationCompletedRef.current = true;
|
||||
toast.success("Pull request created!", {
|
||||
description: `PR created from ${result.result.branch}`,
|
||||
action: {
|
||||
label: "View PR",
|
||||
onClick: () => window.open(result.result!.prUrl!, "_blank"),
|
||||
},
|
||||
});
|
||||
|
||||
// Show different message based on whether PR already existed
|
||||
if (result.result.prAlreadyExisted) {
|
||||
toast.success("Pull request found!", {
|
||||
description: `PR already exists for ${result.result.branch}`,
|
||||
action: {
|
||||
label: "View PR",
|
||||
onClick: () => window.open(result.result!.prUrl!, "_blank"),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
toast.success("Pull request created!", {
|
||||
description: `PR created from ${result.result.branch}`,
|
||||
action: {
|
||||
label: "View PR",
|
||||
onClick: () => window.open(result.result!.prUrl!, "_blank"),
|
||||
},
|
||||
});
|
||||
}
|
||||
// Don't call onCreated() here - keep dialog open to show success message
|
||||
// onCreated() will be called when user closes the dialog
|
||||
} else {
|
||||
@@ -200,7 +215,8 @@ export function CreatePRDialog({
|
||||
// Only call onCreated() if an actual operation completed
|
||||
// This prevents unnecessary refreshes when user cancels
|
||||
if (operationCompletedRef.current) {
|
||||
onCreated();
|
||||
// Pass the PR URL if one was created
|
||||
onCreated(prUrl || undefined);
|
||||
}
|
||||
onOpenChange(false);
|
||||
// State reset is handled by useEffect when open becomes false
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
|
||||
import {
|
||||
@@ -61,6 +62,7 @@ interface EditFeatureDialogProps {
|
||||
onUpdate: (
|
||||
featureId: string,
|
||||
updates: {
|
||||
title: string;
|
||||
category: string;
|
||||
description: string;
|
||||
steps: string[];
|
||||
@@ -159,6 +161,7 @@ export function EditFeatureDialog({
|
||||
: editingFeature.branchName || "";
|
||||
|
||||
const updates = {
|
||||
title: editingFeature.title ?? "",
|
||||
category: editingFeature.category,
|
||||
description: editingFeature.description,
|
||||
steps: editingFeature.steps,
|
||||
@@ -311,6 +314,21 @@ export function EditFeatureDialog({
|
||||
data-testid="edit-feature-description"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-title">Title (optional)</Label>
|
||||
<Input
|
||||
id="edit-title"
|
||||
value={editingFeature.title ?? ""}
|
||||
onChange={(e) =>
|
||||
setEditingFeature({
|
||||
...editingFeature,
|
||||
title: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Leave blank to auto-generate"
|
||||
data-testid="edit-feature-title"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-fit items-center gap-3 select-none cursor-default">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -41,6 +41,7 @@ interface UseBoardActionsProps {
|
||||
outputFeature: Feature | null;
|
||||
projectPath: string | null;
|
||||
onWorktreeCreated?: () => void;
|
||||
onWorktreeAutoSelect?: (worktree: { path: string; branch: string }) => void;
|
||||
currentWorktreeBranch: string | null; // Branch name of the selected worktree for filtering
|
||||
}
|
||||
|
||||
@@ -68,6 +69,7 @@ export function useBoardActions({
|
||||
outputFeature,
|
||||
projectPath,
|
||||
onWorktreeCreated,
|
||||
onWorktreeAutoSelect,
|
||||
currentWorktreeBranch,
|
||||
}: UseBoardActionsProps) {
|
||||
const {
|
||||
@@ -87,6 +89,7 @@ export function useBoardActions({
|
||||
|
||||
const handleAddFeature = useCallback(
|
||||
async (featureData: {
|
||||
title: string;
|
||||
category: string;
|
||||
description: string;
|
||||
steps: string[];
|
||||
@@ -114,15 +117,20 @@ export function useBoardActions({
|
||||
currentProject.path,
|
||||
finalBranchName
|
||||
);
|
||||
if (result.success) {
|
||||
if (result.success && result.worktree) {
|
||||
console.log(
|
||||
`[Board] Worktree for branch "${finalBranchName}" ${
|
||||
result.worktree?.isNew ? "created" : "already exists"
|
||||
}`
|
||||
);
|
||||
// Auto-select the worktree when creating a feature for it
|
||||
onWorktreeAutoSelect?.({
|
||||
path: result.worktree.path,
|
||||
branch: result.worktree.branch,
|
||||
});
|
||||
// Refresh worktree list in UI
|
||||
onWorktreeCreated?.();
|
||||
} else {
|
||||
} else if (!result.success) {
|
||||
console.error(
|
||||
`[Board] Failed to create worktree for branch "${finalBranchName}":`,
|
||||
result.error
|
||||
@@ -141,8 +149,14 @@ export function useBoardActions({
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we need to generate a title
|
||||
const needsTitleGeneration =
|
||||
!featureData.title.trim() && featureData.description.trim();
|
||||
|
||||
const newFeatureData = {
|
||||
...featureData,
|
||||
title: featureData.title,
|
||||
titleGenerating: needsTitleGeneration,
|
||||
status: "backlog" as const,
|
||||
branchName: finalBranchName,
|
||||
};
|
||||
@@ -150,14 +164,56 @@ export function useBoardActions({
|
||||
// Must await to ensure feature exists on server before user can drag it
|
||||
await persistFeatureCreate(createdFeature);
|
||||
saveCategory(featureData.category);
|
||||
|
||||
// Generate title in the background if needed (non-blocking)
|
||||
if (needsTitleGeneration) {
|
||||
const api = getElectronAPI();
|
||||
if (api?.features?.generateTitle) {
|
||||
api.features
|
||||
.generateTitle(featureData.description)
|
||||
.then((result) => {
|
||||
if (result.success && result.title) {
|
||||
const titleUpdates = {
|
||||
title: result.title,
|
||||
titleGenerating: false,
|
||||
};
|
||||
updateFeature(createdFeature.id, titleUpdates);
|
||||
persistFeatureUpdate(createdFeature.id, titleUpdates);
|
||||
} else {
|
||||
// Clear generating flag even if failed
|
||||
const titleUpdates = { titleGenerating: false };
|
||||
updateFeature(createdFeature.id, titleUpdates);
|
||||
persistFeatureUpdate(createdFeature.id, titleUpdates);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Board] Error generating title:", error);
|
||||
// Clear generating flag on error
|
||||
const titleUpdates = { titleGenerating: false };
|
||||
updateFeature(createdFeature.id, titleUpdates);
|
||||
persistFeatureUpdate(createdFeature.id, titleUpdates);
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[addFeature, persistFeatureCreate, saveCategory, useWorktrees, currentProject, onWorktreeCreated]
|
||||
[
|
||||
addFeature,
|
||||
persistFeatureCreate,
|
||||
persistFeatureUpdate,
|
||||
updateFeature,
|
||||
saveCategory,
|
||||
useWorktrees,
|
||||
currentProject,
|
||||
onWorktreeCreated,
|
||||
onWorktreeAutoSelect,
|
||||
]
|
||||
);
|
||||
|
||||
const handleUpdateFeature = useCallback(
|
||||
async (
|
||||
featureId: string,
|
||||
updates: {
|
||||
title: string;
|
||||
category: string;
|
||||
description: string;
|
||||
steps: string[];
|
||||
@@ -212,6 +268,7 @@ export function useBoardActions({
|
||||
|
||||
const finalUpdates = {
|
||||
...updates,
|
||||
title: updates.title,
|
||||
branchName: finalBranchName,
|
||||
};
|
||||
|
||||
@@ -222,7 +279,15 @@ export function useBoardActions({
|
||||
}
|
||||
setEditingFeature(null);
|
||||
},
|
||||
[updateFeature, persistFeatureUpdate, saveCategory, setEditingFeature, useWorktrees, currentProject, onWorktreeCreated]
|
||||
[
|
||||
updateFeature,
|
||||
persistFeatureUpdate,
|
||||
saveCategory,
|
||||
setEditingFeature,
|
||||
useWorktrees,
|
||||
currentProject,
|
||||
onWorktreeCreated,
|
||||
]
|
||||
);
|
||||
|
||||
const handleDeleteFeature = useCallback(
|
||||
|
||||
@@ -194,7 +194,6 @@ export function KanbanBoard({
|
||||
onMoveBackToInProgress(feature)
|
||||
}
|
||||
onFollowUp={() => onFollowUp(feature)}
|
||||
onCommit={() => onCommit(feature)}
|
||||
onComplete={() => onComplete(feature)}
|
||||
onImplement={() => onImplement(feature)}
|
||||
onViewPlan={() => onViewPlan(feature)}
|
||||
|
||||
@@ -19,9 +19,11 @@ import {
|
||||
Play,
|
||||
Square,
|
||||
Globe,
|
||||
MessageSquare,
|
||||
GitMerge,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { WorktreeInfo, DevServerInfo } from "../types";
|
||||
import type { WorktreeInfo, DevServerInfo, PRInfo } from "../types";
|
||||
|
||||
interface WorktreeActionsDropdownProps {
|
||||
worktree: WorktreeInfo;
|
||||
@@ -40,6 +42,8 @@ interface WorktreeActionsDropdownProps {
|
||||
onOpenInEditor: (worktree: WorktreeInfo) => void;
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||
onStartDevServer: (worktree: WorktreeInfo) => void;
|
||||
onStopDevServer: (worktree: WorktreeInfo) => void;
|
||||
@@ -63,11 +67,16 @@ export function WorktreeActionsDropdown({
|
||||
onOpenInEditor,
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
onAddressPRComments,
|
||||
onResolveConflicts,
|
||||
onDeleteWorktree,
|
||||
onStartDevServer,
|
||||
onStopDevServer,
|
||||
onOpenDevServerUrl,
|
||||
}: WorktreeActionsDropdownProps) {
|
||||
// Check if there's a PR associated with this worktree from stored metadata
|
||||
const hasPR = !!worktree.pr;
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -154,6 +163,15 @@ export function WorktreeActionsDropdown({
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
{!worktree.isMain && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onResolveConflicts(worktree)}
|
||||
className="text-xs text-purple-500 focus:text-purple-600"
|
||||
>
|
||||
<GitMerge className="w-3.5 h-3.5 mr-2" />
|
||||
Pull & Resolve Conflicts
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onOpenInEditor(worktree)}
|
||||
@@ -170,12 +188,50 @@ export function WorktreeActionsDropdown({
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* Show PR option for non-primary worktrees, or primary worktree with changes */}
|
||||
{(!worktree.isMain || worktree.hasChanges) && (
|
||||
{(!worktree.isMain || worktree.hasChanges) && !hasPR && (
|
||||
<DropdownMenuItem onClick={() => onCreatePR(worktree)} className="text-xs">
|
||||
<GitPullRequest className="w-3.5 h-3.5 mr-2" />
|
||||
Create Pull Request
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* Show PR info and Address Comments button if PR exists */}
|
||||
{!worktree.isMain && hasPR && worktree.pr && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
window.open(worktree.pr!.url, "_blank");
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<GitPullRequest className="w-3 h-3 mr-2" />
|
||||
PR #{worktree.pr.number}
|
||||
<span className="ml-auto text-[10px] bg-green-500/20 text-green-600 px-1.5 py-0.5 rounded uppercase">
|
||||
{worktree.pr.state}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
// Convert stored PR info to the full PRInfo format for the handler
|
||||
// The handler will fetch full comments from GitHub
|
||||
const prInfo: PRInfo = {
|
||||
number: worktree.pr!.number,
|
||||
title: worktree.pr!.title,
|
||||
url: worktree.pr!.url,
|
||||
state: worktree.pr!.state,
|
||||
author: "", // Will be fetched
|
||||
body: "", // Will be fetched
|
||||
comments: [],
|
||||
reviewComments: [],
|
||||
};
|
||||
onAddressPRComments(worktree, prInfo);
|
||||
}}
|
||||
className="text-xs text-blue-500 focus:text-blue-600"
|
||||
>
|
||||
<MessageSquare className="w-3.5 h-3.5 mr-2" />
|
||||
Address PR Comments
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{!worktree.isMain && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RefreshCw, Globe, Loader2 } from "lucide-react";
|
||||
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { WorktreeInfo, BranchInfo, DevServerInfo } from "../types";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo } from "../types";
|
||||
import { BranchSwitchDropdown } from "./branch-switch-dropdown";
|
||||
import { WorktreeActionsDropdown } from "./worktree-actions-dropdown";
|
||||
|
||||
interface WorktreeTabProps {
|
||||
worktree: WorktreeInfo;
|
||||
cardCount?: number; // Number of unarchived cards for this branch
|
||||
hasChanges?: boolean; // Whether the worktree has uncommitted changes
|
||||
changedFilesCount?: number; // Number of files with uncommitted changes
|
||||
isSelected: boolean;
|
||||
isRunning: boolean;
|
||||
isActivating: boolean;
|
||||
@@ -36,6 +44,8 @@ interface WorktreeTabProps {
|
||||
onOpenInEditor: (worktree: WorktreeInfo) => void;
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||
onStartDevServer: (worktree: WorktreeInfo) => void;
|
||||
onStopDevServer: (worktree: WorktreeInfo) => void;
|
||||
@@ -45,6 +55,8 @@ interface WorktreeTabProps {
|
||||
export function WorktreeTab({
|
||||
worktree,
|
||||
cardCount,
|
||||
hasChanges,
|
||||
changedFilesCount,
|
||||
isSelected,
|
||||
isRunning,
|
||||
isActivating,
|
||||
@@ -72,13 +84,101 @@ export function WorktreeTab({
|
||||
onOpenInEditor,
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
onAddressPRComments,
|
||||
onResolveConflicts,
|
||||
onDeleteWorktree,
|
||||
onStartDevServer,
|
||||
onStopDevServer,
|
||||
onOpenDevServerUrl,
|
||||
}: WorktreeTabProps) {
|
||||
|
||||
let prBadge: JSX.Element | null = null;
|
||||
if (worktree.pr) {
|
||||
const prState = worktree.pr.state?.toLowerCase() ?? "open";
|
||||
const prStateClasses = (() => {
|
||||
// When selected (active tab), use high contrast solid background (paper-like)
|
||||
if (isSelected) {
|
||||
return "bg-background text-foreground border-transparent shadow-sm";
|
||||
}
|
||||
|
||||
// When not selected, use the colored variants
|
||||
switch (prState) {
|
||||
case "open":
|
||||
case "reopened":
|
||||
return "bg-emerald-500/15 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 border-emerald-500/30 dark:border-emerald-500/40 hover:bg-emerald-500/25";
|
||||
case "draft":
|
||||
return "bg-amber-500/15 dark:bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30 dark:border-amber-500/40 hover:bg-amber-500/25";
|
||||
case "merged":
|
||||
return "bg-purple-500/15 dark:bg-purple-500/20 text-purple-600 dark:text-purple-400 border-purple-500/30 dark:border-purple-500/40 hover:bg-purple-500/25";
|
||||
case "closed":
|
||||
return "bg-rose-500/15 dark:bg-rose-500/20 text-rose-600 dark:text-rose-400 border-rose-500/30 dark:border-rose-500/40 hover:bg-rose-500/25";
|
||||
default:
|
||||
return "bg-muted text-muted-foreground border-border/60 hover:bg-muted/80";
|
||||
}
|
||||
})();
|
||||
|
||||
const prLabel = `Pull Request #${worktree.pr.number}, ${prState}${worktree.pr.title ? `: ${worktree.pr.title}` : ""}`;
|
||||
|
||||
// Helper to get status icon color for the selected state
|
||||
const getStatusColorClass = () => {
|
||||
if (!isSelected) return "";
|
||||
switch (prState) {
|
||||
case "open":
|
||||
case "reopened":
|
||||
return "text-emerald-600 dark:text-emerald-500";
|
||||
case "draft":
|
||||
return "text-amber-600 dark:text-amber-500";
|
||||
case "merged":
|
||||
return "text-purple-600 dark:text-purple-500";
|
||||
case "closed":
|
||||
return "text-rose-600 dark:text-rose-500";
|
||||
default:
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
};
|
||||
|
||||
prBadge = (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"ml-1.5 inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium transition-colors",
|
||||
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1 focus:ring-offset-background",
|
||||
"cursor-pointer hover:opacity-80 active:opacity-70",
|
||||
prStateClasses
|
||||
)}
|
||||
title={`${prLabel} - Click to open`}
|
||||
aria-label={`${prLabel} - Click to open pull request`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // Prevent triggering worktree selection
|
||||
if (worktree.pr?.url) {
|
||||
window.open(worktree.pr.url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Prevent event from bubbling to parent button
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
if (worktree.pr?.url) {
|
||||
window.open(worktree.pr.url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<GitPullRequest className={cn("w-3 h-3", getStatusColorClass())} aria-hidden="true" />
|
||||
<span aria-hidden="true" className={isSelected ? "text-foreground font-semibold" : ""}>
|
||||
PR #{worktree.pr.number}
|
||||
</span>
|
||||
<span className={cn("capitalize", getStatusColorClass())} aria-hidden="true">
|
||||
{prState}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center rounded-md">
|
||||
{worktree.isMain ? (
|
||||
<>
|
||||
<Button
|
||||
@@ -103,6 +203,27 @@ export function WorktreeTab({
|
||||
{cardCount}
|
||||
</span>
|
||||
)}
|
||||
{hasChanges && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn(
|
||||
"inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border",
|
||||
isSelected
|
||||
? "bg-amber-500 text-amber-950 border-amber-400"
|
||||
: "bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30"
|
||||
)}>
|
||||
<CircleDot className="w-2.5 h-2.5 mr-0.5" />
|
||||
{changedFilesCount ?? "!"}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{changedFilesCount ?? "Some"} uncommitted file{changedFilesCount !== 1 ? "s" : ""}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{prBadge}
|
||||
</Button>
|
||||
<BranchSwitchDropdown
|
||||
worktree={worktree}
|
||||
@@ -146,6 +267,27 @@ export function WorktreeTab({
|
||||
{cardCount}
|
||||
</span>
|
||||
)}
|
||||
{hasChanges && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn(
|
||||
"inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border",
|
||||
isSelected
|
||||
? "bg-amber-500 text-amber-950 border-amber-400"
|
||||
: "bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30"
|
||||
)}>
|
||||
<CircleDot className="w-2.5 h-2.5 mr-0.5" />
|
||||
{changedFilesCount ?? "!"}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{changedFilesCount ?? "Some"} uncommitted file{changedFilesCount !== 1 ? "s" : ""}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{prBadge}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -183,6 +325,8 @@ export function WorktreeTab({
|
||||
onOpenInEditor={onOpenInEditor}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={onStartDevServer}
|
||||
onStopDevServer={onStopDevServer}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { pathsEqual } from "@/lib/utils";
|
||||
@@ -20,9 +20,12 @@ export function useWorktrees({ projectPath, refreshTrigger = 0, onRemovedWorktre
|
||||
const setWorktreesInStore = useAppStore((s) => s.setWorktrees);
|
||||
const useWorktreesEnabled = useAppStore((s) => s.useWorktrees);
|
||||
|
||||
const fetchWorktrees = useCallback(async () => {
|
||||
const fetchWorktrees = useCallback(async (options?: { silent?: boolean }) => {
|
||||
if (!projectPath) return;
|
||||
setIsLoading(true);
|
||||
const silent = options?.silent ?? false;
|
||||
if (!silent) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.listAll) {
|
||||
@@ -40,7 +43,9 @@ export function useWorktrees({ projectPath, refreshTrigger = 0, onRemovedWorktre
|
||||
console.error("Failed to fetch worktrees:", error);
|
||||
return undefined;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (!silent) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [projectPath, setWorktreesInStore]);
|
||||
|
||||
@@ -58,14 +63,25 @@ export function useWorktrees({ projectPath, refreshTrigger = 0, onRemovedWorktre
|
||||
}
|
||||
}, [refreshTrigger, fetchWorktrees, onRemovedWorktrees]);
|
||||
|
||||
// Use a ref to track the current worktree to avoid running validation
|
||||
// when selection changes (which could cause a race condition with stale worktrees list)
|
||||
const currentWorktreeRef = useRef(currentWorktree);
|
||||
useEffect(() => {
|
||||
currentWorktreeRef.current = currentWorktree;
|
||||
}, [currentWorktree]);
|
||||
|
||||
// Validation effect: only runs when worktrees list changes (not on selection change)
|
||||
// This prevents a race condition where the selection is reset because the
|
||||
// local worktrees state hasn't been updated yet from the async fetch
|
||||
useEffect(() => {
|
||||
if (worktrees.length > 0) {
|
||||
const currentPath = currentWorktree?.path;
|
||||
const current = currentWorktreeRef.current;
|
||||
const currentPath = current?.path;
|
||||
const currentWorktreeExists = currentPath === null
|
||||
? true
|
||||
: worktrees.some((w) => !w.isMain && pathsEqual(w.path, currentPath));
|
||||
|
||||
if (currentWorktree == null || (currentPath !== null && !currentWorktreeExists)) {
|
||||
if (current == null || (currentPath !== null && !currentWorktreeExists)) {
|
||||
// Find the primary worktree and get its branch name
|
||||
// Fallback to "main" only if worktrees haven't loaded yet
|
||||
const mainWorktree = worktrees.find((w) => w.isMain);
|
||||
@@ -73,7 +89,7 @@ export function useWorktrees({ projectPath, refreshTrigger = 0, onRemovedWorktre
|
||||
setCurrentWorktree(projectPath, null, mainBranch);
|
||||
}
|
||||
}
|
||||
}, [worktrees, currentWorktree, projectPath, setCurrentWorktree]);
|
||||
}, [worktrees, projectPath, setCurrentWorktree]);
|
||||
|
||||
const handleSelectWorktree = useCallback(
|
||||
(worktree: WorktreeInfo) => {
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
export interface WorktreePRInfo {
|
||||
number: number;
|
||||
url: string;
|
||||
title: string;
|
||||
state: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface WorktreeInfo {
|
||||
path: string;
|
||||
branch: string;
|
||||
@@ -6,6 +14,7 @@ export interface WorktreeInfo {
|
||||
hasWorktree: boolean;
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
pr?: WorktreePRInfo;
|
||||
}
|
||||
|
||||
export interface BranchInfo {
|
||||
@@ -25,6 +34,31 @@ export interface FeatureInfo {
|
||||
branchName?: string;
|
||||
}
|
||||
|
||||
export interface PRInfo {
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
state: string;
|
||||
author: string;
|
||||
body: string;
|
||||
comments: Array<{
|
||||
id: number;
|
||||
author: string;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
isReviewComment: boolean;
|
||||
}>;
|
||||
reviewComments: Array<{
|
||||
id: number;
|
||||
author: string;
|
||||
body: string;
|
||||
path?: string;
|
||||
line?: number;
|
||||
createdAt: string;
|
||||
isReviewComment: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface WorktreePanelProps {
|
||||
projectPath: string;
|
||||
onCreateWorktree: () => void;
|
||||
@@ -32,6 +66,8 @@ export interface WorktreePanelProps {
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onCreateBranch: (worktree: WorktreeInfo) => void;
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
|
||||
runningFeatureIds?: string[];
|
||||
features?: FeatureInfo[];
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { GitBranch, Plus, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
GitBranch,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
PanelLeftOpen,
|
||||
PanelLeftClose,
|
||||
} from "lucide-react";
|
||||
import { cn, pathsEqual } from "@/lib/utils";
|
||||
import type { WorktreePanelProps, WorktreeInfo } from "./types";
|
||||
import {
|
||||
@@ -13,6 +19,8 @@ import {
|
||||
} from "./hooks";
|
||||
import { WorktreeTab } from "./components";
|
||||
|
||||
const WORKTREE_PANEL_COLLAPSED_KEY = "worktree-panel-collapsed";
|
||||
|
||||
export function WorktreePanel({
|
||||
projectPath,
|
||||
onCreateWorktree,
|
||||
@@ -20,6 +28,8 @@ export function WorktreePanel({
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
onCreateBranch,
|
||||
onAddressPRComments,
|
||||
onResolveConflicts,
|
||||
onRemovedWorktrees,
|
||||
runningFeatureIds = [],
|
||||
features = [],
|
||||
@@ -79,6 +89,45 @@ export function WorktreePanel({
|
||||
features,
|
||||
});
|
||||
|
||||
// Collapse state with localStorage persistence
|
||||
const [isCollapsed, setIsCollapsed] = useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
const saved = localStorage.getItem(WORKTREE_PANEL_COLLAPSED_KEY);
|
||||
return saved === "true";
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(WORKTREE_PANEL_COLLAPSED_KEY, String(isCollapsed));
|
||||
}, [isCollapsed]);
|
||||
|
||||
const toggleCollapsed = () => setIsCollapsed((prev) => !prev);
|
||||
|
||||
// Periodic interval check (1 second) to detect branch changes on disk
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
useEffect(() => {
|
||||
intervalRef.current = setInterval(() => {
|
||||
fetchWorktrees({ silent: true });
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [fetchWorktrees]);
|
||||
|
||||
// Get the currently selected worktree for collapsed view
|
||||
const selectedWorktree = worktrees.find((w) => {
|
||||
if (
|
||||
currentWorktree === null ||
|
||||
currentWorktree === undefined ||
|
||||
currentWorktree.path === null
|
||||
) {
|
||||
return w.isMain;
|
||||
}
|
||||
return pathsEqual(w.path, currentWorktreePath);
|
||||
});
|
||||
|
||||
const isWorktreeSelected = (worktree: WorktreeInfo) => {
|
||||
return worktree.isMain
|
||||
? currentWorktree === null ||
|
||||
@@ -87,99 +136,208 @@ export function WorktreePanel({
|
||||
: pathsEqual(worktree.path, currentWorktreePath);
|
||||
};
|
||||
|
||||
const handleBranchDropdownOpenChange = (worktree: WorktreeInfo) => (open: boolean) => {
|
||||
if (open) {
|
||||
fetchBranches(worktree.path);
|
||||
resetBranchFilter();
|
||||
}
|
||||
};
|
||||
const handleBranchDropdownOpenChange =
|
||||
(worktree: WorktreeInfo) => (open: boolean) => {
|
||||
if (open) {
|
||||
fetchBranches(worktree.path);
|
||||
resetBranchFilter();
|
||||
}
|
||||
};
|
||||
|
||||
const handleActionsDropdownOpenChange = (worktree: WorktreeInfo) => (open: boolean) => {
|
||||
if (open) {
|
||||
fetchBranches(worktree.path);
|
||||
}
|
||||
};
|
||||
const handleActionsDropdownOpenChange =
|
||||
(worktree: WorktreeInfo) => (open: boolean) => {
|
||||
if (open) {
|
||||
fetchBranches(worktree.path);
|
||||
}
|
||||
};
|
||||
|
||||
if (!useWorktreesEnabled) {
|
||||
return null;
|
||||
const mainWorktree = worktrees.find((w) => w.isMain);
|
||||
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
|
||||
|
||||
// Collapsed view - just show current branch and toggle
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-1.5 border-b border-border bg-glass/50 backdrop-blur-sm">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={toggleCollapsed}
|
||||
title="Expand worktree panel"
|
||||
>
|
||||
<PanelLeftOpen className="w-4 h-4" />
|
||||
</Button>
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Branch:</span>
|
||||
<span className="text-sm font-mono font-medium">
|
||||
{selectedWorktree?.branch ?? "main"}
|
||||
</span>
|
||||
{selectedWorktree?.hasChanges && (
|
||||
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30">
|
||||
{selectedWorktree.changedFilesCount ?? "!"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Expanded view - full worktree panel
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={toggleCollapsed}
|
||||
title="Collapse worktree panel"
|
||||
>
|
||||
<PanelLeftClose className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground mr-2">Branch:</span>
|
||||
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{worktrees.map((worktree) => {
|
||||
const cardCount = branchCardCounts?.[worktree.branch];
|
||||
return (
|
||||
<WorktreeTab
|
||||
key={worktree.path}
|
||||
worktree={worktree}
|
||||
cardCount={cardCount}
|
||||
isSelected={isWorktreeSelected(worktree)}
|
||||
isRunning={hasRunningFeatures(worktree)}
|
||||
isActivating={isActivating}
|
||||
isDevServerRunning={isDevServerRunning(worktree)}
|
||||
devServerInfo={getDevServerInfo(worktree)}
|
||||
defaultEditorName={defaultEditorName}
|
||||
branches={branches}
|
||||
filteredBranches={filteredBranches}
|
||||
branchFilter={branchFilter}
|
||||
isLoadingBranches={isLoadingBranches}
|
||||
isSwitching={isSwitching}
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
|
||||
onBranchFilterChange={setBranchFilter}
|
||||
onSwitchBranch={handleSwitchBranch}
|
||||
onCreateBranch={onCreateBranch}
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={handleStartDevServer}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onCreateWorktree}
|
||||
title="Create new worktree"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={async () => {
|
||||
const removedWorktrees = await fetchWorktrees();
|
||||
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
|
||||
onRemovedWorktrees(removedWorktrees);
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
title="Refresh worktrees"
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("w-3.5 h-3.5", isLoading && "animate-spin")}
|
||||
<div className="flex items-center gap-2">
|
||||
{mainWorktree && (
|
||||
<WorktreeTab
|
||||
key={mainWorktree.path}
|
||||
worktree={mainWorktree}
|
||||
cardCount={branchCardCounts?.[mainWorktree.branch]}
|
||||
hasChanges={mainWorktree.hasChanges}
|
||||
changedFilesCount={mainWorktree.changedFilesCount}
|
||||
isSelected={isWorktreeSelected(mainWorktree)}
|
||||
isRunning={hasRunningFeatures(mainWorktree)}
|
||||
isActivating={isActivating}
|
||||
isDevServerRunning={isDevServerRunning(mainWorktree)}
|
||||
devServerInfo={getDevServerInfo(mainWorktree)}
|
||||
defaultEditorName={defaultEditorName}
|
||||
branches={branches}
|
||||
filteredBranches={filteredBranches}
|
||||
branchFilter={branchFilter}
|
||||
isLoadingBranches={isLoadingBranches}
|
||||
isSwitching={isSwitching}
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(
|
||||
mainWorktree
|
||||
)}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(
|
||||
mainWorktree
|
||||
)}
|
||||
onBranchFilterChange={setBranchFilter}
|
||||
onSwitchBranch={handleSwitchBranch}
|
||||
onCreateBranch={onCreateBranch}
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={handleStartDevServer}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Worktrees section - only show if enabled */}
|
||||
{useWorktreesEnabled && (
|
||||
<>
|
||||
<div className="w-px h-5 bg-border mx-2" />
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground mr-2">Worktrees:</span>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{nonMainWorktrees.map((worktree) => {
|
||||
const cardCount = branchCardCounts?.[worktree.branch];
|
||||
return (
|
||||
<WorktreeTab
|
||||
key={worktree.path}
|
||||
worktree={worktree}
|
||||
cardCount={cardCount}
|
||||
hasChanges={worktree.hasChanges}
|
||||
changedFilesCount={worktree.changedFilesCount}
|
||||
isSelected={isWorktreeSelected(worktree)}
|
||||
isRunning={hasRunningFeatures(worktree)}
|
||||
isActivating={isActivating}
|
||||
isDevServerRunning={isDevServerRunning(worktree)}
|
||||
devServerInfo={getDevServerInfo(worktree)}
|
||||
defaultEditorName={defaultEditorName}
|
||||
branches={branches}
|
||||
filteredBranches={filteredBranches}
|
||||
branchFilter={branchFilter}
|
||||
isLoadingBranches={isLoadingBranches}
|
||||
isSwitching={isSwitching}
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(
|
||||
worktree
|
||||
)}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(
|
||||
worktree
|
||||
)}
|
||||
onBranchFilterChange={setBranchFilter}
|
||||
onSwitchBranch={handleSwitchBranch}
|
||||
onCreateBranch={onCreateBranch}
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={handleStartDevServer}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onCreateWorktree}
|
||||
title="Create new worktree"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={async () => {
|
||||
const removedWorktrees = await fetchWorktrees();
|
||||
if (
|
||||
removedWorktrees &&
|
||||
removedWorktrees.length > 0 &&
|
||||
onRemovedWorktrees
|
||||
) {
|
||||
onRemovedWorktrees(removedWorktrees);
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
title="Refresh worktrees"
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("w-3.5 h-3.5", isLoading && "animate-spin")}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,9 @@ export function SettingsView() {
|
||||
setDefaultPlanningMode,
|
||||
defaultRequirePlanApproval,
|
||||
setDefaultRequirePlanApproval,
|
||||
defaultAIProfileId,
|
||||
setDefaultAIProfileId,
|
||||
aiProfiles,
|
||||
} = useAppStore();
|
||||
|
||||
// Convert electron Project to settings-view Project type
|
||||
@@ -127,12 +130,15 @@ export function SettingsView() {
|
||||
useWorktrees={useWorktrees}
|
||||
defaultPlanningMode={defaultPlanningMode}
|
||||
defaultRequirePlanApproval={defaultRequirePlanApproval}
|
||||
defaultAIProfileId={defaultAIProfileId}
|
||||
aiProfiles={aiProfiles}
|
||||
onShowProfilesOnlyChange={setShowProfilesOnly}
|
||||
onDefaultSkipTestsChange={setDefaultSkipTests}
|
||||
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
|
||||
onUseWorktreesChange={setUseWorktrees}
|
||||
onDefaultPlanningModeChange={setDefaultPlanningMode}
|
||||
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
|
||||
onDefaultAIProfileIdChange={setDefaultAIProfileId}
|
||||
/>
|
||||
);
|
||||
case "danger":
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
FlaskConical, Settings2, TestTube, GitBranch, AlertCircle,
|
||||
Zap, ClipboardList, FileText, ScrollText, ShieldCheck
|
||||
Zap, ClipboardList, FileText, ScrollText, ShieldCheck, User
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { AIProfile } from "@/store/app-store";
|
||||
|
||||
type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
|
||||
|
||||
@@ -22,12 +23,15 @@ interface FeatureDefaultsSectionProps {
|
||||
useWorktrees: boolean;
|
||||
defaultPlanningMode: PlanningMode;
|
||||
defaultRequirePlanApproval: boolean;
|
||||
defaultAIProfileId: string | null;
|
||||
aiProfiles: AIProfile[];
|
||||
onShowProfilesOnlyChange: (value: boolean) => void;
|
||||
onDefaultSkipTestsChange: (value: boolean) => void;
|
||||
onEnableDependencyBlockingChange: (value: boolean) => void;
|
||||
onUseWorktreesChange: (value: boolean) => void;
|
||||
onDefaultPlanningModeChange: (value: PlanningMode) => void;
|
||||
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
|
||||
onDefaultAIProfileIdChange: (value: string | null) => void;
|
||||
}
|
||||
|
||||
export function FeatureDefaultsSection({
|
||||
@@ -37,13 +41,20 @@ export function FeatureDefaultsSection({
|
||||
useWorktrees,
|
||||
defaultPlanningMode,
|
||||
defaultRequirePlanApproval,
|
||||
defaultAIProfileId,
|
||||
aiProfiles,
|
||||
onShowProfilesOnlyChange,
|
||||
onDefaultSkipTestsChange,
|
||||
onEnableDependencyBlockingChange,
|
||||
onUseWorktreesChange,
|
||||
onDefaultPlanningModeChange,
|
||||
onDefaultRequirePlanApprovalChange,
|
||||
onDefaultAIProfileIdChange,
|
||||
}: FeatureDefaultsSectionProps) {
|
||||
// Find the selected profile name for display
|
||||
const selectedProfile = defaultAIProfileId
|
||||
? aiProfiles.find((p) => p.id === defaultAIProfileId)
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -169,6 +180,49 @@ export function FeatureDefaultsSection({
|
||||
{/* Separator */}
|
||||
{defaultPlanningMode === 'skip' && <div className="border-t border-border/30" />}
|
||||
|
||||
{/* Default AI Profile */}
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||
<div className="w-10 h-10 mt-0.5 rounded-xl flex items-center justify-center shrink-0 bg-brand-500/10">
|
||||
<User className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-foreground font-medium">
|
||||
Default AI Profile
|
||||
</Label>
|
||||
<Select
|
||||
value={defaultAIProfileId ?? "none"}
|
||||
onValueChange={(v: string) => onDefaultAIProfileIdChange(v === "none" ? null : v)}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-[180px] h-8"
|
||||
data-testid="default-ai-profile-select"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
<span className="text-muted-foreground">None (pick manually)</span>
|
||||
</SelectItem>
|
||||
{aiProfiles.map((profile) => (
|
||||
<SelectItem key={profile.id} value={profile.id}>
|
||||
<span>{profile.name}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
{selectedProfile
|
||||
? `New features will use the "${selectedProfile.name}" profile (${selectedProfile.model}, ${selectedProfile.thinkingLevel} thinking).`
|
||||
: "Pre-select an AI profile when creating new features. Choose \"None\" to pick manually each time."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="border-t border-border/30" />
|
||||
|
||||
{/* Profiles Only Setting */}
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||
<Checkbox
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
|
||||
import { useSetupStore } from "@/store/setup-store";
|
||||
import { StepIndicator } from "./setup-view/components";
|
||||
import {
|
||||
WelcomeStep,
|
||||
ThemeStep,
|
||||
CompleteStep,
|
||||
ClaudeSetupStep,
|
||||
GitHubSetupStep,
|
||||
@@ -11,20 +11,17 @@ import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
// Main Setup View
|
||||
export function SetupView() {
|
||||
const {
|
||||
currentStep,
|
||||
setCurrentStep,
|
||||
completeSetup,
|
||||
setSkipClaudeSetup,
|
||||
} = useSetupStore();
|
||||
const { currentStep, setCurrentStep, completeSetup, setSkipClaudeSetup } =
|
||||
useSetupStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const steps = ["welcome", "claude", "github", "complete"] as const;
|
||||
const steps = ["welcome", "theme", "claude", "github", "complete"] as const;
|
||||
type StepName = (typeof steps)[number];
|
||||
const getStepName = (): StepName => {
|
||||
if (currentStep === "claude_detect" || currentStep === "claude_auth")
|
||||
return "claude";
|
||||
if (currentStep === "welcome") return "welcome";
|
||||
if (currentStep === "theme") return "theme";
|
||||
if (currentStep === "github") return "github";
|
||||
return "complete";
|
||||
};
|
||||
@@ -39,6 +36,10 @@ export function SetupView() {
|
||||
);
|
||||
switch (from) {
|
||||
case "welcome":
|
||||
console.log("[Setup Flow] Moving to theme step");
|
||||
setCurrentStep("theme");
|
||||
break;
|
||||
case "theme":
|
||||
console.log("[Setup Flow] Moving to claude_detect step");
|
||||
setCurrentStep("claude_detect");
|
||||
break;
|
||||
@@ -56,9 +57,12 @@ export function SetupView() {
|
||||
const handleBack = (from: string) => {
|
||||
console.log("[Setup Flow] handleBack called from:", from);
|
||||
switch (from) {
|
||||
case "claude":
|
||||
case "theme":
|
||||
setCurrentStep("welcome");
|
||||
break;
|
||||
case "claude":
|
||||
setCurrentStep("theme");
|
||||
break;
|
||||
case "github":
|
||||
setCurrentStep("claude_detect");
|
||||
break;
|
||||
@@ -98,42 +102,47 @@ export function SetupView() {
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="p-8">
|
||||
<div className="w-full max-w-2xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<StepIndicator
|
||||
currentStep={currentIndex}
|
||||
totalSteps={steps.length}
|
||||
<div className="flex-1 overflow-y-auto min-h-0 flex items-center justify-center">
|
||||
<div className="w-full max-w-2xl mx-auto px-8">
|
||||
<div className="mb-8">
|
||||
<StepIndicator
|
||||
currentStep={currentIndex}
|
||||
totalSteps={steps.length}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{currentStep === "welcome" && (
|
||||
<WelcomeStep onNext={() => handleNext("welcome")} />
|
||||
)}
|
||||
|
||||
{currentStep === "theme" && (
|
||||
<ThemeStep
|
||||
onNext={() => handleNext("theme")}
|
||||
onBack={() => handleBack("theme")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="py-8">
|
||||
{currentStep === "welcome" && (
|
||||
<WelcomeStep onNext={() => handleNext("welcome")} />
|
||||
)}
|
||||
{(currentStep === "claude_detect" ||
|
||||
currentStep === "claude_auth") && (
|
||||
<ClaudeSetupStep
|
||||
onNext={() => handleNext("claude")}
|
||||
onBack={() => handleBack("claude")}
|
||||
onSkip={handleSkipClaude}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(currentStep === "claude_detect" ||
|
||||
currentStep === "claude_auth") && (
|
||||
<ClaudeSetupStep
|
||||
onNext={() => handleNext("claude")}
|
||||
onBack={() => handleBack("claude")}
|
||||
onSkip={handleSkipClaude}
|
||||
/>
|
||||
)}
|
||||
{currentStep === "github" && (
|
||||
<GitHubSetupStep
|
||||
onNext={() => handleNext("github")}
|
||||
onBack={() => handleBack("github")}
|
||||
onSkip={handleSkipGithub}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === "github" && (
|
||||
<GitHubSetupStep
|
||||
onNext={() => handleNext("github")}
|
||||
onBack={() => handleBack("github")}
|
||||
onSkip={handleSkipGithub}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === "complete" && (
|
||||
<CompleteStep onFinish={handleFinish} />
|
||||
)}
|
||||
</div>
|
||||
{currentStep === "complete" && (
|
||||
<CompleteStep onFinish={handleFinish} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Re-export all setup step components for easier imports
|
||||
export { WelcomeStep } from "./welcome-step";
|
||||
export { ThemeStep } from "./theme-step";
|
||||
export { CompleteStep } from "./complete-step";
|
||||
export { ClaudeSetupStep } from "./claude-setup-step";
|
||||
export { GitHubSetupStep } from "./github-setup-step";
|
||||
|
||||
90
apps/ui/src/components/views/setup-view/steps/theme-step.tsx
Normal file
90
apps/ui/src/components/views/setup-view/steps/theme-step.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowRight, ArrowLeft, Check } from "lucide-react";
|
||||
import { themeOptions } from "@/config/theme-options";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ThemeStepProps {
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function ThemeStep({ onNext, onBack }: ThemeStepProps) {
|
||||
const { theme, setTheme, setPreviewTheme } = useAppStore();
|
||||
|
||||
const handleThemeHover = (themeValue: string) => {
|
||||
setPreviewTheme(themeValue as typeof theme);
|
||||
};
|
||||
|
||||
const handleThemeLeave = () => {
|
||||
setPreviewTheme(null);
|
||||
};
|
||||
|
||||
const handleThemeClick = (themeValue: string) => {
|
||||
setTheme(themeValue as typeof theme);
|
||||
setPreviewTheme(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl font-bold text-foreground mb-3">
|
||||
Choose Your Theme
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-md mx-auto">
|
||||
Pick a theme that suits your style. Hover to preview, click to select.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||
{themeOptions.map((option) => {
|
||||
const Icon = option.Icon;
|
||||
const isSelected = theme === option.value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
data-testid={option.testId}
|
||||
onMouseEnter={() => handleThemeHover(option.value)}
|
||||
onMouseLeave={handleThemeLeave}
|
||||
onClick={() => handleThemeClick(option.value)}
|
||||
className={cn(
|
||||
"relative flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all duration-200",
|
||||
"hover:scale-105 hover:shadow-lg",
|
||||
isSelected
|
||||
? "border-brand-500 bg-brand-500/10"
|
||||
: "border-border hover:border-brand-400 bg-card"
|
||||
)}
|
||||
>
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<Check className="w-4 h-4 text-brand-500" />
|
||||
</div>
|
||||
)}
|
||||
<Icon className="w-6 h-6 text-foreground" />
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{option.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button variant="ghost" onClick={onBack}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white"
|
||||
onClick={onNext}
|
||||
data-testid="theme-continue-button"
|
||||
>
|
||||
Continue
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
182
apps/ui/src/hooks/use-board-background-settings.ts
Normal file
182
apps/ui/src/hooks/use-board-background-settings.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { getHttpApiClient } from "@/lib/http-api-client";
|
||||
import { toast } from "sonner";
|
||||
|
||||
/**
|
||||
* Hook for managing board background settings with automatic persistence to server
|
||||
*/
|
||||
export function useBoardBackgroundSettings() {
|
||||
const store = useAppStore();
|
||||
const httpClient = getHttpApiClient();
|
||||
|
||||
// Helper to persist settings to server
|
||||
const persistSettings = useCallback(
|
||||
async (projectPath: string, settingsToUpdate: Record<string, unknown>) => {
|
||||
try {
|
||||
const result = await httpClient.settings.updateProject(
|
||||
projectPath,
|
||||
{
|
||||
boardBackground: settingsToUpdate,
|
||||
}
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
console.error("Failed to persist settings:", result.error);
|
||||
toast.error("Failed to save settings");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to persist settings:", error);
|
||||
toast.error("Failed to save settings");
|
||||
}
|
||||
},
|
||||
[httpClient]
|
||||
);
|
||||
|
||||
// Get current background settings for a project
|
||||
const getCurrentSettings = useCallback(
|
||||
(projectPath: string) => {
|
||||
const current = store.boardBackgroundByProject[projectPath];
|
||||
return current || {
|
||||
imagePath: null,
|
||||
cardOpacity: 100,
|
||||
columnOpacity: 100,
|
||||
columnBorderEnabled: true,
|
||||
cardGlassmorphism: true,
|
||||
cardBorderEnabled: true,
|
||||
cardBorderOpacity: 100,
|
||||
hideScrollbar: false,
|
||||
};
|
||||
},
|
||||
[store.boardBackgroundByProject]
|
||||
);
|
||||
|
||||
// Persisting wrappers for store actions
|
||||
const setBoardBackground = useCallback(
|
||||
async (projectPath: string, imagePath: string | null) => {
|
||||
// Get current settings first
|
||||
const current = getCurrentSettings(projectPath);
|
||||
|
||||
// Prepare the updated settings
|
||||
const toUpdate = {
|
||||
...current,
|
||||
imagePath,
|
||||
imageVersion: imagePath ? Date.now() : undefined,
|
||||
};
|
||||
|
||||
// Update local store
|
||||
store.setBoardBackground(projectPath, imagePath);
|
||||
|
||||
// Persist to server
|
||||
await persistSettings(projectPath, toUpdate);
|
||||
},
|
||||
[store, persistSettings, getCurrentSettings]
|
||||
);
|
||||
|
||||
const setCardOpacity = useCallback(
|
||||
async (projectPath: string, opacity: number) => {
|
||||
const current = getCurrentSettings(projectPath);
|
||||
store.setCardOpacity(projectPath, opacity);
|
||||
await persistSettings(projectPath, { ...current, cardOpacity: opacity });
|
||||
},
|
||||
[store, persistSettings, getCurrentSettings]
|
||||
);
|
||||
|
||||
const setColumnOpacity = useCallback(
|
||||
async (projectPath: string, opacity: number) => {
|
||||
const current = getCurrentSettings(projectPath);
|
||||
store.setColumnOpacity(projectPath, opacity);
|
||||
await persistSettings(projectPath, { ...current, columnOpacity: opacity });
|
||||
},
|
||||
[store, persistSettings, getCurrentSettings]
|
||||
);
|
||||
|
||||
const setColumnBorderEnabled = useCallback(
|
||||
async (projectPath: string, enabled: boolean) => {
|
||||
const current = getCurrentSettings(projectPath);
|
||||
store.setColumnBorderEnabled(projectPath, enabled);
|
||||
await persistSettings(projectPath, {
|
||||
...current,
|
||||
columnBorderEnabled: enabled,
|
||||
});
|
||||
},
|
||||
[store, persistSettings, getCurrentSettings]
|
||||
);
|
||||
|
||||
const setCardGlassmorphism = useCallback(
|
||||
async (projectPath: string, enabled: boolean) => {
|
||||
const current = getCurrentSettings(projectPath);
|
||||
store.setCardGlassmorphism(projectPath, enabled);
|
||||
await persistSettings(projectPath, {
|
||||
...current,
|
||||
cardGlassmorphism: enabled,
|
||||
});
|
||||
},
|
||||
[store, persistSettings, getCurrentSettings]
|
||||
);
|
||||
|
||||
const setCardBorderEnabled = useCallback(
|
||||
async (projectPath: string, enabled: boolean) => {
|
||||
const current = getCurrentSettings(projectPath);
|
||||
store.setCardBorderEnabled(projectPath, enabled);
|
||||
await persistSettings(projectPath, {
|
||||
...current,
|
||||
cardBorderEnabled: enabled,
|
||||
});
|
||||
},
|
||||
[store, persistSettings, getCurrentSettings]
|
||||
);
|
||||
|
||||
const setCardBorderOpacity = useCallback(
|
||||
async (projectPath: string, opacity: number) => {
|
||||
const current = getCurrentSettings(projectPath);
|
||||
store.setCardBorderOpacity(projectPath, opacity);
|
||||
await persistSettings(projectPath, {
|
||||
...current,
|
||||
cardBorderOpacity: opacity,
|
||||
});
|
||||
},
|
||||
[store, persistSettings, getCurrentSettings]
|
||||
);
|
||||
|
||||
const setHideScrollbar = useCallback(
|
||||
async (projectPath: string, hide: boolean) => {
|
||||
const current = getCurrentSettings(projectPath);
|
||||
store.setHideScrollbar(projectPath, hide);
|
||||
await persistSettings(projectPath, { ...current, hideScrollbar: hide });
|
||||
},
|
||||
[store, persistSettings, getCurrentSettings]
|
||||
);
|
||||
|
||||
const clearBoardBackground = useCallback(
|
||||
async (projectPath: string) => {
|
||||
store.clearBoardBackground(projectPath);
|
||||
// Clear the boardBackground settings
|
||||
await persistSettings(projectPath, {
|
||||
imagePath: null,
|
||||
imageVersion: undefined,
|
||||
cardOpacity: 100,
|
||||
columnOpacity: 100,
|
||||
columnBorderEnabled: true,
|
||||
cardGlassmorphism: true,
|
||||
cardBorderEnabled: true,
|
||||
cardBorderOpacity: 100,
|
||||
hideScrollbar: false,
|
||||
});
|
||||
},
|
||||
[store, persistSettings]
|
||||
);
|
||||
|
||||
return {
|
||||
setBoardBackground,
|
||||
setCardOpacity,
|
||||
setColumnOpacity,
|
||||
setColumnBorderEnabled,
|
||||
setCardGlassmorphism,
|
||||
setCardBorderEnabled,
|
||||
setCardBorderOpacity,
|
||||
setHideScrollbar,
|
||||
clearBoardBackground,
|
||||
getCurrentSettings,
|
||||
};
|
||||
}
|
||||
324
apps/ui/src/hooks/use-settings-migration.ts
Normal file
324
apps/ui/src/hooks/use-settings-migration.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* Settings Migration Hook and Sync Functions
|
||||
*
|
||||
* Handles migrating user settings from localStorage to persistent file-based storage
|
||||
* on app startup. Also provides utility functions for syncing individual setting
|
||||
* categories to the server.
|
||||
*
|
||||
* Migration flow:
|
||||
* 1. useSettingsMigration() hook checks server for existing settings files
|
||||
* 2. If none exist, collects localStorage data and sends to /api/settings/migrate
|
||||
* 3. After successful migration, clears deprecated localStorage keys
|
||||
* 4. Maintains automaker-storage in localStorage as fast cache for Zustand
|
||||
*
|
||||
* Sync functions for incremental updates:
|
||||
* - syncSettingsToServer: Writes global settings to file
|
||||
* - syncCredentialsToServer: Writes API keys to file
|
||||
* - syncProjectSettingsToServer: Writes project-specific overrides
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { getHttpApiClient } from "@/lib/http-api-client";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
|
||||
/**
|
||||
* State returned by useSettingsMigration hook
|
||||
*/
|
||||
interface MigrationState {
|
||||
/** Whether migration check has completed */
|
||||
checked: boolean;
|
||||
/** Whether migration actually occurred */
|
||||
migrated: boolean;
|
||||
/** Error message if migration failed (null if success/no-op) */
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* localStorage keys that may contain settings to migrate
|
||||
*
|
||||
* These keys are collected and sent to the server for migration.
|
||||
* The automaker-storage key is handled specially as it's still used by Zustand.
|
||||
*/
|
||||
const LOCALSTORAGE_KEYS = [
|
||||
"automaker-storage",
|
||||
"automaker-setup",
|
||||
"worktree-panel-collapsed",
|
||||
"file-browser-recent-folders",
|
||||
"automaker:lastProjectDir",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* localStorage keys to remove after successful migration
|
||||
*
|
||||
* automaker-storage is intentionally NOT in this list because Zustand still uses it
|
||||
* as a cache. These other keys have been migrated and are no longer needed.
|
||||
*/
|
||||
const KEYS_TO_CLEAR_AFTER_MIGRATION = [
|
||||
"worktree-panel-collapsed",
|
||||
"file-browser-recent-folders",
|
||||
"automaker:lastProjectDir",
|
||||
// Legacy keys from older versions
|
||||
"automaker_projects",
|
||||
"automaker_current_project",
|
||||
"automaker_trashed_projects",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* React hook to handle settings migration from localStorage to file-based storage
|
||||
*
|
||||
* Runs automatically once on component mount. Returns state indicating whether
|
||||
* migration check is complete, whether migration occurred, and any errors.
|
||||
*
|
||||
* Only runs in Electron mode (isElectron() must be true). Web mode uses different
|
||||
* storage mechanisms.
|
||||
*
|
||||
* The hook uses a ref to ensure it only runs once despite multiple mounts.
|
||||
*
|
||||
* @returns MigrationState with checked, migrated, and error fields
|
||||
*/
|
||||
export function useSettingsMigration(): MigrationState {
|
||||
const [state, setState] = useState<MigrationState>({
|
||||
checked: false,
|
||||
migrated: false,
|
||||
error: null,
|
||||
});
|
||||
const migrationAttempted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Only run once
|
||||
if (migrationAttempted.current) return;
|
||||
migrationAttempted.current = true;
|
||||
|
||||
async function checkAndMigrate() {
|
||||
// Only run migration in Electron mode (web mode uses different storage)
|
||||
if (!isElectron()) {
|
||||
setState({ checked: true, migrated: false, error: null });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
|
||||
// Check if server has settings files
|
||||
const status = await api.settings.getStatus();
|
||||
|
||||
if (!status.success) {
|
||||
console.error("[Settings Migration] Failed to get status:", status);
|
||||
setState({
|
||||
checked: true,
|
||||
migrated: false,
|
||||
error: "Failed to check settings status",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If settings files already exist, no migration needed
|
||||
if (!status.needsMigration) {
|
||||
console.log(
|
||||
"[Settings Migration] Settings files exist, no migration needed"
|
||||
);
|
||||
setState({ checked: true, migrated: false, error: null });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we have localStorage data to migrate
|
||||
const automakerStorage = localStorage.getItem("automaker-storage");
|
||||
if (!automakerStorage) {
|
||||
console.log(
|
||||
"[Settings Migration] No localStorage data to migrate"
|
||||
);
|
||||
setState({ checked: true, migrated: false, error: null });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[Settings Migration] Starting migration...");
|
||||
|
||||
// Collect all localStorage data
|
||||
const localStorageData: Record<string, string> = {};
|
||||
for (const key of LOCALSTORAGE_KEYS) {
|
||||
const value = localStorage.getItem(key);
|
||||
if (value) {
|
||||
localStorageData[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Send to server for migration
|
||||
const result = await api.settings.migrate(localStorageData);
|
||||
|
||||
if (result.success) {
|
||||
console.log("[Settings Migration] Migration successful:", {
|
||||
globalSettings: result.migratedGlobalSettings,
|
||||
credentials: result.migratedCredentials,
|
||||
projects: result.migratedProjectCount,
|
||||
});
|
||||
|
||||
// Clear old localStorage keys (but keep automaker-storage for Zustand)
|
||||
for (const key of KEYS_TO_CLEAR_AFTER_MIGRATION) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
setState({ checked: true, migrated: true, error: null });
|
||||
} else {
|
||||
console.warn(
|
||||
"[Settings Migration] Migration had errors:",
|
||||
result.errors
|
||||
);
|
||||
setState({
|
||||
checked: true,
|
||||
migrated: false,
|
||||
error: result.errors.join(", "),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Settings Migration] Migration failed:", error);
|
||||
setState({
|
||||
checked: true,
|
||||
migrated: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
checkAndMigrate();
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync current global settings to file-based server storage
|
||||
*
|
||||
* Reads the current Zustand state from localStorage and sends all global settings
|
||||
* to the server to be written to {dataDir}/settings.json.
|
||||
*
|
||||
* Call this when important global settings change (theme, UI preferences, profiles, etc.)
|
||||
* Safe to call from store subscribers or change handlers.
|
||||
*
|
||||
* Only functions in Electron mode. Returns false if not in Electron or on error.
|
||||
*
|
||||
* @returns Promise resolving to true if sync succeeded, false otherwise
|
||||
*/
|
||||
export async function syncSettingsToServer(): Promise<boolean> {
|
||||
if (!isElectron()) return false;
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const automakerStorage = localStorage.getItem("automaker-storage");
|
||||
|
||||
if (!automakerStorage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(automakerStorage);
|
||||
const state = parsed.state || parsed;
|
||||
|
||||
// Extract settings to sync
|
||||
const updates = {
|
||||
theme: state.theme,
|
||||
sidebarOpen: state.sidebarOpen,
|
||||
chatHistoryOpen: state.chatHistoryOpen,
|
||||
kanbanCardDetailLevel: state.kanbanCardDetailLevel,
|
||||
maxConcurrency: state.maxConcurrency,
|
||||
defaultSkipTests: state.defaultSkipTests,
|
||||
enableDependencyBlocking: state.enableDependencyBlocking,
|
||||
useWorktrees: state.useWorktrees,
|
||||
showProfilesOnly: state.showProfilesOnly,
|
||||
defaultPlanningMode: state.defaultPlanningMode,
|
||||
defaultRequirePlanApproval: state.defaultRequirePlanApproval,
|
||||
defaultAIProfileId: state.defaultAIProfileId,
|
||||
muteDoneSound: state.muteDoneSound,
|
||||
enhancementModel: state.enhancementModel,
|
||||
keyboardShortcuts: state.keyboardShortcuts,
|
||||
aiProfiles: state.aiProfiles,
|
||||
projects: state.projects,
|
||||
trashedProjects: state.trashedProjects,
|
||||
projectHistory: state.projectHistory,
|
||||
projectHistoryIndex: state.projectHistoryIndex,
|
||||
lastSelectedSessionByProject: state.lastSelectedSessionByProject,
|
||||
};
|
||||
|
||||
const result = await api.settings.updateGlobal(updates);
|
||||
return result.success;
|
||||
} catch (error) {
|
||||
console.error("[Settings Sync] Failed to sync settings:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync API credentials to file-based server storage
|
||||
*
|
||||
* Sends API keys (partial update supported) to the server to be written to
|
||||
* {dataDir}/credentials.json. Credentials are kept separate from settings for security.
|
||||
*
|
||||
* Call this when API keys are added or updated in settings UI.
|
||||
* Only requires providing the keys that have changed.
|
||||
*
|
||||
* Only functions in Electron mode. Returns false if not in Electron or on error.
|
||||
*
|
||||
* @param apiKeys - Partial credential object with optional anthropic, google, openai keys
|
||||
* @returns Promise resolving to true if sync succeeded, false otherwise
|
||||
*/
|
||||
export async function syncCredentialsToServer(apiKeys: {
|
||||
anthropic?: string;
|
||||
google?: string;
|
||||
openai?: string;
|
||||
}): Promise<boolean> {
|
||||
if (!isElectron()) return false;
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.settings.updateCredentials({ apiKeys });
|
||||
return result.success;
|
||||
} catch (error) {
|
||||
console.error("[Settings Sync] Failed to sync credentials:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync project-specific settings to file-based server storage
|
||||
*
|
||||
* Sends project settings (theme, worktree config, board customization) to the server
|
||||
* to be written to {projectPath}/.automaker/settings.json.
|
||||
*
|
||||
* These settings override global settings for specific projects.
|
||||
* Supports partial updates - only include fields that have changed.
|
||||
*
|
||||
* Call this when project settings are modified in the board or settings UI.
|
||||
* Only functions in Electron mode. Returns false if not in Electron or on error.
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @param updates - Partial ProjectSettings with optional theme, worktree, and board settings
|
||||
* @returns Promise resolving to true if sync succeeded, false otherwise
|
||||
*/
|
||||
export async function syncProjectSettingsToServer(
|
||||
projectPath: string,
|
||||
updates: {
|
||||
theme?: string;
|
||||
useWorktrees?: boolean;
|
||||
boardBackground?: Record<string, unknown>;
|
||||
currentWorktree?: { path: string | null; branch: string };
|
||||
worktrees?: Array<{
|
||||
path: string;
|
||||
branch: string;
|
||||
isMain: boolean;
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
}>;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
if (!isElectron()) return false;
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.settings.updateProject(projectPath, updates);
|
||||
return result.success;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[Settings Sync] Failed to sync project settings:",
|
||||
error
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -203,6 +203,9 @@ export interface FeaturesAPI {
|
||||
projectPath: string,
|
||||
featureId: string
|
||||
) => Promise<{ success: boolean; content?: string | null; error?: string }>;
|
||||
generateTitle: (
|
||||
description: string
|
||||
) => Promise<{ success: boolean; title?: string; error?: string }>;
|
||||
}
|
||||
|
||||
export interface AutoModeAPI {
|
||||
@@ -505,7 +508,15 @@ const mockFileSystem: Record<string, string> = {};
|
||||
|
||||
// Check if we're in Electron (for UI indicators only)
|
||||
export const isElectron = (): boolean => {
|
||||
return typeof window !== "undefined" && window.isElectron === true;
|
||||
if (typeof window === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((window as any).isElectron === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return window.electronAPI?.isElectron === true;
|
||||
};
|
||||
|
||||
// Check if backend server is available
|
||||
@@ -1353,6 +1364,17 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
getPRInfo: async (worktreePath: string, branchName: string) => {
|
||||
console.log("[Mock] Getting PR info:", { worktreePath, branchName });
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
hasPR: false,
|
||||
ghCliAvailable: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2595,6 +2617,14 @@ function createMockFeaturesAPI(): FeaturesAPI {
|
||||
const content = mockFileSystem[agentOutputPath];
|
||||
return { success: true, content: content || null };
|
||||
},
|
||||
|
||||
generateTitle: async (description: string) => {
|
||||
console.log("[Mock] Generating title for:", description.substring(0, 50));
|
||||
// Mock title generation - just take first few words
|
||||
const words = description.split(/\s+/).slice(0, 6).join(" ");
|
||||
const title = words.length > 40 ? words.substring(0, 40) + "..." : words;
|
||||
return { success: true, title: `Add ${title}` };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -512,6 +512,8 @@ export class HttpApiClient implements ElectronAPI {
|
||||
this.post("/api/features/delete", { projectPath, featureId }),
|
||||
getAgentOutput: (projectPath: string, featureId: string) =>
|
||||
this.post("/api/features/agent-output", { projectPath, featureId }),
|
||||
generateTitle: (description: string) =>
|
||||
this.post("/api/features/generate-title", { description }),
|
||||
};
|
||||
|
||||
// Auto Mode API
|
||||
@@ -672,6 +674,8 @@ export class HttpApiClient implements ElectronAPI {
|
||||
stopDevServer: (worktreePath: string) =>
|
||||
this.post("/api/worktree/stop-dev", { worktreePath }),
|
||||
listDevServers: () => this.post("/api/worktree/list-dev-servers", {}),
|
||||
getPRInfo: (worktreePath: string, branchName: string) =>
|
||||
this.post("/api/worktree/pr-info", { worktreePath, branchName }),
|
||||
};
|
||||
|
||||
// Git API
|
||||
@@ -833,6 +837,135 @@ export class HttpApiClient implements ElectronAPI {
|
||||
this.post("/api/templates/clone", { repoUrl, projectName, parentDir }),
|
||||
};
|
||||
|
||||
// Settings API - persistent file-based settings
|
||||
settings = {
|
||||
// Get settings status (check if migration needed)
|
||||
getStatus: (): Promise<{
|
||||
success: boolean;
|
||||
hasGlobalSettings: boolean;
|
||||
hasCredentials: boolean;
|
||||
dataDir: string;
|
||||
needsMigration: boolean;
|
||||
}> => this.get("/api/settings/status"),
|
||||
|
||||
// Global settings
|
||||
getGlobal: (): Promise<{
|
||||
success: boolean;
|
||||
settings?: {
|
||||
version: number;
|
||||
theme: string;
|
||||
sidebarOpen: boolean;
|
||||
chatHistoryOpen: boolean;
|
||||
kanbanCardDetailLevel: string;
|
||||
maxConcurrency: number;
|
||||
defaultSkipTests: boolean;
|
||||
enableDependencyBlocking: boolean;
|
||||
useWorktrees: boolean;
|
||||
showProfilesOnly: boolean;
|
||||
defaultPlanningMode: string;
|
||||
defaultRequirePlanApproval: boolean;
|
||||
defaultAIProfileId: string | null;
|
||||
muteDoneSound: boolean;
|
||||
enhancementModel: string;
|
||||
keyboardShortcuts: Record<string, string>;
|
||||
aiProfiles: unknown[];
|
||||
projects: unknown[];
|
||||
trashedProjects: unknown[];
|
||||
projectHistory: string[];
|
||||
projectHistoryIndex: number;
|
||||
lastProjectDir?: string;
|
||||
recentFolders: string[];
|
||||
worktreePanelCollapsed: boolean;
|
||||
lastSelectedSessionByProject: Record<string, string>;
|
||||
};
|
||||
error?: string;
|
||||
}> => this.get("/api/settings/global"),
|
||||
|
||||
updateGlobal: (updates: Record<string, unknown>): Promise<{
|
||||
success: boolean;
|
||||
settings?: Record<string, unknown>;
|
||||
error?: string;
|
||||
}> => this.put("/api/settings/global", updates),
|
||||
|
||||
// Credentials (masked for security)
|
||||
getCredentials: (): Promise<{
|
||||
success: boolean;
|
||||
credentials?: {
|
||||
anthropic: { configured: boolean; masked: string };
|
||||
google: { configured: boolean; masked: string };
|
||||
openai: { configured: boolean; masked: string };
|
||||
};
|
||||
error?: string;
|
||||
}> => this.get("/api/settings/credentials"),
|
||||
|
||||
updateCredentials: (updates: {
|
||||
apiKeys?: { anthropic?: string; google?: string; openai?: string };
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
credentials?: {
|
||||
anthropic: { configured: boolean; masked: string };
|
||||
google: { configured: boolean; masked: string };
|
||||
openai: { configured: boolean; masked: string };
|
||||
};
|
||||
error?: string;
|
||||
}> => this.put("/api/settings/credentials", updates),
|
||||
|
||||
// Project settings
|
||||
getProject: (projectPath: string): Promise<{
|
||||
success: boolean;
|
||||
settings?: {
|
||||
version: number;
|
||||
theme?: string;
|
||||
useWorktrees?: boolean;
|
||||
currentWorktree?: { path: string | null; branch: string };
|
||||
worktrees?: Array<{
|
||||
path: string;
|
||||
branch: string;
|
||||
isMain: boolean;
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
}>;
|
||||
boardBackground?: {
|
||||
imagePath: string | null;
|
||||
imageVersion?: number;
|
||||
cardOpacity: number;
|
||||
columnOpacity: number;
|
||||
columnBorderEnabled: boolean;
|
||||
cardGlassmorphism: boolean;
|
||||
cardBorderEnabled: boolean;
|
||||
cardBorderOpacity: number;
|
||||
hideScrollbar: boolean;
|
||||
};
|
||||
lastSelectedSessionId?: string;
|
||||
};
|
||||
error?: string;
|
||||
}> => this.post("/api/settings/project", { projectPath }),
|
||||
|
||||
updateProject: (
|
||||
projectPath: string,
|
||||
updates: Record<string, unknown>
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
settings?: Record<string, unknown>;
|
||||
error?: string;
|
||||
}> => this.put("/api/settings/project", { projectPath, updates }),
|
||||
|
||||
// Migration from localStorage
|
||||
migrate: (data: {
|
||||
"automaker-storage"?: string;
|
||||
"automaker-setup"?: string;
|
||||
"worktree-panel-collapsed"?: string;
|
||||
"file-browser-recent-folders"?: string;
|
||||
"automaker:lastProjectDir"?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
migratedGlobalSettings: boolean;
|
||||
migratedCredentials: boolean;
|
||||
migratedProjectCount: number;
|
||||
errors: string[];
|
||||
}> => this.post("/api/settings/migrate", { data }),
|
||||
};
|
||||
|
||||
// Sessions API
|
||||
sessions = {
|
||||
list: (
|
||||
|
||||
@@ -304,6 +304,20 @@ function createWindow(): void {
|
||||
|
||||
// App lifecycle
|
||||
app.whenReady().then(async () => {
|
||||
// Ensure userData path is consistent across dev/prod so files land in Automaker dir
|
||||
try {
|
||||
const desiredUserDataPath = path.join(app.getPath("appData"), "Automaker");
|
||||
if (app.getPath("userData") !== desiredUserDataPath) {
|
||||
app.setPath("userData", desiredUserDataPath);
|
||||
console.log("[Electron] userData path set to:", desiredUserDataPath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[Electron] Failed to set userData path:",
|
||||
(error as Error).message
|
||||
);
|
||||
}
|
||||
|
||||
if (process.platform === "darwin" && app.dock) {
|
||||
const iconPath = getIconPath();
|
||||
if (iconPath) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useState, useCallback } from "react";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { FileBrowserProvider, useFileBrowser, setGlobalFileBrowser } from "@/contexts/file-browser-context";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { useSetupStore } from "@/store/setup-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { Toaster } from "sonner";
|
||||
import { ThemeOption, themeOptions } from "@/config/theme-options";
|
||||
@@ -16,9 +17,13 @@ function RootLayoutContent() {
|
||||
previewTheme,
|
||||
getEffectiveTheme,
|
||||
} = useAppStore();
|
||||
const { setupComplete } = useSetupStore();
|
||||
const navigate = useNavigate();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [streamerPanelOpen, setStreamerPanelOpen] = useState(false);
|
||||
const [setupHydrated, setSetupHydrated] = useState(() =>
|
||||
useSetupStore.persist?.hasHydrated?.() ?? false
|
||||
);
|
||||
const { openFileBrowser } = useFileBrowser();
|
||||
|
||||
// Hidden streamer panel - opens with "\" key
|
||||
@@ -61,6 +66,35 @@ function RootLayoutContent() {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
// Wait for setup store hydration before enforcing routing rules
|
||||
useEffect(() => {
|
||||
if (useSetupStore.persist?.hasHydrated?.()) {
|
||||
setSetupHydrated(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = useSetupStore.persist?.onFinishHydration?.(() => {
|
||||
setSetupHydrated(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (typeof unsubscribe === "function") {
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Redirect first-run users (or anyone who reopened the wizard) to /setup
|
||||
useEffect(() => {
|
||||
if (!setupHydrated) return;
|
||||
|
||||
if (!setupComplete && location.pathname !== "/setup") {
|
||||
navigate({ to: "/setup" });
|
||||
} else if (setupComplete && location.pathname === "/setup") {
|
||||
navigate({ to: "/" });
|
||||
}
|
||||
}, [setupComplete, setupHydrated, location.pathname, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
setGlobalFileBrowser(openFileBrowser);
|
||||
}, [openFileBrowser]);
|
||||
|
||||
@@ -280,6 +280,8 @@ export interface AIProfile {
|
||||
|
||||
export interface Feature {
|
||||
id: string;
|
||||
title?: string;
|
||||
titleGenerating?: boolean;
|
||||
category: string;
|
||||
description: string;
|
||||
steps: string[];
|
||||
@@ -305,6 +307,7 @@ export interface Feature {
|
||||
planningMode?: PlanningMode; // Planning mode for this feature
|
||||
planSpec?: PlanSpec; // Generated spec/plan data
|
||||
requirePlanApproval?: boolean; // Whether to pause and require manual approval before implementation
|
||||
prUrl?: string; // Pull request URL when a PR has been created for this feature
|
||||
}
|
||||
|
||||
// Parsed task from spec (for spec and full planning modes)
|
||||
@@ -494,6 +497,7 @@ export interface AppState {
|
||||
|
||||
defaultPlanningMode: PlanningMode;
|
||||
defaultRequirePlanApproval: boolean;
|
||||
defaultAIProfileId: string | null;
|
||||
|
||||
// Plan Approval State
|
||||
// When a plan requires user approval, this holds the pending approval details
|
||||
@@ -742,6 +746,7 @@ export interface AppActions {
|
||||
|
||||
setDefaultPlanningMode: (mode: PlanningMode) => void;
|
||||
setDefaultRequirePlanApproval: (require: boolean) => void;
|
||||
setDefaultAIProfileId: (profileId: string | null) => void;
|
||||
|
||||
// Plan Approval actions
|
||||
setPendingPlanApproval: (approval: {
|
||||
@@ -841,6 +846,7 @@ const initialState: AppState = {
|
||||
specCreatingForProject: null,
|
||||
defaultPlanningMode: 'skip' as PlanningMode,
|
||||
defaultRequirePlanApproval: false,
|
||||
defaultAIProfileId: null,
|
||||
pendingPlanApproval: null,
|
||||
};
|
||||
|
||||
@@ -1510,6 +1516,10 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
// Only allow removing non-built-in profiles
|
||||
const profile = get().aiProfiles.find((p) => p.id === id);
|
||||
if (profile && !profile.isBuiltIn) {
|
||||
// Clear default if this profile was selected
|
||||
if (get().defaultAIProfileId === id) {
|
||||
set({ defaultAIProfileId: null });
|
||||
}
|
||||
set({ aiProfiles: get().aiProfiles.filter((p) => p.id !== id) });
|
||||
}
|
||||
},
|
||||
@@ -2265,6 +2275,7 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
|
||||
setDefaultPlanningMode: (mode) => set({ defaultPlanningMode: mode }),
|
||||
setDefaultRequirePlanApproval: (require) => set({ defaultRequirePlanApproval: require }),
|
||||
setDefaultAIProfileId: (profileId) => set({ defaultAIProfileId: profileId }),
|
||||
|
||||
// Plan Approval actions
|
||||
setPendingPlanApproval: (approval) => set({ pendingPlanApproval: approval }),
|
||||
@@ -2340,7 +2351,210 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
boardBackgroundByProject: state.boardBackgroundByProject,
|
||||
defaultPlanningMode: state.defaultPlanningMode,
|
||||
defaultRequirePlanApproval: state.defaultRequirePlanApproval,
|
||||
defaultAIProfileId: state.defaultAIProfileId,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Settings Sync to Server (file-based storage)
|
||||
// ============================================================================
|
||||
|
||||
// Debounced sync function to avoid excessive server calls
|
||||
let syncTimeoutId: NodeJS.Timeout | null = null;
|
||||
const SYNC_DEBOUNCE_MS = 2000; // Wait 2 seconds after last change before syncing
|
||||
|
||||
/**
|
||||
* Schedule a sync of current settings to the server
|
||||
* This is debounced to avoid excessive API calls
|
||||
*/
|
||||
function scheduleSyncToServer() {
|
||||
// Only sync in Electron mode
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
// Clear any pending sync
|
||||
if (syncTimeoutId) {
|
||||
clearTimeout(syncTimeoutId);
|
||||
}
|
||||
|
||||
// Schedule new sync
|
||||
syncTimeoutId = setTimeout(async () => {
|
||||
try {
|
||||
// Dynamic import to avoid circular dependencies
|
||||
const { syncSettingsToServer } = await import(
|
||||
"@/hooks/use-settings-migration"
|
||||
);
|
||||
await syncSettingsToServer();
|
||||
} catch (error) {
|
||||
console.error("[AppStore] Failed to sync settings to server:", error);
|
||||
}
|
||||
}, SYNC_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
// Subscribe to store changes and sync to server
|
||||
// Only sync when important settings change (not every state change)
|
||||
let previousState: Partial<AppState> | null = null;
|
||||
let previousProjectSettings: Record<
|
||||
string,
|
||||
{
|
||||
theme?: string;
|
||||
boardBackground?: typeof initialState.boardBackgroundByProject[string];
|
||||
currentWorktree?: typeof initialState.currentWorktreeByProject[string];
|
||||
worktrees?: typeof initialState.worktreesByProject[string];
|
||||
}
|
||||
> = {};
|
||||
|
||||
// Track pending project syncs (debounced per project)
|
||||
const projectSyncTimeouts: Record<string, NodeJS.Timeout> = {};
|
||||
const PROJECT_SYNC_DEBOUNCE_MS = 2000;
|
||||
|
||||
/**
|
||||
* Schedule sync of project settings to server
|
||||
*/
|
||||
function scheduleProjectSettingsSync(
|
||||
projectPath: string,
|
||||
updates: Record<string, unknown>
|
||||
) {
|
||||
// Only sync in Electron mode
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
// Clear any pending sync for this project
|
||||
if (projectSyncTimeouts[projectPath]) {
|
||||
clearTimeout(projectSyncTimeouts[projectPath]);
|
||||
}
|
||||
|
||||
// Schedule new sync
|
||||
projectSyncTimeouts[projectPath] = setTimeout(async () => {
|
||||
try {
|
||||
const { syncProjectSettingsToServer } = await import(
|
||||
"@/hooks/use-settings-migration"
|
||||
);
|
||||
await syncProjectSettingsToServer(projectPath, updates);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[AppStore] Failed to sync project settings for ${projectPath}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
delete projectSyncTimeouts[projectPath];
|
||||
}, PROJECT_SYNC_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
useAppStore.subscribe((state) => {
|
||||
// Skip if this is the initial load
|
||||
if (!previousState) {
|
||||
previousState = {
|
||||
theme: state.theme,
|
||||
projects: state.projects,
|
||||
trashedProjects: state.trashedProjects,
|
||||
keyboardShortcuts: state.keyboardShortcuts,
|
||||
aiProfiles: state.aiProfiles,
|
||||
maxConcurrency: state.maxConcurrency,
|
||||
defaultSkipTests: state.defaultSkipTests,
|
||||
enableDependencyBlocking: state.enableDependencyBlocking,
|
||||
useWorktrees: state.useWorktrees,
|
||||
showProfilesOnly: state.showProfilesOnly,
|
||||
muteDoneSound: state.muteDoneSound,
|
||||
enhancementModel: state.enhancementModel,
|
||||
defaultPlanningMode: state.defaultPlanningMode,
|
||||
defaultRequirePlanApproval: state.defaultRequirePlanApproval,
|
||||
defaultAIProfileId: state.defaultAIProfileId,
|
||||
};
|
||||
// Initialize project settings tracking
|
||||
for (const project of state.projects) {
|
||||
previousProjectSettings[project.path] = {
|
||||
theme: project.theme,
|
||||
boardBackground: state.boardBackgroundByProject[project.path],
|
||||
currentWorktree: state.currentWorktreeByProject[project.path],
|
||||
worktrees: state.worktreesByProject[project.path],
|
||||
};
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if any important global settings changed
|
||||
const importantSettingsChanged =
|
||||
state.theme !== previousState.theme ||
|
||||
state.projects !== previousState.projects ||
|
||||
state.trashedProjects !== previousState.trashedProjects ||
|
||||
state.keyboardShortcuts !== previousState.keyboardShortcuts ||
|
||||
state.aiProfiles !== previousState.aiProfiles ||
|
||||
state.maxConcurrency !== previousState.maxConcurrency ||
|
||||
state.defaultSkipTests !== previousState.defaultSkipTests ||
|
||||
state.enableDependencyBlocking !== previousState.enableDependencyBlocking ||
|
||||
state.useWorktrees !== previousState.useWorktrees ||
|
||||
state.showProfilesOnly !== previousState.showProfilesOnly ||
|
||||
state.muteDoneSound !== previousState.muteDoneSound ||
|
||||
state.enhancementModel !== previousState.enhancementModel ||
|
||||
state.defaultPlanningMode !== previousState.defaultPlanningMode ||
|
||||
state.defaultRequirePlanApproval !== previousState.defaultRequirePlanApproval ||
|
||||
state.defaultAIProfileId !== previousState.defaultAIProfileId;
|
||||
|
||||
if (importantSettingsChanged) {
|
||||
// Update previous state
|
||||
previousState = {
|
||||
theme: state.theme,
|
||||
projects: state.projects,
|
||||
trashedProjects: state.trashedProjects,
|
||||
keyboardShortcuts: state.keyboardShortcuts,
|
||||
aiProfiles: state.aiProfiles,
|
||||
maxConcurrency: state.maxConcurrency,
|
||||
defaultSkipTests: state.defaultSkipTests,
|
||||
enableDependencyBlocking: state.enableDependencyBlocking,
|
||||
useWorktrees: state.useWorktrees,
|
||||
showProfilesOnly: state.showProfilesOnly,
|
||||
muteDoneSound: state.muteDoneSound,
|
||||
enhancementModel: state.enhancementModel,
|
||||
defaultPlanningMode: state.defaultPlanningMode,
|
||||
defaultRequirePlanApproval: state.defaultRequirePlanApproval,
|
||||
defaultAIProfileId: state.defaultAIProfileId,
|
||||
};
|
||||
|
||||
// Schedule sync to server
|
||||
scheduleSyncToServer();
|
||||
}
|
||||
|
||||
// Check for per-project settings changes
|
||||
for (const project of state.projects) {
|
||||
const projectPath = project.path;
|
||||
const prev = previousProjectSettings[projectPath] || {};
|
||||
const updates: Record<string, unknown> = {};
|
||||
|
||||
// Check if project theme changed
|
||||
if (project.theme !== prev.theme) {
|
||||
updates.theme = project.theme;
|
||||
}
|
||||
|
||||
// Check if board background changed
|
||||
const currentBg = state.boardBackgroundByProject[projectPath];
|
||||
if (currentBg !== prev.boardBackground) {
|
||||
updates.boardBackground = currentBg;
|
||||
}
|
||||
|
||||
// Check if current worktree changed
|
||||
const currentWt = state.currentWorktreeByProject[projectPath];
|
||||
if (currentWt !== prev.currentWorktree) {
|
||||
updates.currentWorktree = currentWt;
|
||||
}
|
||||
|
||||
// Check if worktrees list changed
|
||||
const worktrees = state.worktreesByProject[projectPath];
|
||||
if (worktrees !== prev.worktrees) {
|
||||
updates.worktrees = worktrees;
|
||||
}
|
||||
|
||||
// If any project settings changed, sync them
|
||||
if (Object.keys(updates).length > 0) {
|
||||
scheduleProjectSettingsSync(projectPath, updates);
|
||||
|
||||
// Update tracking
|
||||
previousProjectSettings[projectPath] = {
|
||||
theme: project.theme,
|
||||
boardBackground: currentBg,
|
||||
currentWorktree: currentWt,
|
||||
worktrees: worktrees,
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface InstallProgress {
|
||||
|
||||
export type SetupStep =
|
||||
| "welcome"
|
||||
| "theme"
|
||||
| "claude_detect"
|
||||
| "claude_auth"
|
||||
| "github"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
22
apps/ui/src/styles/theme-imports.ts
Normal file
22
apps/ui/src/styles/theme-imports.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Bundles all individual theme styles so the build pipeline
|
||||
* doesn't tree-shake their CSS when imported dynamically.
|
||||
*/
|
||||
import "./themes/dark.css";
|
||||
import "./themes/light.css";
|
||||
import "./themes/retro.css";
|
||||
import "./themes/dracula.css";
|
||||
import "./themes/nord.css";
|
||||
import "./themes/monokai.css";
|
||||
import "./themes/tokyonight.css";
|
||||
import "./themes/solarized.css";
|
||||
import "./themes/gruvbox.css";
|
||||
import "./themes/catppuccin.css";
|
||||
import "./themes/onedark.css";
|
||||
import "./themes/synthwave.css";
|
||||
import "./themes/red.css";
|
||||
import "./themes/cream.css";
|
||||
import "./themes/sunset.css";
|
||||
import "./themes/gray.css";
|
||||
|
||||
|
||||
144
apps/ui/src/styles/themes/catppuccin.css
Normal file
144
apps/ui/src/styles/themes/catppuccin.css
Normal file
@@ -0,0 +1,144 @@
|
||||
/* Catppuccin Theme */
|
||||
|
||||
.catppuccin {
|
||||
--background: oklch(0.18 0.02 260); /* #1e1e2e base */
|
||||
--background-50: oklch(0.18 0.02 260 / 0.5);
|
||||
--background-80: oklch(0.18 0.02 260 / 0.8);
|
||||
|
||||
--foreground: oklch(0.9 0.01 280); /* #cdd6f4 text */
|
||||
--foreground-secondary: oklch(0.75 0.02 280); /* #bac2de subtext1 */
|
||||
--foreground-muted: oklch(0.6 0.03 280); /* #a6adc8 subtext0 */
|
||||
|
||||
--card: oklch(0.22 0.02 260); /* #313244 surface0 */
|
||||
--card-foreground: oklch(0.9 0.01 280);
|
||||
--popover: oklch(0.2 0.02 260);
|
||||
--popover-foreground: oklch(0.9 0.01 280);
|
||||
|
||||
--primary: oklch(0.75 0.15 280); /* #cba6f7 mauve */
|
||||
--primary-foreground: oklch(0.18 0.02 260);
|
||||
|
||||
--brand-400: oklch(0.8 0.15 280);
|
||||
--brand-500: oklch(0.75 0.15 280); /* Mauve */
|
||||
--brand-600: oklch(0.7 0.17 280);
|
||||
|
||||
--secondary: oklch(0.26 0.02 260); /* #45475a surface1 */
|
||||
--secondary-foreground: oklch(0.9 0.01 280);
|
||||
|
||||
--muted: oklch(0.26 0.02 260);
|
||||
--muted-foreground: oklch(0.6 0.03 280);
|
||||
|
||||
--accent: oklch(0.3 0.03 260); /* #585b70 surface2 */
|
||||
--accent-foreground: oklch(0.9 0.01 280);
|
||||
|
||||
--destructive: oklch(0.65 0.2 15); /* #f38ba8 red */
|
||||
|
||||
--border: oklch(0.35 0.03 260);
|
||||
--border-glass: oklch(0.75 0.15 280 / 0.3);
|
||||
|
||||
--input: oklch(0.22 0.02 260);
|
||||
--ring: oklch(0.75 0.15 280);
|
||||
|
||||
--chart-1: oklch(0.75 0.15 280); /* Mauve */
|
||||
--chart-2: oklch(0.75 0.15 220); /* Blue #89b4fa */
|
||||
--chart-3: oklch(0.8 0.15 160); /* Green #a6e3a1 */
|
||||
--chart-4: oklch(0.8 0.15 350); /* Pink #f5c2e7 */
|
||||
--chart-5: oklch(0.85 0.12 90); /* Yellow #f9e2af */
|
||||
|
||||
--sidebar: oklch(0.16 0.02 260); /* #181825 mantle */
|
||||
--sidebar-foreground: oklch(0.9 0.01 280);
|
||||
--sidebar-primary: oklch(0.75 0.15 280);
|
||||
--sidebar-primary-foreground: oklch(0.18 0.02 260);
|
||||
--sidebar-accent: oklch(0.26 0.02 260);
|
||||
--sidebar-accent-foreground: oklch(0.9 0.01 280);
|
||||
--sidebar-border: oklch(0.35 0.03 260);
|
||||
--sidebar-ring: oklch(0.75 0.15 280);
|
||||
|
||||
/* Action button colors - Catppuccin mauve/pink theme */
|
||||
--action-view: oklch(0.75 0.15 280); /* Mauve */
|
||||
--action-view-hover: oklch(0.7 0.17 280);
|
||||
--action-followup: oklch(0.75 0.15 220); /* Blue */
|
||||
--action-followup-hover: oklch(0.7 0.17 220);
|
||||
--action-commit: oklch(0.8 0.15 160); /* Green */
|
||||
--action-commit-hover: oklch(0.75 0.17 160);
|
||||
--action-verify: oklch(0.8 0.15 160); /* Green */
|
||||
--action-verify-hover: oklch(0.75 0.17 160);
|
||||
|
||||
/* Running indicator - Mauve */
|
||||
--running-indicator: oklch(0.75 0.15 280);
|
||||
--running-indicator-text: oklch(0.8 0.13 280);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
ONE DARK THEME
|
||||
Atom's iconic One Dark theme
|
||||
======================================== */
|
||||
|
||||
/* Theme-specific overrides */
|
||||
|
||||
.catppuccin .animated-outline-gradient {
|
||||
background: conic-gradient(from 90deg at 50% 50%, #cba6f7 0%, #f5c2e7 50%, #cba6f7 100%);
|
||||
}
|
||||
|
||||
.catppuccin .animated-outline-inner {
|
||||
background: oklch(0.18 0.02 260) !important;
|
||||
color: #cba6f7 !important;
|
||||
}
|
||||
|
||||
.catppuccin [data-slot="button"][class*="animated-outline"]:hover .animated-outline-inner {
|
||||
background: oklch(0.24 0.03 260) !important;
|
||||
color: #f5c2e7 !important;
|
||||
}
|
||||
|
||||
.catppuccin .slider-track {
|
||||
background: oklch(0.26 0.02 260);
|
||||
}
|
||||
|
||||
.catppuccin .slider-range {
|
||||
background: linear-gradient(to right, #cba6f7, #89b4fa);
|
||||
}
|
||||
|
||||
.catppuccin .slider-thumb {
|
||||
background: oklch(0.22 0.02 260);
|
||||
border-color: #cba6f7;
|
||||
}
|
||||
|
||||
.catppuccin .xml-highlight {
|
||||
color: oklch(0.9 0.01 280); /* #cdd6f4 */
|
||||
}
|
||||
|
||||
.catppuccin .xml-tag-bracket {
|
||||
color: oklch(0.65 0.2 15); /* #f38ba8 red */
|
||||
}
|
||||
|
||||
.catppuccin .xml-tag-name {
|
||||
color: oklch(0.65 0.2 15); /* Red for tags */
|
||||
}
|
||||
|
||||
.catppuccin .xml-attribute-name {
|
||||
color: oklch(0.75 0.15 280); /* #cba6f7 mauve */
|
||||
}
|
||||
|
||||
.catppuccin .xml-attribute-equals {
|
||||
color: oklch(0.75 0.02 280); /* Subtext */
|
||||
}
|
||||
|
||||
.catppuccin .xml-attribute-value {
|
||||
color: oklch(0.8 0.15 160); /* #a6e3a1 green */
|
||||
}
|
||||
|
||||
.catppuccin .xml-comment {
|
||||
color: oklch(0.5 0.04 280); /* Overlay */
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.catppuccin .xml-cdata {
|
||||
color: oklch(0.75 0.15 220); /* #89b4fa blue */
|
||||
}
|
||||
|
||||
.catppuccin .xml-doctype {
|
||||
color: oklch(0.8 0.15 350); /* #f5c2e7 pink */
|
||||
}
|
||||
|
||||
.catppuccin .xml-text {
|
||||
color: oklch(0.9 0.01 280); /* Text */
|
||||
}
|
||||
116
apps/ui/src/styles/themes/cream.css
Normal file
116
apps/ui/src/styles/themes/cream.css
Normal file
@@ -0,0 +1,116 @@
|
||||
/* Cream Theme */
|
||||
|
||||
.cream {
|
||||
/* Cream Theme - Warm, soft, easy on the eyes */
|
||||
--background: oklch(0.95 0.01 70); /* Warm cream background */
|
||||
--background-50: oklch(0.95 0.01 70 / 0.5);
|
||||
--background-80: oklch(0.95 0.01 70 / 0.8);
|
||||
|
||||
--foreground: oklch(0.25 0.02 60); /* Dark warm brown */
|
||||
--foreground-secondary: oklch(0.45 0.02 60); /* Medium brown */
|
||||
--foreground-muted: oklch(0.55 0.02 60); /* Light brown */
|
||||
|
||||
--card: oklch(0.98 0.005 70); /* Slightly lighter cream */
|
||||
--card-foreground: oklch(0.25 0.02 60);
|
||||
--popover: oklch(0.97 0.008 70);
|
||||
--popover-foreground: oklch(0.25 0.02 60);
|
||||
|
||||
--primary: oklch(0.5 0.12 45); /* Warm terracotta/rust */
|
||||
--primary-foreground: oklch(0.98 0.005 70);
|
||||
|
||||
--brand-400: oklch(0.55 0.12 45);
|
||||
--brand-500: oklch(0.5 0.12 45); /* Terracotta */
|
||||
--brand-600: oklch(0.45 0.13 45);
|
||||
|
||||
--secondary: oklch(0.88 0.02 70);
|
||||
--secondary-foreground: oklch(0.25 0.02 60);
|
||||
|
||||
--muted: oklch(0.9 0.015 70);
|
||||
--muted-foreground: oklch(0.45 0.02 60);
|
||||
|
||||
--accent: oklch(0.85 0.025 70);
|
||||
--accent-foreground: oklch(0.25 0.02 60);
|
||||
|
||||
--destructive: oklch(0.55 0.22 25); /* Warm red */
|
||||
|
||||
--border: oklch(0.85 0.015 70);
|
||||
--border-glass: oklch(0.5 0.12 45 / 0.2);
|
||||
|
||||
--input: oklch(0.98 0.005 70);
|
||||
--ring: oklch(0.5 0.12 45);
|
||||
|
||||
--chart-1: oklch(0.5 0.12 45); /* Terracotta */
|
||||
--chart-2: oklch(0.55 0.15 35); /* Burnt orange */
|
||||
--chart-3: oklch(0.6 0.12 100); /* Olive */
|
||||
--chart-4: oklch(0.5 0.15 20); /* Deep rust */
|
||||
--chart-5: oklch(0.65 0.1 80); /* Golden */
|
||||
|
||||
--sidebar: oklch(0.93 0.012 70);
|
||||
--sidebar-foreground: oklch(0.25 0.02 60);
|
||||
--sidebar-primary: oklch(0.5 0.12 45);
|
||||
--sidebar-primary-foreground: oklch(0.98 0.005 70);
|
||||
--sidebar-accent: oklch(0.88 0.02 70);
|
||||
--sidebar-accent-foreground: oklch(0.25 0.02 60);
|
||||
--sidebar-border: oklch(0.85 0.015 70);
|
||||
--sidebar-ring: oklch(0.5 0.12 45);
|
||||
|
||||
/* Action button colors - Warm earth tones */
|
||||
--action-view: oklch(0.5 0.12 45); /* Terracotta */
|
||||
--action-view-hover: oklch(0.45 0.13 45);
|
||||
--action-followup: oklch(0.55 0.15 35); /* Burnt orange */
|
||||
--action-followup-hover: oklch(0.5 0.16 35);
|
||||
--action-commit: oklch(0.55 0.12 130); /* Sage green */
|
||||
--action-commit-hover: oklch(0.5 0.13 130);
|
||||
--action-verify: oklch(0.55 0.12 130); /* Sage green */
|
||||
--action-verify-hover: oklch(0.5 0.13 130);
|
||||
|
||||
/* Running indicator - Terracotta */
|
||||
--running-indicator: oklch(0.5 0.12 45);
|
||||
--running-indicator-text: oklch(0.55 0.12 45);
|
||||
|
||||
/* Status colors - Cream theme */
|
||||
--status-success: oklch(0.55 0.15 130);
|
||||
--status-success-bg: oklch(0.55 0.15 130 / 0.15);
|
||||
--status-warning: oklch(0.6 0.15 70);
|
||||
--status-warning-bg: oklch(0.6 0.15 70 / 0.15);
|
||||
--status-error: oklch(0.55 0.22 25);
|
||||
--status-error-bg: oklch(0.55 0.22 25 / 0.15);
|
||||
--status-info: oklch(0.5 0.15 230);
|
||||
--status-info-bg: oklch(0.5 0.15 230 / 0.15);
|
||||
--status-backlog: oklch(0.6 0.02 60);
|
||||
--status-in-progress: oklch(0.6 0.15 70);
|
||||
--status-waiting: oklch(0.58 0.13 50);
|
||||
}
|
||||
|
||||
|
||||
/* Theme-specific overrides */
|
||||
|
||||
/* Cream theme scrollbar */
|
||||
.cream ::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.cream ::-webkit-scrollbar-thumb,
|
||||
.cream .scrollbar-visible::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.7 0.03 60);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.cream ::-webkit-scrollbar-thumb:hover,
|
||||
.cream .scrollbar-visible::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.6 0.04 60);
|
||||
}
|
||||
|
||||
.cream ::-webkit-scrollbar-track,
|
||||
.cream .scrollbar-visible::-webkit-scrollbar-track {
|
||||
background: oklch(0.9 0.015 70);
|
||||
}
|
||||
|
||||
.cream .scrollbar-styled::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.7 0.03 60);
|
||||
}
|
||||
|
||||
.cream .scrollbar-styled::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.6 0.04 60);
|
||||
}
|
||||
166
apps/ui/src/styles/themes/dark.css
Normal file
166
apps/ui/src/styles/themes/dark.css
Normal file
@@ -0,0 +1,166 @@
|
||||
/* Dark Theme */
|
||||
|
||||
.dark {
|
||||
/* Deep dark backgrounds - zinc-950 family */
|
||||
--background: oklch(0.04 0 0); /* zinc-950 */
|
||||
--background-50: oklch(0.04 0 0 / 0.5); /* zinc-950/50 */
|
||||
--background-80: oklch(0.04 0 0 / 0.8); /* zinc-950/80 */
|
||||
|
||||
/* Text colors following hierarchy */
|
||||
--foreground: oklch(1 0 0); /* text-white */
|
||||
--foreground-secondary: oklch(0.588 0 0); /* text-zinc-400 */
|
||||
--foreground-muted: oklch(0.525 0 0); /* text-zinc-500 */
|
||||
|
||||
/* Card and popover backgrounds */
|
||||
--card: oklch(0.14 0 0); /* slightly lighter than background for contrast */
|
||||
--card-foreground: oklch(1 0 0);
|
||||
--popover: oklch(0.10 0 0); /* slightly lighter than background */
|
||||
--popover-foreground: oklch(1 0 0);
|
||||
|
||||
/* Brand colors - purple/violet theme */
|
||||
--primary: oklch(0.55 0.25 265); /* brand-500 */
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--brand-400: oklch(0.6 0.22 265);
|
||||
--brand-500: oklch(0.55 0.25 265);
|
||||
--brand-600: oklch(0.5 0.28 270); /* purple-600 for gradients */
|
||||
|
||||
/* Glass morphism borders and accents */
|
||||
--secondary: oklch(1 0 0 / 0.05); /* bg-white/5 */
|
||||
--secondary-foreground: oklch(1 0 0);
|
||||
--muted: oklch(0.176 0 0); /* zinc-800 */
|
||||
--muted-foreground: oklch(0.588 0 0); /* text-zinc-400 */
|
||||
--accent: oklch(1 0 0 / 0.1); /* bg-white/10 for hover */
|
||||
--accent-foreground: oklch(1 0 0);
|
||||
|
||||
/* Borders with transparency for glass effect */
|
||||
--border: oklch(0.176 0 0); /* zinc-800 */
|
||||
--border-glass: oklch(1 0 0 / 0.1); /* white/10 for glass morphism */
|
||||
--destructive: oklch(0.6 0.25 25);
|
||||
--input: oklch(0.04 0 0 / 0.8); /* Semi-transparent dark */
|
||||
--ring: oklch(0.55 0.25 265);
|
||||
|
||||
/* Chart colors with brand theme */
|
||||
--chart-1: oklch(0.55 0.25 265);
|
||||
--chart-2: oklch(0.65 0.2 160);
|
||||
--chart-3: oklch(0.75 0.2 70);
|
||||
--chart-4: oklch(0.6 0.25 300);
|
||||
--chart-5: oklch(0.6 0.25 20);
|
||||
|
||||
/* Sidebar with glass morphism */
|
||||
--sidebar: oklch(0.04 0 0 / 0.5); /* zinc-950/50 with backdrop blur */
|
||||
--sidebar-foreground: oklch(1 0 0);
|
||||
--sidebar-primary: oklch(0.55 0.25 265);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: oklch(1 0 0 / 0.05); /* bg-white/5 */
|
||||
--sidebar-accent-foreground: oklch(1 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 0.1); /* white/10 for glass borders */
|
||||
--sidebar-ring: oklch(0.55 0.25 265);
|
||||
|
||||
/* Action button colors */
|
||||
--action-view: oklch(0.6 0.25 265); /* Purple */
|
||||
--action-view-hover: oklch(0.55 0.27 270);
|
||||
--action-followup: oklch(0.6 0.2 230); /* Blue */
|
||||
--action-followup-hover: oklch(0.55 0.22 230);
|
||||
--action-commit: oklch(0.55 0.2 140); /* Green */
|
||||
--action-commit-hover: oklch(0.5 0.22 140);
|
||||
--action-verify: oklch(0.55 0.2 140); /* Green */
|
||||
--action-verify-hover: oklch(0.5 0.22 140);
|
||||
|
||||
/* Running indicator - Purple */
|
||||
--running-indicator: oklch(0.6 0.25 265);
|
||||
--running-indicator-text: oklch(0.65 0.22 265);
|
||||
|
||||
/* Status colors - Dark mode */
|
||||
--status-success: oklch(0.65 0.2 140);
|
||||
--status-success-bg: oklch(0.65 0.2 140 / 0.2);
|
||||
--status-warning: oklch(0.75 0.15 70);
|
||||
--status-warning-bg: oklch(0.75 0.15 70 / 0.2);
|
||||
--status-error: oklch(0.65 0.22 25);
|
||||
--status-error-bg: oklch(0.65 0.22 25 / 0.2);
|
||||
--status-info: oklch(0.65 0.2 230);
|
||||
--status-info-bg: oklch(0.65 0.2 230 / 0.2);
|
||||
--status-backlog: oklch(0.6 0 0);
|
||||
--status-in-progress: oklch(0.75 0.15 70);
|
||||
--status-waiting: oklch(0.7 0.18 50);
|
||||
|
||||
/* Shadow tokens - darker for dark mode */
|
||||
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -2px rgba(0, 0, 0, 0.2);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Theme-specific overrides */
|
||||
|
||||
.dark .content-bg {
|
||||
background: linear-gradient(135deg, oklch(0.04 0 0), oklch(0.08 0 0), oklch(0.04 0 0));
|
||||
}
|
||||
|
||||
.dark .animated-outline-gradient {
|
||||
background: conic-gradient(from 90deg at 50% 50%, #a855f7 0%, #3b82f6 50%, #a855f7 100%);
|
||||
}
|
||||
|
||||
.dark .animated-outline-inner {
|
||||
background: oklch(0.15 0 0) !important;
|
||||
color: #c084fc !important;
|
||||
}
|
||||
|
||||
.dark [data-slot="button"][class*="animated-outline"]:hover .animated-outline-inner {
|
||||
background: oklch(0.2 0.02 270) !important;
|
||||
color: #e9d5ff !important;
|
||||
}
|
||||
|
||||
.dark .slider-track {
|
||||
background: oklch(0.2 0 0);
|
||||
}
|
||||
|
||||
.dark .slider-range {
|
||||
background: linear-gradient(to right, #a855f7, #3b82f6);
|
||||
}
|
||||
|
||||
.dark .slider-thumb {
|
||||
background: oklch(0.25 0 0);
|
||||
border-color: oklch(0.4 0 0);
|
||||
}
|
||||
|
||||
.dark .xml-highlight {
|
||||
color: oklch(0.9 0 0); /* Default light text */
|
||||
}
|
||||
|
||||
.dark .xml-tag-bracket {
|
||||
color: oklch(0.7 0.12 220); /* Soft blue for < > */
|
||||
}
|
||||
|
||||
.dark .xml-tag-name {
|
||||
color: oklch(0.75 0.2 25); /* Coral/salmon for tag names */
|
||||
}
|
||||
|
||||
.dark .xml-attribute-name {
|
||||
color: oklch(0.8 0.15 280); /* Light purple for attributes */
|
||||
}
|
||||
|
||||
.dark .xml-attribute-equals {
|
||||
color: oklch(0.6 0 0); /* Gray for = */
|
||||
}
|
||||
|
||||
.dark .xml-attribute-value {
|
||||
color: oklch(0.8 0.18 145); /* Bright green for strings */
|
||||
}
|
||||
|
||||
.dark .xml-comment {
|
||||
color: oklch(0.55 0.05 100); /* Muted for comments */
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.dark .xml-cdata {
|
||||
color: oklch(0.7 0.12 200); /* Teal for CDATA */
|
||||
}
|
||||
|
||||
.dark .xml-doctype {
|
||||
color: oklch(0.7 0.15 280); /* Purple for DOCTYPE */
|
||||
}
|
||||
|
||||
.dark .xml-text {
|
||||
color: oklch(0.85 0 0); /* Off-white for text */
|
||||
}
|
||||
144
apps/ui/src/styles/themes/dracula.css
Normal file
144
apps/ui/src/styles/themes/dracula.css
Normal file
@@ -0,0 +1,144 @@
|
||||
/* Dracula Theme */
|
||||
|
||||
.dracula {
|
||||
--background: oklch(0.18 0.02 280); /* #282a36 */
|
||||
--background-50: oklch(0.18 0.02 280 / 0.5);
|
||||
--background-80: oklch(0.18 0.02 280 / 0.8);
|
||||
|
||||
--foreground: oklch(0.95 0.01 280); /* #f8f8f2 */
|
||||
--foreground-secondary: oklch(0.7 0.05 280);
|
||||
--foreground-muted: oklch(0.55 0.08 280); /* #6272a4 */
|
||||
|
||||
--card: oklch(0.22 0.02 280); /* #44475a */
|
||||
--card-foreground: oklch(0.95 0.01 280);
|
||||
--popover: oklch(0.2 0.02 280);
|
||||
--popover-foreground: oklch(0.95 0.01 280);
|
||||
|
||||
--primary: oklch(0.7 0.2 320); /* #bd93f9 purple */
|
||||
--primary-foreground: oklch(0.18 0.02 280);
|
||||
|
||||
--brand-400: oklch(0.75 0.2 320);
|
||||
--brand-500: oklch(0.7 0.2 320); /* #bd93f9 */
|
||||
--brand-600: oklch(0.65 0.22 320);
|
||||
|
||||
--secondary: oklch(0.28 0.03 280); /* #44475a */
|
||||
--secondary-foreground: oklch(0.95 0.01 280);
|
||||
|
||||
--muted: oklch(0.28 0.03 280);
|
||||
--muted-foreground: oklch(0.55 0.08 280); /* #6272a4 */
|
||||
|
||||
--accent: oklch(0.32 0.04 280);
|
||||
--accent-foreground: oklch(0.95 0.01 280);
|
||||
|
||||
--destructive: oklch(0.65 0.25 15); /* #ff5555 */
|
||||
|
||||
--border: oklch(0.35 0.05 280);
|
||||
--border-glass: oklch(0.7 0.2 320 / 0.3);
|
||||
|
||||
--input: oklch(0.22 0.02 280);
|
||||
--ring: oklch(0.7 0.2 320);
|
||||
|
||||
--chart-1: oklch(0.7 0.2 320); /* Purple */
|
||||
--chart-2: oklch(0.75 0.2 180); /* Cyan #8be9fd */
|
||||
--chart-3: oklch(0.8 0.2 130); /* Green #50fa7b */
|
||||
--chart-4: oklch(0.7 0.25 350); /* Pink #ff79c6 */
|
||||
--chart-5: oklch(0.85 0.2 90); /* Yellow #f1fa8c */
|
||||
|
||||
--sidebar: oklch(0.16 0.02 280);
|
||||
--sidebar-foreground: oklch(0.95 0.01 280);
|
||||
--sidebar-primary: oklch(0.7 0.2 320);
|
||||
--sidebar-primary-foreground: oklch(0.18 0.02 280);
|
||||
--sidebar-accent: oklch(0.28 0.03 280);
|
||||
--sidebar-accent-foreground: oklch(0.95 0.01 280);
|
||||
--sidebar-border: oklch(0.35 0.05 280);
|
||||
--sidebar-ring: oklch(0.7 0.2 320);
|
||||
|
||||
/* Action button colors - Dracula purple/pink theme */
|
||||
--action-view: oklch(0.7 0.2 320); /* Purple */
|
||||
--action-view-hover: oklch(0.65 0.22 320);
|
||||
--action-followup: oklch(0.65 0.25 350); /* Pink */
|
||||
--action-followup-hover: oklch(0.6 0.27 350);
|
||||
--action-commit: oklch(0.75 0.2 130); /* Green */
|
||||
--action-commit-hover: oklch(0.7 0.22 130);
|
||||
--action-verify: oklch(0.75 0.2 130); /* Green */
|
||||
--action-verify-hover: oklch(0.7 0.22 130);
|
||||
|
||||
/* Running indicator - Purple */
|
||||
--running-indicator: oklch(0.7 0.2 320);
|
||||
--running-indicator-text: oklch(0.75 0.18 320);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
NORD THEME
|
||||
Inspired by the Arctic, north-bluish color palette
|
||||
======================================== */
|
||||
|
||||
/* Theme-specific overrides */
|
||||
|
||||
.dracula .animated-outline-gradient {
|
||||
background: conic-gradient(from 90deg at 50% 50%, #bd93f9 0%, #ff79c6 50%, #bd93f9 100%);
|
||||
}
|
||||
|
||||
.dracula .animated-outline-inner {
|
||||
background: oklch(0.18 0.02 280) !important;
|
||||
color: #bd93f9 !important;
|
||||
}
|
||||
|
||||
.dracula [data-slot="button"][class*="animated-outline"]:hover .animated-outline-inner {
|
||||
background: oklch(0.24 0.03 280) !important;
|
||||
color: #ff79c6 !important;
|
||||
}
|
||||
|
||||
.dracula .slider-track {
|
||||
background: oklch(0.28 0.03 280);
|
||||
}
|
||||
|
||||
.dracula .slider-range {
|
||||
background: linear-gradient(to right, #bd93f9, #ff79c6);
|
||||
}
|
||||
|
||||
.dracula .slider-thumb {
|
||||
background: oklch(0.22 0.02 280);
|
||||
border-color: #bd93f9;
|
||||
}
|
||||
|
||||
.dracula .xml-highlight {
|
||||
color: oklch(0.95 0.01 280); /* #f8f8f2 */
|
||||
}
|
||||
|
||||
.dracula .xml-tag-bracket {
|
||||
color: oklch(0.7 0.25 350); /* Pink #ff79c6 */
|
||||
}
|
||||
|
||||
.dracula .xml-tag-name {
|
||||
color: oklch(0.7 0.25 350); /* Pink for tags */
|
||||
}
|
||||
|
||||
.dracula .xml-attribute-name {
|
||||
color: oklch(0.8 0.2 130); /* Green #50fa7b */
|
||||
}
|
||||
|
||||
.dracula .xml-attribute-equals {
|
||||
color: oklch(0.95 0.01 280); /* White */
|
||||
}
|
||||
|
||||
.dracula .xml-attribute-value {
|
||||
color: oklch(0.85 0.2 90); /* Yellow #f1fa8c */
|
||||
}
|
||||
|
||||
.dracula .xml-comment {
|
||||
color: oklch(0.55 0.08 280); /* #6272a4 */
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.dracula .xml-cdata {
|
||||
color: oklch(0.75 0.2 180); /* Cyan */
|
||||
}
|
||||
|
||||
.dracula .xml-doctype {
|
||||
color: oklch(0.7 0.2 320); /* Purple #bd93f9 */
|
||||
}
|
||||
|
||||
.dracula .xml-text {
|
||||
color: oklch(0.95 0.01 280); /* White */
|
||||
}
|
||||
110
apps/ui/src/styles/themes/gray.css
Normal file
110
apps/ui/src/styles/themes/gray.css
Normal file
@@ -0,0 +1,110 @@
|
||||
/* Gray Theme */
|
||||
|
||||
.gray {
|
||||
/* Gray Theme - Modern, minimal gray scheme inspired by Cursor */
|
||||
--background: oklch(0.2 0.005 250); /* Medium-dark neutral gray */
|
||||
--background-50: oklch(0.2 0.005 250 / 0.5);
|
||||
--background-80: oklch(0.2 0.005 250 / 0.8);
|
||||
|
||||
--foreground: oklch(0.9 0.005 250); /* Light gray */
|
||||
--foreground-secondary: oklch(0.65 0.005 250);
|
||||
--foreground-muted: oklch(0.5 0.005 250);
|
||||
|
||||
--card: oklch(0.24 0.005 250);
|
||||
--card-foreground: oklch(0.9 0.005 250);
|
||||
--popover: oklch(0.22 0.005 250);
|
||||
--popover-foreground: oklch(0.9 0.005 250);
|
||||
|
||||
--primary: oklch(0.6 0.08 250); /* Subtle blue-gray */
|
||||
--primary-foreground: oklch(0.95 0.005 250);
|
||||
|
||||
--brand-400: oklch(0.65 0.08 250);
|
||||
--brand-500: oklch(0.6 0.08 250); /* Blue-gray */
|
||||
--brand-600: oklch(0.55 0.09 250);
|
||||
|
||||
--secondary: oklch(0.28 0.005 250);
|
||||
--secondary-foreground: oklch(0.9 0.005 250);
|
||||
|
||||
--muted: oklch(0.3 0.005 250);
|
||||
--muted-foreground: oklch(0.6 0.005 250);
|
||||
|
||||
--accent: oklch(0.35 0.01 250);
|
||||
--accent-foreground: oklch(0.9 0.005 250);
|
||||
|
||||
--destructive: oklch(0.6 0.2 25); /* Muted red */
|
||||
|
||||
--border: oklch(0.32 0.005 250);
|
||||
--border-glass: oklch(0.6 0.08 250 / 0.2);
|
||||
|
||||
--input: oklch(0.24 0.005 250);
|
||||
--ring: oklch(0.6 0.08 250);
|
||||
|
||||
--chart-1: oklch(0.6 0.08 250); /* Blue-gray */
|
||||
--chart-2: oklch(0.65 0.1 210); /* Cyan */
|
||||
--chart-3: oklch(0.7 0.12 160); /* Teal */
|
||||
--chart-4: oklch(0.65 0.1 280); /* Purple */
|
||||
--chart-5: oklch(0.7 0.08 300); /* Violet */
|
||||
|
||||
--sidebar: oklch(0.18 0.005 250);
|
||||
--sidebar-foreground: oklch(0.9 0.005 250);
|
||||
--sidebar-primary: oklch(0.6 0.08 250);
|
||||
--sidebar-primary-foreground: oklch(0.95 0.005 250);
|
||||
--sidebar-accent: oklch(0.28 0.005 250);
|
||||
--sidebar-accent-foreground: oklch(0.9 0.005 250);
|
||||
--sidebar-border: oklch(0.32 0.005 250);
|
||||
--sidebar-ring: oklch(0.6 0.08 250);
|
||||
|
||||
/* Action button colors - Subtle modern colors */
|
||||
--action-view: oklch(0.6 0.08 250); /* Blue-gray */
|
||||
--action-view-hover: oklch(0.55 0.09 250);
|
||||
--action-followup: oklch(0.65 0.1 210); /* Cyan */
|
||||
--action-followup-hover: oklch(0.6 0.11 210);
|
||||
--action-commit: oklch(0.65 0.12 150); /* Teal-green */
|
||||
--action-commit-hover: oklch(0.6 0.13 150);
|
||||
--action-verify: oklch(0.65 0.12 150); /* Teal-green */
|
||||
--action-verify-hover: oklch(0.6 0.13 150);
|
||||
|
||||
/* Running indicator - Blue-gray */
|
||||
--running-indicator: oklch(0.6 0.08 250);
|
||||
--running-indicator-text: oklch(0.65 0.08 250);
|
||||
|
||||
/* Status colors - Gray theme */
|
||||
--status-success: oklch(0.65 0.12 150);
|
||||
--status-success-bg: oklch(0.65 0.12 150 / 0.2);
|
||||
--status-warning: oklch(0.7 0.15 70);
|
||||
--status-warning-bg: oklch(0.7 0.15 70 / 0.2);
|
||||
--status-error: oklch(0.6 0.2 25);
|
||||
--status-error-bg: oklch(0.6 0.2 25 / 0.2);
|
||||
--status-info: oklch(0.65 0.1 210);
|
||||
--status-info-bg: oklch(0.65 0.1 210 / 0.2);
|
||||
--status-backlog: oklch(0.6 0.005 250);
|
||||
--status-in-progress: oklch(0.7 0.15 70);
|
||||
--status-waiting: oklch(0.68 0.1 220);
|
||||
}
|
||||
|
||||
/* Theme-specific overrides */
|
||||
|
||||
/* Gray theme scrollbar */
|
||||
.gray ::-webkit-scrollbar-thumb,
|
||||
.gray .scrollbar-visible::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.4 0.01 250);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.gray ::-webkit-scrollbar-thumb:hover,
|
||||
.gray .scrollbar-visible::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.5 0.02 250);
|
||||
}
|
||||
|
||||
.gray ::-webkit-scrollbar-track,
|
||||
.gray .scrollbar-visible::-webkit-scrollbar-track {
|
||||
background: oklch(0.25 0.005 250);
|
||||
}
|
||||
|
||||
.gray .scrollbar-styled::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.4 0.01 250);
|
||||
}
|
||||
|
||||
.gray .scrollbar-styled::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.5 0.02 250);
|
||||
}
|
||||
144
apps/ui/src/styles/themes/gruvbox.css
Normal file
144
apps/ui/src/styles/themes/gruvbox.css
Normal file
@@ -0,0 +1,144 @@
|
||||
/* Gruvbox Theme */
|
||||
|
||||
.gruvbox {
|
||||
--background: oklch(0.18 0.02 60); /* #282828 bg */
|
||||
--background-50: oklch(0.18 0.02 60 / 0.5);
|
||||
--background-80: oklch(0.18 0.02 60 / 0.8);
|
||||
|
||||
--foreground: oklch(0.85 0.05 85); /* #ebdbb2 fg */
|
||||
--foreground-secondary: oklch(0.7 0.04 85); /* #d5c4a1 */
|
||||
--foreground-muted: oklch(0.55 0.04 85); /* #928374 */
|
||||
|
||||
--card: oklch(0.22 0.02 60); /* #3c3836 bg1 */
|
||||
--card-foreground: oklch(0.85 0.05 85);
|
||||
--popover: oklch(0.2 0.02 60);
|
||||
--popover-foreground: oklch(0.85 0.05 85);
|
||||
|
||||
--primary: oklch(0.7 0.18 55); /* #fabd2f yellow */
|
||||
--primary-foreground: oklch(0.18 0.02 60);
|
||||
|
||||
--brand-400: oklch(0.75 0.18 55);
|
||||
--brand-500: oklch(0.7 0.18 55); /* Yellow */
|
||||
--brand-600: oklch(0.65 0.2 55);
|
||||
|
||||
--secondary: oklch(0.26 0.02 60); /* #504945 bg2 */
|
||||
--secondary-foreground: oklch(0.85 0.05 85);
|
||||
|
||||
--muted: oklch(0.26 0.02 60);
|
||||
--muted-foreground: oklch(0.55 0.04 85);
|
||||
|
||||
--accent: oklch(0.3 0.03 60);
|
||||
--accent-foreground: oklch(0.85 0.05 85);
|
||||
|
||||
--destructive: oklch(0.55 0.22 25); /* #fb4934 red */
|
||||
|
||||
--border: oklch(0.35 0.03 60);
|
||||
--border-glass: oklch(0.7 0.18 55 / 0.3);
|
||||
|
||||
--input: oklch(0.22 0.02 60);
|
||||
--ring: oklch(0.7 0.18 55);
|
||||
|
||||
--chart-1: oklch(0.7 0.18 55); /* Yellow */
|
||||
--chart-2: oklch(0.65 0.2 140); /* Green #b8bb26 */
|
||||
--chart-3: oklch(0.7 0.15 200); /* Aqua #8ec07c */
|
||||
--chart-4: oklch(0.6 0.2 30); /* Orange #fe8019 */
|
||||
--chart-5: oklch(0.6 0.2 320); /* Purple #d3869b */
|
||||
|
||||
--sidebar: oklch(0.16 0.02 60);
|
||||
--sidebar-foreground: oklch(0.85 0.05 85);
|
||||
--sidebar-primary: oklch(0.7 0.18 55);
|
||||
--sidebar-primary-foreground: oklch(0.18 0.02 60);
|
||||
--sidebar-accent: oklch(0.26 0.02 60);
|
||||
--sidebar-accent-foreground: oklch(0.85 0.05 85);
|
||||
--sidebar-border: oklch(0.35 0.03 60);
|
||||
--sidebar-ring: oklch(0.7 0.18 55);
|
||||
|
||||
/* Action button colors - Gruvbox yellow/orange theme */
|
||||
--action-view: oklch(0.7 0.18 55); /* Yellow */
|
||||
--action-view-hover: oklch(0.65 0.2 55);
|
||||
--action-followup: oklch(0.7 0.15 200); /* Aqua */
|
||||
--action-followup-hover: oklch(0.65 0.17 200);
|
||||
--action-commit: oklch(0.65 0.2 140); /* Green */
|
||||
--action-commit-hover: oklch(0.6 0.22 140);
|
||||
--action-verify: oklch(0.65 0.2 140); /* Green */
|
||||
--action-verify-hover: oklch(0.6 0.22 140);
|
||||
|
||||
/* Running indicator - Yellow */
|
||||
--running-indicator: oklch(0.7 0.18 55);
|
||||
--running-indicator-text: oklch(0.75 0.16 55);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
CATPPUCCIN MOCHA THEME
|
||||
Soothing pastel theme for the high-spirited
|
||||
======================================== */
|
||||
|
||||
/* Theme-specific overrides */
|
||||
|
||||
.gruvbox .animated-outline-gradient {
|
||||
background: conic-gradient(from 90deg at 50% 50%, #fabd2f 0%, #fe8019 50%, #fabd2f 100%);
|
||||
}
|
||||
|
||||
.gruvbox .animated-outline-inner {
|
||||
background: oklch(0.18 0.02 60) !important;
|
||||
color: #fabd2f !important;
|
||||
}
|
||||
|
||||
.gruvbox [data-slot="button"][class*="animated-outline"]:hover .animated-outline-inner {
|
||||
background: oklch(0.24 0.03 60) !important;
|
||||
color: #fe8019 !important;
|
||||
}
|
||||
|
||||
.gruvbox .slider-track {
|
||||
background: oklch(0.26 0.02 60);
|
||||
}
|
||||
|
||||
.gruvbox .slider-range {
|
||||
background: linear-gradient(to right, #fabd2f, #fe8019);
|
||||
}
|
||||
|
||||
.gruvbox .slider-thumb {
|
||||
background: oklch(0.22 0.02 60);
|
||||
border-color: #fabd2f;
|
||||
}
|
||||
|
||||
.gruvbox .xml-highlight {
|
||||
color: oklch(0.85 0.05 85); /* #ebdbb2 */
|
||||
}
|
||||
|
||||
.gruvbox .xml-tag-bracket {
|
||||
color: oklch(0.55 0.22 25); /* #fb4934 red */
|
||||
}
|
||||
|
||||
.gruvbox .xml-tag-name {
|
||||
color: oklch(0.55 0.22 25); /* Red for tags */
|
||||
}
|
||||
|
||||
.gruvbox .xml-attribute-name {
|
||||
color: oklch(0.7 0.15 200); /* #8ec07c aqua */
|
||||
}
|
||||
|
||||
.gruvbox .xml-attribute-equals {
|
||||
color: oklch(0.7 0.04 85); /* Dim text */
|
||||
}
|
||||
|
||||
.gruvbox .xml-attribute-value {
|
||||
color: oklch(0.65 0.2 140); /* #b8bb26 green */
|
||||
}
|
||||
|
||||
.gruvbox .xml-comment {
|
||||
color: oklch(0.55 0.04 85); /* #928374 gray */
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.gruvbox .xml-cdata {
|
||||
color: oklch(0.7 0.15 200); /* Aqua */
|
||||
}
|
||||
|
||||
.gruvbox .xml-doctype {
|
||||
color: oklch(0.6 0.2 320); /* #d3869b purple */
|
||||
}
|
||||
|
||||
.gruvbox .xml-text {
|
||||
color: oklch(0.85 0.05 85); /* Foreground */
|
||||
}
|
||||
103
apps/ui/src/styles/themes/light.css
Normal file
103
apps/ui/src/styles/themes/light.css
Normal file
@@ -0,0 +1,103 @@
|
||||
/* Light Theme Overrides */
|
||||
|
||||
.light .scrollbar-visible::-webkit-scrollbar-track {
|
||||
background: oklch(0.95 0 0);
|
||||
}
|
||||
|
||||
.light .scrollbar-visible::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.7 0 0);
|
||||
}
|
||||
|
||||
.light .scrollbar-visible::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.6 0 0);
|
||||
}
|
||||
|
||||
.light .scrollbar-styled::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.75 0 0);
|
||||
}
|
||||
|
||||
.light .scrollbar-styled::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.65 0 0);
|
||||
}
|
||||
|
||||
.light .bg-glass {
|
||||
background: oklch(1 0 0 / 0.8);
|
||||
}
|
||||
|
||||
.light .bg-glass-80 {
|
||||
background: oklch(1 0 0 / 0.95);
|
||||
}
|
||||
|
||||
.light .content-bg {
|
||||
background: linear-gradient(135deg, oklch(0.99 0 0), oklch(0.98 0 0), oklch(0.99 0 0));
|
||||
}
|
||||
|
||||
.light .animated-outline-gradient {
|
||||
background: conic-gradient(from 90deg at 50% 50%, #7c3aed 0%, #2563eb 50%, #7c3aed 100%);
|
||||
}
|
||||
|
||||
.light .animated-outline-inner {
|
||||
background: oklch(100% 0 0) !important;
|
||||
color: #7c3aed !important;
|
||||
border: 1px solid oklch(92% 0 0);
|
||||
}
|
||||
|
||||
.light [data-slot="button"][class*="animated-outline"]:hover .animated-outline-inner {
|
||||
background: oklch(97% 0.02 270) !important;
|
||||
color: #5b21b6 !important;
|
||||
}
|
||||
|
||||
.light .slider-track {
|
||||
background: oklch(90% 0 0);
|
||||
}
|
||||
|
||||
.light .slider-range {
|
||||
background: linear-gradient(to right, #7c3aed, #2563eb);
|
||||
}
|
||||
|
||||
.light .slider-thumb {
|
||||
background: oklch(100% 0 0);
|
||||
border-color: oklch(80% 0 0);
|
||||
}
|
||||
|
||||
.light .xml-highlight {
|
||||
color: oklch(0.3 0 0); /* Default text */
|
||||
}
|
||||
|
||||
.light .xml-tag-bracket {
|
||||
color: oklch(0.45 0.15 250); /* Blue-gray for < > */
|
||||
}
|
||||
|
||||
.light .xml-tag-name {
|
||||
color: oklch(0.45 0.22 25); /* Red/maroon for tag names */
|
||||
}
|
||||
|
||||
.light .xml-attribute-name {
|
||||
color: oklch(0.45 0.18 280); /* Purple for attributes */
|
||||
}
|
||||
|
||||
.light .xml-attribute-equals {
|
||||
color: oklch(0.4 0 0); /* Dark gray for = */
|
||||
}
|
||||
|
||||
.light .xml-attribute-value {
|
||||
color: oklch(0.45 0.18 145); /* Green for string values */
|
||||
}
|
||||
|
||||
.light .xml-comment {
|
||||
color: oklch(0.55 0.05 100); /* Muted olive for comments */
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.light .xml-cdata {
|
||||
color: oklch(0.5 0.1 200); /* Teal for CDATA */
|
||||
}
|
||||
|
||||
.light .xml-doctype {
|
||||
color: oklch(0.5 0.15 280); /* Purple for DOCTYPE */
|
||||
}
|
||||
|
||||
.light .xml-text {
|
||||
color: oklch(0.25 0 0); /* Near-black for text content */
|
||||
}
|
||||
|
||||
144
apps/ui/src/styles/themes/monokai.css
Normal file
144
apps/ui/src/styles/themes/monokai.css
Normal file
@@ -0,0 +1,144 @@
|
||||
/* Monokai Theme */
|
||||
|
||||
.monokai {
|
||||
--background: oklch(0.17 0.01 90); /* #272822 */
|
||||
--background-50: oklch(0.17 0.01 90 / 0.5);
|
||||
--background-80: oklch(0.17 0.01 90 / 0.8);
|
||||
|
||||
--foreground: oklch(0.95 0.02 100); /* #f8f8f2 */
|
||||
--foreground-secondary: oklch(0.8 0.02 100);
|
||||
--foreground-muted: oklch(0.55 0.04 100); /* #75715e */
|
||||
|
||||
--card: oklch(0.22 0.01 90); /* #3e3d32 */
|
||||
--card-foreground: oklch(0.95 0.02 100);
|
||||
--popover: oklch(0.2 0.01 90);
|
||||
--popover-foreground: oklch(0.95 0.02 100);
|
||||
|
||||
--primary: oklch(0.8 0.2 350); /* #f92672 pink */
|
||||
--primary-foreground: oklch(0.17 0.01 90);
|
||||
|
||||
--brand-400: oklch(0.85 0.2 350);
|
||||
--brand-500: oklch(0.8 0.2 350); /* #f92672 */
|
||||
--brand-600: oklch(0.75 0.22 350);
|
||||
|
||||
--secondary: oklch(0.25 0.02 90);
|
||||
--secondary-foreground: oklch(0.95 0.02 100);
|
||||
|
||||
--muted: oklch(0.25 0.02 90);
|
||||
--muted-foreground: oklch(0.55 0.04 100);
|
||||
|
||||
--accent: oklch(0.3 0.02 90);
|
||||
--accent-foreground: oklch(0.95 0.02 100);
|
||||
|
||||
--destructive: oklch(0.65 0.25 15); /* red */
|
||||
|
||||
--border: oklch(0.35 0.03 90);
|
||||
--border-glass: oklch(0.8 0.2 350 / 0.3);
|
||||
|
||||
--input: oklch(0.22 0.01 90);
|
||||
--ring: oklch(0.8 0.2 350);
|
||||
|
||||
--chart-1: oklch(0.8 0.2 350); /* Pink #f92672 */
|
||||
--chart-2: oklch(0.85 0.2 90); /* Yellow #e6db74 */
|
||||
--chart-3: oklch(0.8 0.2 140); /* Green #a6e22e */
|
||||
--chart-4: oklch(0.75 0.2 200); /* Cyan #66d9ef */
|
||||
--chart-5: oklch(0.75 0.2 30); /* Orange #fd971f */
|
||||
|
||||
--sidebar: oklch(0.15 0.01 90);
|
||||
--sidebar-foreground: oklch(0.95 0.02 100);
|
||||
--sidebar-primary: oklch(0.8 0.2 350);
|
||||
--sidebar-primary-foreground: oklch(0.17 0.01 90);
|
||||
--sidebar-accent: oklch(0.25 0.02 90);
|
||||
--sidebar-accent-foreground: oklch(0.95 0.02 100);
|
||||
--sidebar-border: oklch(0.35 0.03 90);
|
||||
--sidebar-ring: oklch(0.8 0.2 350);
|
||||
|
||||
/* Action button colors - Monokai pink/yellow theme */
|
||||
--action-view: oklch(0.8 0.2 350); /* Pink */
|
||||
--action-view-hover: oklch(0.75 0.22 350);
|
||||
--action-followup: oklch(0.75 0.2 200); /* Cyan */
|
||||
--action-followup-hover: oklch(0.7 0.22 200);
|
||||
--action-commit: oklch(0.8 0.2 140); /* Green */
|
||||
--action-commit-hover: oklch(0.75 0.22 140);
|
||||
--action-verify: oklch(0.8 0.2 140); /* Green */
|
||||
--action-verify-hover: oklch(0.75 0.22 140);
|
||||
|
||||
/* Running indicator - Pink */
|
||||
--running-indicator: oklch(0.8 0.2 350);
|
||||
--running-indicator-text: oklch(0.85 0.18 350);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
TOKYO NIGHT THEME
|
||||
A clean dark theme celebrating Tokyo at night
|
||||
======================================== */
|
||||
|
||||
/* Theme-specific overrides */
|
||||
|
||||
.monokai .animated-outline-gradient {
|
||||
background: conic-gradient(from 90deg at 50% 50%, #f92672 0%, #e6db74 50%, #f92672 100%);
|
||||
}
|
||||
|
||||
.monokai .animated-outline-inner {
|
||||
background: oklch(0.17 0.01 90) !important;
|
||||
color: #f92672 !important;
|
||||
}
|
||||
|
||||
.monokai [data-slot="button"][class*="animated-outline"]:hover .animated-outline-inner {
|
||||
background: oklch(0.22 0.02 90) !important;
|
||||
color: #e6db74 !important;
|
||||
}
|
||||
|
||||
.monokai .slider-track {
|
||||
background: oklch(0.25 0.02 90);
|
||||
}
|
||||
|
||||
.monokai .slider-range {
|
||||
background: linear-gradient(to right, #f92672, #fd971f);
|
||||
}
|
||||
|
||||
.monokai .slider-thumb {
|
||||
background: oklch(0.22 0.01 90);
|
||||
border-color: #f92672;
|
||||
}
|
||||
|
||||
.monokai .xml-highlight {
|
||||
color: oklch(0.95 0.02 100); /* #f8f8f2 */
|
||||
}
|
||||
|
||||
.monokai .xml-tag-bracket {
|
||||
color: oklch(0.95 0.02 100); /* White */
|
||||
}
|
||||
|
||||
.monokai .xml-tag-name {
|
||||
color: oklch(0.8 0.2 350); /* #f92672 pink */
|
||||
}
|
||||
|
||||
.monokai .xml-attribute-name {
|
||||
color: oklch(0.8 0.2 140); /* #a6e22e green */
|
||||
}
|
||||
|
||||
.monokai .xml-attribute-equals {
|
||||
color: oklch(0.95 0.02 100); /* White */
|
||||
}
|
||||
|
||||
.monokai .xml-attribute-value {
|
||||
color: oklch(0.85 0.2 90); /* #e6db74 yellow */
|
||||
}
|
||||
|
||||
.monokai .xml-comment {
|
||||
color: oklch(0.55 0.04 100); /* #75715e */
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.monokai .xml-cdata {
|
||||
color: oklch(0.75 0.2 200); /* Cyan #66d9ef */
|
||||
}
|
||||
|
||||
.monokai .xml-doctype {
|
||||
color: oklch(0.75 0.2 200); /* Cyan */
|
||||
}
|
||||
|
||||
.monokai .xml-text {
|
||||
color: oklch(0.95 0.02 100); /* White */
|
||||
}
|
||||
144
apps/ui/src/styles/themes/nord.css
Normal file
144
apps/ui/src/styles/themes/nord.css
Normal file
@@ -0,0 +1,144 @@
|
||||
/* Nord Theme */
|
||||
|
||||
.nord {
|
||||
--background: oklch(0.23 0.02 240); /* #2e3440 */
|
||||
--background-50: oklch(0.23 0.02 240 / 0.5);
|
||||
--background-80: oklch(0.23 0.02 240 / 0.8);
|
||||
|
||||
--foreground: oklch(0.9 0.01 230); /* #eceff4 */
|
||||
--foreground-secondary: oklch(0.75 0.02 230); /* #d8dee9 */
|
||||
--foreground-muted: oklch(0.6 0.03 230); /* #4c566a */
|
||||
|
||||
--card: oklch(0.27 0.02 240); /* #3b4252 */
|
||||
--card-foreground: oklch(0.9 0.01 230);
|
||||
--popover: oklch(0.25 0.02 240);
|
||||
--popover-foreground: oklch(0.9 0.01 230);
|
||||
|
||||
--primary: oklch(0.7 0.12 220); /* #88c0d0 frost */
|
||||
--primary-foreground: oklch(0.23 0.02 240);
|
||||
|
||||
--brand-400: oklch(0.75 0.12 220);
|
||||
--brand-500: oklch(0.7 0.12 220); /* #88c0d0 */
|
||||
--brand-600: oklch(0.65 0.14 220); /* #81a1c1 */
|
||||
|
||||
--secondary: oklch(0.31 0.02 240); /* #434c5e */
|
||||
--secondary-foreground: oklch(0.9 0.01 230);
|
||||
|
||||
--muted: oklch(0.31 0.02 240);
|
||||
--muted-foreground: oklch(0.55 0.03 230);
|
||||
|
||||
--accent: oklch(0.35 0.03 240); /* #4c566a */
|
||||
--accent-foreground: oklch(0.9 0.01 230);
|
||||
|
||||
--destructive: oklch(0.65 0.2 15); /* #bf616a */
|
||||
|
||||
--border: oklch(0.35 0.03 240);
|
||||
--border-glass: oklch(0.7 0.12 220 / 0.3);
|
||||
|
||||
--input: oklch(0.27 0.02 240);
|
||||
--ring: oklch(0.7 0.12 220);
|
||||
|
||||
--chart-1: oklch(0.7 0.12 220); /* Frost blue */
|
||||
--chart-2: oklch(0.65 0.14 220); /* #81a1c1 */
|
||||
--chart-3: oklch(0.7 0.15 140); /* #a3be8c green */
|
||||
--chart-4: oklch(0.7 0.2 320); /* #b48ead purple */
|
||||
--chart-5: oklch(0.75 0.15 70); /* #ebcb8b yellow */
|
||||
|
||||
--sidebar: oklch(0.21 0.02 240);
|
||||
--sidebar-foreground: oklch(0.9 0.01 230);
|
||||
--sidebar-primary: oklch(0.7 0.12 220);
|
||||
--sidebar-primary-foreground: oklch(0.23 0.02 240);
|
||||
--sidebar-accent: oklch(0.31 0.02 240);
|
||||
--sidebar-accent-foreground: oklch(0.9 0.01 230);
|
||||
--sidebar-border: oklch(0.35 0.03 240);
|
||||
--sidebar-ring: oklch(0.7 0.12 220);
|
||||
|
||||
/* Action button colors - Nord frost blue theme */
|
||||
--action-view: oklch(0.7 0.12 220); /* Frost blue */
|
||||
--action-view-hover: oklch(0.65 0.14 220);
|
||||
--action-followup: oklch(0.65 0.14 220); /* Darker frost */
|
||||
--action-followup-hover: oklch(0.6 0.16 220);
|
||||
--action-commit: oklch(0.7 0.15 140); /* Green */
|
||||
--action-commit-hover: oklch(0.65 0.17 140);
|
||||
--action-verify: oklch(0.7 0.15 140); /* Green */
|
||||
--action-verify-hover: oklch(0.65 0.17 140);
|
||||
|
||||
/* Running indicator - Frost blue */
|
||||
--running-indicator: oklch(0.7 0.12 220);
|
||||
--running-indicator-text: oklch(0.75 0.1 220);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
MONOKAI THEME
|
||||
The classic Monokai color scheme
|
||||
======================================== */
|
||||
|
||||
/* Theme-specific overrides */
|
||||
|
||||
.nord .animated-outline-gradient {
|
||||
background: conic-gradient(from 90deg at 50% 50%, #88c0d0 0%, #81a1c1 50%, #88c0d0 100%);
|
||||
}
|
||||
|
||||
.nord .animated-outline-inner {
|
||||
background: oklch(0.23 0.02 240) !important;
|
||||
color: #88c0d0 !important;
|
||||
}
|
||||
|
||||
.nord [data-slot="button"][class*="animated-outline"]:hover .animated-outline-inner {
|
||||
background: oklch(0.28 0.03 240) !important;
|
||||
color: #8fbcbb !important;
|
||||
}
|
||||
|
||||
.nord .slider-track {
|
||||
background: oklch(0.31 0.02 240);
|
||||
}
|
||||
|
||||
.nord .slider-range {
|
||||
background: linear-gradient(to right, #88c0d0, #81a1c1);
|
||||
}
|
||||
|
||||
.nord .slider-thumb {
|
||||
background: oklch(0.27 0.02 240);
|
||||
border-color: #88c0d0;
|
||||
}
|
||||
|
||||
.nord .xml-highlight {
|
||||
color: oklch(0.9 0.01 230); /* #eceff4 */
|
||||
}
|
||||
|
||||
.nord .xml-tag-bracket {
|
||||
color: oklch(0.65 0.14 220); /* #81a1c1 */
|
||||
}
|
||||
|
||||
.nord .xml-tag-name {
|
||||
color: oklch(0.65 0.14 220); /* Frost blue for tags */
|
||||
}
|
||||
|
||||
.nord .xml-attribute-name {
|
||||
color: oklch(0.7 0.12 220); /* #88c0d0 */
|
||||
}
|
||||
|
||||
.nord .xml-attribute-equals {
|
||||
color: oklch(0.75 0.02 230); /* Dim white */
|
||||
}
|
||||
|
||||
.nord .xml-attribute-value {
|
||||
color: oklch(0.7 0.15 140); /* #a3be8c green */
|
||||
}
|
||||
|
||||
.nord .xml-comment {
|
||||
color: oklch(0.5 0.04 230); /* Dim text */
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.nord .xml-cdata {
|
||||
color: oklch(0.7 0.12 220); /* Frost blue */
|
||||
}
|
||||
|
||||
.nord .xml-doctype {
|
||||
color: oklch(0.7 0.2 320); /* #b48ead purple */
|
||||
}
|
||||
|
||||
.nord .xml-text {
|
||||
color: oklch(0.9 0.01 230); /* Snow white */
|
||||
}
|
||||
144
apps/ui/src/styles/themes/onedark.css
Normal file
144
apps/ui/src/styles/themes/onedark.css
Normal file
@@ -0,0 +1,144 @@
|
||||
/* Onedark Theme */
|
||||
|
||||
.onedark {
|
||||
--background: oklch(0.19 0.01 250); /* #282c34 */
|
||||
--background-50: oklch(0.19 0.01 250 / 0.5);
|
||||
--background-80: oklch(0.19 0.01 250 / 0.8);
|
||||
|
||||
--foreground: oklch(0.85 0.02 240); /* #abb2bf */
|
||||
--foreground-secondary: oklch(0.7 0.02 240);
|
||||
--foreground-muted: oklch(0.5 0.03 240); /* #5c6370 */
|
||||
|
||||
--card: oklch(0.23 0.01 250); /* #21252b */
|
||||
--card-foreground: oklch(0.85 0.02 240);
|
||||
--popover: oklch(0.21 0.01 250);
|
||||
--popover-foreground: oklch(0.85 0.02 240);
|
||||
|
||||
--primary: oklch(0.7 0.18 230); /* #61afef blue */
|
||||
--primary-foreground: oklch(0.19 0.01 250);
|
||||
|
||||
--brand-400: oklch(0.75 0.18 230);
|
||||
--brand-500: oklch(0.7 0.18 230); /* Blue */
|
||||
--brand-600: oklch(0.65 0.2 230);
|
||||
|
||||
--secondary: oklch(0.25 0.01 250);
|
||||
--secondary-foreground: oklch(0.85 0.02 240);
|
||||
|
||||
--muted: oklch(0.25 0.01 250);
|
||||
--muted-foreground: oklch(0.5 0.03 240);
|
||||
|
||||
--accent: oklch(0.28 0.02 250);
|
||||
--accent-foreground: oklch(0.85 0.02 240);
|
||||
|
||||
--destructive: oklch(0.6 0.2 20); /* #e06c75 red */
|
||||
|
||||
--border: oklch(0.35 0.02 250);
|
||||
--border-glass: oklch(0.7 0.18 230 / 0.3);
|
||||
|
||||
--input: oklch(0.23 0.01 250);
|
||||
--ring: oklch(0.7 0.18 230);
|
||||
|
||||
--chart-1: oklch(0.7 0.18 230); /* Blue */
|
||||
--chart-2: oklch(0.75 0.15 320); /* Magenta #c678dd */
|
||||
--chart-3: oklch(0.75 0.18 150); /* Green #98c379 */
|
||||
--chart-4: oklch(0.8 0.15 80); /* Yellow #e5c07b */
|
||||
--chart-5: oklch(0.7 0.15 180); /* Cyan #56b6c2 */
|
||||
|
||||
--sidebar: oklch(0.17 0.01 250);
|
||||
--sidebar-foreground: oklch(0.85 0.02 240);
|
||||
--sidebar-primary: oklch(0.7 0.18 230);
|
||||
--sidebar-primary-foreground: oklch(0.19 0.01 250);
|
||||
--sidebar-accent: oklch(0.25 0.01 250);
|
||||
--sidebar-accent-foreground: oklch(0.85 0.02 240);
|
||||
--sidebar-border: oklch(0.35 0.02 250);
|
||||
--sidebar-ring: oklch(0.7 0.18 230);
|
||||
|
||||
/* Action button colors - One Dark blue/magenta theme */
|
||||
--action-view: oklch(0.7 0.18 230); /* Blue */
|
||||
--action-view-hover: oklch(0.65 0.2 230);
|
||||
--action-followup: oklch(0.75 0.15 320); /* Magenta */
|
||||
--action-followup-hover: oklch(0.7 0.17 320);
|
||||
--action-commit: oklch(0.75 0.18 150); /* Green */
|
||||
--action-commit-hover: oklch(0.7 0.2 150);
|
||||
--action-verify: oklch(0.75 0.18 150); /* Green */
|
||||
--action-verify-hover: oklch(0.7 0.2 150);
|
||||
|
||||
/* Running indicator - Blue */
|
||||
--running-indicator: oklch(0.7 0.18 230);
|
||||
--running-indicator-text: oklch(0.75 0.16 230);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
SYNTHWAVE '84 THEME
|
||||
Neon dreams of the 80s
|
||||
======================================== */
|
||||
|
||||
/* Theme-specific overrides */
|
||||
|
||||
.onedark .animated-outline-gradient {
|
||||
background: conic-gradient(from 90deg at 50% 50%, #61afef 0%, #c678dd 50%, #61afef 100%);
|
||||
}
|
||||
|
||||
.onedark .animated-outline-inner {
|
||||
background: oklch(0.19 0.01 250) !important;
|
||||
color: #61afef !important;
|
||||
}
|
||||
|
||||
.onedark [data-slot="button"][class*="animated-outline"]:hover .animated-outline-inner {
|
||||
background: oklch(0.25 0.02 250) !important;
|
||||
color: #c678dd !important;
|
||||
}
|
||||
|
||||
.onedark .slider-track {
|
||||
background: oklch(0.25 0.01 250);
|
||||
}
|
||||
|
||||
.onedark .slider-range {
|
||||
background: linear-gradient(to right, #61afef, #c678dd);
|
||||
}
|
||||
|
||||
.onedark .slider-thumb {
|
||||
background: oklch(0.23 0.01 250);
|
||||
border-color: #61afef;
|
||||
}
|
||||
|
||||
.onedark .xml-highlight {
|
||||
color: oklch(0.85 0.02 240); /* #abb2bf */
|
||||
}
|
||||
|
||||
.onedark .xml-tag-bracket {
|
||||
color: oklch(0.6 0.2 20); /* #e06c75 red */
|
||||
}
|
||||
|
||||
.onedark .xml-tag-name {
|
||||
color: oklch(0.6 0.2 20); /* Red for tags */
|
||||
}
|
||||
|
||||
.onedark .xml-attribute-name {
|
||||
color: oklch(0.8 0.15 80); /* #e5c07b yellow */
|
||||
}
|
||||
|
||||
.onedark .xml-attribute-equals {
|
||||
color: oklch(0.7 0.02 240); /* Dim text */
|
||||
}
|
||||
|
||||
.onedark .xml-attribute-value {
|
||||
color: oklch(0.75 0.18 150); /* #98c379 green */
|
||||
}
|
||||
|
||||
.onedark .xml-comment {
|
||||
color: oklch(0.5 0.03 240); /* #5c6370 */
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.onedark .xml-cdata {
|
||||
color: oklch(0.7 0.15 180); /* #56b6c2 cyan */
|
||||
}
|
||||
|
||||
.onedark .xml-doctype {
|
||||
color: oklch(0.75 0.15 320); /* #c678dd magenta */
|
||||
}
|
||||
|
||||
.onedark .xml-text {
|
||||
color: oklch(0.85 0.02 240); /* Text */
|
||||
}
|
||||
70
apps/ui/src/styles/themes/red.css
Normal file
70
apps/ui/src/styles/themes/red.css
Normal file
@@ -0,0 +1,70 @@
|
||||
/* Red Theme */
|
||||
|
||||
.red {
|
||||
--background: oklch(0.12 0.03 15); /* Deep dark red-tinted black */
|
||||
--background-50: oklch(0.12 0.03 15 / 0.5);
|
||||
--background-80: oklch(0.12 0.03 15 / 0.8);
|
||||
|
||||
--foreground: oklch(0.95 0.01 15); /* Off-white with warm tint */
|
||||
--foreground-secondary: oklch(0.7 0.02 15);
|
||||
--foreground-muted: oklch(0.5 0.03 15);
|
||||
|
||||
--card: oklch(0.18 0.04 15); /* Slightly lighter dark red */
|
||||
--card-foreground: oklch(0.95 0.01 15);
|
||||
--popover: oklch(0.15 0.035 15);
|
||||
--popover-foreground: oklch(0.95 0.01 15);
|
||||
|
||||
--primary: oklch(0.55 0.25 25); /* Vibrant crimson red */
|
||||
--primary-foreground: oklch(0.98 0 0);
|
||||
|
||||
--brand-400: oklch(0.6 0.23 25);
|
||||
--brand-500: oklch(0.55 0.25 25); /* Crimson */
|
||||
--brand-600: oklch(0.5 0.27 25);
|
||||
|
||||
--secondary: oklch(0.22 0.05 15);
|
||||
--secondary-foreground: oklch(0.95 0.01 15);
|
||||
|
||||
--muted: oklch(0.22 0.05 15);
|
||||
--muted-foreground: oklch(0.5 0.03 15);
|
||||
|
||||
--accent: oklch(0.28 0.06 15);
|
||||
--accent-foreground: oklch(0.95 0.01 15);
|
||||
|
||||
--destructive: oklch(0.6 0.28 30); /* Bright orange-red for destructive */
|
||||
|
||||
--border: oklch(0.35 0.08 15);
|
||||
--border-glass: oklch(0.55 0.25 25 / 0.3);
|
||||
|
||||
--input: oklch(0.18 0.04 15);
|
||||
--ring: oklch(0.55 0.25 25);
|
||||
|
||||
--chart-1: oklch(0.55 0.25 25); /* Crimson */
|
||||
--chart-2: oklch(0.7 0.2 50); /* Orange */
|
||||
--chart-3: oklch(0.8 0.18 80); /* Gold */
|
||||
--chart-4: oklch(0.6 0.22 0); /* Pure red */
|
||||
--chart-5: oklch(0.65 0.2 350); /* Pink-red */
|
||||
|
||||
--sidebar: oklch(0.1 0.025 15);
|
||||
--sidebar-foreground: oklch(0.95 0.01 15);
|
||||
--sidebar-primary: oklch(0.55 0.25 25);
|
||||
--sidebar-primary-foreground: oklch(0.98 0 0);
|
||||
--sidebar-accent: oklch(0.22 0.05 15);
|
||||
--sidebar-accent-foreground: oklch(0.95 0.01 15);
|
||||
--sidebar-border: oklch(0.35 0.08 15);
|
||||
--sidebar-ring: oklch(0.55 0.25 25);
|
||||
|
||||
/* Action button colors - Red theme */
|
||||
--action-view: oklch(0.55 0.25 25); /* Crimson */
|
||||
--action-view-hover: oklch(0.5 0.27 25);
|
||||
--action-followup: oklch(0.7 0.2 50); /* Orange */
|
||||
--action-followup-hover: oklch(0.65 0.22 50);
|
||||
--action-commit: oklch(0.6 0.2 140); /* Green for positive actions */
|
||||
--action-commit-hover: oklch(0.55 0.22 140);
|
||||
--action-verify: oklch(0.6 0.2 140); /* Green */
|
||||
--action-verify-hover: oklch(0.55 0.22 140);
|
||||
|
||||
/* Running indicator - Crimson */
|
||||
--running-indicator: oklch(0.55 0.25 25);
|
||||
--running-indicator-text: oklch(0.6 0.23 25);
|
||||
}
|
||||
|
||||
227
apps/ui/src/styles/themes/retro.css
Normal file
227
apps/ui/src/styles/themes/retro.css
Normal file
@@ -0,0 +1,227 @@
|
||||
/* Retro Theme */
|
||||
|
||||
.retro {
|
||||
/* Retro / Cyberpunk Theme */
|
||||
--background: oklch(0 0 0); /* Pure Black */
|
||||
--background-50: oklch(0 0 0 / 0.5);
|
||||
--background-80: oklch(0 0 0 / 0.8);
|
||||
|
||||
/* Neon Green Text */
|
||||
--foreground: oklch(0.85 0.25 145); /* Neon Green */
|
||||
--foreground-secondary: oklch(0.7 0.2 145);
|
||||
--foreground-muted: oklch(0.5 0.15 145);
|
||||
|
||||
/* Hard Edges */
|
||||
--radius: 0px;
|
||||
|
||||
/* UI Elements */
|
||||
--card: oklch(0 0 0); /* Black card */
|
||||
--card-foreground: oklch(0.85 0.25 145);
|
||||
--popover: oklch(0.05 0.05 145);
|
||||
--popover-foreground: oklch(0.85 0.25 145);
|
||||
|
||||
--primary: oklch(0.85 0.25 145); /* Neon Green */
|
||||
--primary-foreground: oklch(0 0 0); /* Black text on green */
|
||||
|
||||
--brand-400: oklch(0.85 0.25 145);
|
||||
--brand-500: oklch(0.85 0.25 145);
|
||||
--brand-600: oklch(0.75 0.25 145);
|
||||
|
||||
--secondary: oklch(0.1 0.1 145); /* Dark Green bg */
|
||||
--secondary-foreground: oklch(0.85 0.25 145);
|
||||
|
||||
--muted: oklch(0.1 0.05 145);
|
||||
--muted-foreground: oklch(0.5 0.15 145);
|
||||
|
||||
--accent: oklch(0.2 0.2 145); /* Brighter green accent */
|
||||
--accent-foreground: oklch(0.85 0.25 145);
|
||||
|
||||
--destructive: oklch(0.6 0.25 25); /* Keep red for destructive */
|
||||
|
||||
--border: oklch(0.3 0.15 145); /* Visible Green Border */
|
||||
--border-glass: oklch(0.85 0.25 145 / 0.3);
|
||||
|
||||
--input: oklch(0.1 0.1 145);
|
||||
--ring: oklch(0.85 0.25 145);
|
||||
|
||||
/* Charts - various neons */
|
||||
--chart-1: oklch(0.85 0.25 145); /* Green */
|
||||
--chart-2: oklch(0.8 0.25 300); /* Purple Neon */
|
||||
--chart-3: oklch(0.8 0.25 200); /* Cyan Neon */
|
||||
--chart-4: oklch(0.8 0.25 60); /* Yellow Neon */
|
||||
--chart-5: oklch(0.8 0.25 20); /* Red Neon */
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar: oklch(0 0 0);
|
||||
--sidebar-foreground: oklch(0.85 0.25 145);
|
||||
--sidebar-primary: oklch(0.85 0.25 145);
|
||||
--sidebar-primary-foreground: oklch(0 0 0);
|
||||
--sidebar-accent: oklch(0.1 0.1 145);
|
||||
--sidebar-accent-foreground: oklch(0.85 0.25 145);
|
||||
--sidebar-border: oklch(0.3 0.15 145);
|
||||
--sidebar-ring: oklch(0.85 0.25 145);
|
||||
|
||||
/* Fonts */
|
||||
--font-sans: var(--font-geist-mono); /* Force Mono everywhere */
|
||||
|
||||
/* Action button colors - All green neon for retro theme */
|
||||
--action-view: oklch(0.85 0.25 145); /* Neon Green */
|
||||
--action-view-hover: oklch(0.9 0.25 145);
|
||||
--action-followup: oklch(0.85 0.25 145); /* Neon Green */
|
||||
--action-followup-hover: oklch(0.9 0.25 145);
|
||||
--action-commit: oklch(0.85 0.25 145); /* Neon Green */
|
||||
--action-commit-hover: oklch(0.9 0.25 145);
|
||||
--action-verify: oklch(0.85 0.25 145); /* Neon Green */
|
||||
--action-verify-hover: oklch(0.9 0.25 145);
|
||||
|
||||
/* Running indicator - Neon Green for retro */
|
||||
--running-indicator: oklch(0.85 0.25 145);
|
||||
--running-indicator-text: oklch(0.85 0.25 145);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
DRACULA THEME
|
||||
Inspired by the popular Dracula VS Code theme
|
||||
======================================== */
|
||||
|
||||
/* Theme-specific overrides */
|
||||
|
||||
.retro .scrollbar-visible::-webkit-scrollbar-thumb {
|
||||
background: var(--primary);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.retro .scrollbar-visible::-webkit-scrollbar-track {
|
||||
background: var(--background);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.retro .scrollbar-styled::-webkit-scrollbar-thumb {
|
||||
background: var(--primary);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.retro .scrollbar-styled::-webkit-scrollbar-track {
|
||||
background: var(--background);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.retro .glass,
|
||||
.retro .glass-subtle,
|
||||
|
||||
.retro .glass-strong,
|
||||
.retro .bg-glass,
|
||||
|
||||
.retro .bg-glass-80 {
|
||||
backdrop-filter: none;
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.retro .gradient-brand {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.retro .content-bg {
|
||||
background:
|
||||
linear-gradient(rgba(0, 255, 65, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 255, 65, 0.03) 1px, transparent 1px),
|
||||
var(--background);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
.retro .animated-outline-gradient {
|
||||
background: conic-gradient(from 90deg at 50% 50%, #00ff41 0%, #00ffff 25%, #ff00ff 50%, #00ffff 75%, #00ff41 100%);
|
||||
animation: spin 2s linear infinite, retro-glow 1s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.retro [data-slot="button"][class*="animated-outline"] {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.retro .animated-outline-inner {
|
||||
background: oklch(0 0 0) !important;
|
||||
color: #00ff41 !important;
|
||||
border-radius: 0 !important;
|
||||
text-shadow: 0 0 5px #00ff41;
|
||||
font-family: var(--font-geist-mono), monospace;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.retro [data-slot="button"][class*="animated-outline"]:hover .animated-outline-inner {
|
||||
background: oklch(0.1 0.1 145) !important;
|
||||
color: #00ff41 !important;
|
||||
box-shadow:
|
||||
0 0 10px #00ff41,
|
||||
0 0 20px #00ff41,
|
||||
inset 0 0 10px rgba(0, 255, 65, 0.1);
|
||||
text-shadow: 0 0 10px #00ff41, 0 0 20px #00ff41;
|
||||
}
|
||||
|
||||
.retro .slider-track {
|
||||
background: oklch(0.15 0.05 145);
|
||||
border: 1px solid #00ff41;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.retro .slider-range {
|
||||
background: #00ff41;
|
||||
box-shadow: 0 0 10px #00ff41, 0 0 5px #00ff41;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.retro .slider-thumb {
|
||||
background: oklch(0 0 0);
|
||||
border: 2px solid #00ff41;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: 0 0 8px #00ff41;
|
||||
}
|
||||
|
||||
.retro .slider-thumb:hover {
|
||||
background: oklch(0.1 0.1 145);
|
||||
box-shadow: 0 0 12px #00ff41, 0 0 20px #00ff41;
|
||||
}
|
||||
|
||||
.retro .xml-highlight {
|
||||
color: oklch(0.85 0.25 145); /* Neon green default */
|
||||
}
|
||||
|
||||
.retro .xml-tag-bracket {
|
||||
color: oklch(0.8 0.25 200); /* Cyan for brackets */
|
||||
}
|
||||
|
||||
.retro .xml-tag-name {
|
||||
color: oklch(0.85 0.25 145); /* Bright green for tags */
|
||||
text-shadow: 0 0 5px oklch(0.85 0.25 145 / 0.5);
|
||||
}
|
||||
|
||||
.retro .xml-attribute-name {
|
||||
color: oklch(0.8 0.25 300); /* Purple neon for attrs */
|
||||
}
|
||||
|
||||
.retro .xml-attribute-equals {
|
||||
color: oklch(0.6 0.15 145); /* Dim green for = */
|
||||
}
|
||||
|
||||
.retro .xml-attribute-value {
|
||||
color: oklch(0.8 0.25 60); /* Yellow neon for strings */
|
||||
}
|
||||
|
||||
.retro .xml-comment {
|
||||
color: oklch(0.5 0.15 145); /* Dim green for comments */
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.retro .xml-cdata {
|
||||
color: oklch(0.75 0.2 200); /* Cyan for CDATA */
|
||||
}
|
||||
|
||||
.retro .xml-doctype {
|
||||
color: oklch(0.75 0.2 300); /* Purple for DOCTYPE */
|
||||
}
|
||||
|
||||
.retro .xml-text {
|
||||
color: oklch(0.7 0.2 145); /* Green text */
|
||||
}
|
||||
144
apps/ui/src/styles/themes/solarized.css
Normal file
144
apps/ui/src/styles/themes/solarized.css
Normal file
@@ -0,0 +1,144 @@
|
||||
/* Solarized Theme */
|
||||
|
||||
.solarized {
|
||||
--background: oklch(0.2 0.02 230); /* #002b36 base03 */
|
||||
--background-50: oklch(0.2 0.02 230 / 0.5);
|
||||
--background-80: oklch(0.2 0.02 230 / 0.8);
|
||||
|
||||
--foreground: oklch(0.75 0.02 90); /* #839496 base0 */
|
||||
--foreground-secondary: oklch(0.6 0.03 200); /* #657b83 base00 */
|
||||
--foreground-muted: oklch(0.5 0.04 200); /* #586e75 base01 */
|
||||
|
||||
--card: oklch(0.23 0.02 230); /* #073642 base02 */
|
||||
--card-foreground: oklch(0.75 0.02 90);
|
||||
--popover: oklch(0.22 0.02 230);
|
||||
--popover-foreground: oklch(0.75 0.02 90);
|
||||
|
||||
--primary: oklch(0.65 0.15 220); /* #268bd2 blue */
|
||||
--primary-foreground: oklch(0.2 0.02 230);
|
||||
|
||||
--brand-400: oklch(0.7 0.15 220);
|
||||
--brand-500: oklch(0.65 0.15 220); /* #268bd2 */
|
||||
--brand-600: oklch(0.6 0.17 220);
|
||||
|
||||
--secondary: oklch(0.25 0.02 230);
|
||||
--secondary-foreground: oklch(0.75 0.02 90);
|
||||
|
||||
--muted: oklch(0.25 0.02 230);
|
||||
--muted-foreground: oklch(0.5 0.04 200);
|
||||
|
||||
--accent: oklch(0.28 0.03 230);
|
||||
--accent-foreground: oklch(0.75 0.02 90);
|
||||
|
||||
--destructive: oklch(0.55 0.2 25); /* #dc322f red */
|
||||
|
||||
--border: oklch(0.35 0.03 230);
|
||||
--border-glass: oklch(0.65 0.15 220 / 0.3);
|
||||
|
||||
--input: oklch(0.23 0.02 230);
|
||||
--ring: oklch(0.65 0.15 220);
|
||||
|
||||
--chart-1: oklch(0.65 0.15 220); /* Blue */
|
||||
--chart-2: oklch(0.6 0.18 180); /* Cyan #2aa198 */
|
||||
--chart-3: oklch(0.65 0.2 140); /* Green #859900 */
|
||||
--chart-4: oklch(0.7 0.18 55); /* Yellow #b58900 */
|
||||
--chart-5: oklch(0.6 0.2 30); /* Orange #cb4b16 */
|
||||
|
||||
--sidebar: oklch(0.18 0.02 230);
|
||||
--sidebar-foreground: oklch(0.75 0.02 90);
|
||||
--sidebar-primary: oklch(0.65 0.15 220);
|
||||
--sidebar-primary-foreground: oklch(0.2 0.02 230);
|
||||
--sidebar-accent: oklch(0.25 0.02 230);
|
||||
--sidebar-accent-foreground: oklch(0.75 0.02 90);
|
||||
--sidebar-border: oklch(0.35 0.03 230);
|
||||
--sidebar-ring: oklch(0.65 0.15 220);
|
||||
|
||||
/* Action button colors - Solarized blue/cyan theme */
|
||||
--action-view: oklch(0.65 0.15 220); /* Blue */
|
||||
--action-view-hover: oklch(0.6 0.17 220);
|
||||
--action-followup: oklch(0.6 0.18 180); /* Cyan */
|
||||
--action-followup-hover: oklch(0.55 0.2 180);
|
||||
--action-commit: oklch(0.65 0.2 140); /* Green */
|
||||
--action-commit-hover: oklch(0.6 0.22 140);
|
||||
--action-verify: oklch(0.65 0.2 140); /* Green */
|
||||
--action-verify-hover: oklch(0.6 0.22 140);
|
||||
|
||||
/* Running indicator - Blue */
|
||||
--running-indicator: oklch(0.65 0.15 220);
|
||||
--running-indicator-text: oklch(0.7 0.13 220);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
GRUVBOX THEME
|
||||
Retro groove color scheme
|
||||
======================================== */
|
||||
|
||||
/* Theme-specific overrides */
|
||||
|
||||
.solarized .animated-outline-gradient {
|
||||
background: conic-gradient(from 90deg at 50% 50%, #268bd2 0%, #2aa198 50%, #268bd2 100%);
|
||||
}
|
||||
|
||||
.solarized .animated-outline-inner {
|
||||
background: oklch(0.2 0.02 230) !important;
|
||||
color: #268bd2 !important;
|
||||
}
|
||||
|
||||
.solarized [data-slot="button"][class*="animated-outline"]:hover .animated-outline-inner {
|
||||
background: oklch(0.25 0.03 230) !important;
|
||||
color: #2aa198 !important;
|
||||
}
|
||||
|
||||
.solarized .slider-track {
|
||||
background: oklch(0.25 0.02 230);
|
||||
}
|
||||
|
||||
.solarized .slider-range {
|
||||
background: linear-gradient(to right, #268bd2, #2aa198);
|
||||
}
|
||||
|
||||
.solarized .slider-thumb {
|
||||
background: oklch(0.23 0.02 230);
|
||||
border-color: #268bd2;
|
||||
}
|
||||
|
||||
.solarized .xml-highlight {
|
||||
color: oklch(0.75 0.02 90); /* #839496 */
|
||||
}
|
||||
|
||||
.solarized .xml-tag-bracket {
|
||||
color: oklch(0.65 0.15 220); /* #268bd2 blue */
|
||||
}
|
||||
|
||||
.solarized .xml-tag-name {
|
||||
color: oklch(0.65 0.15 220); /* Blue for tags */
|
||||
}
|
||||
|
||||
.solarized .xml-attribute-name {
|
||||
color: oklch(0.6 0.18 180); /* #2aa198 cyan */
|
||||
}
|
||||
|
||||
.solarized .xml-attribute-equals {
|
||||
color: oklch(0.75 0.02 90); /* Base text */
|
||||
}
|
||||
|
||||
.solarized .xml-attribute-value {
|
||||
color: oklch(0.65 0.2 140); /* #859900 green */
|
||||
}
|
||||
|
||||
.solarized .xml-comment {
|
||||
color: oklch(0.5 0.04 200); /* #586e75 */
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.solarized .xml-cdata {
|
||||
color: oklch(0.6 0.18 180); /* Cyan */
|
||||
}
|
||||
|
||||
.solarized .xml-doctype {
|
||||
color: oklch(0.6 0.2 290); /* #6c71c4 violet */
|
||||
}
|
||||
|
||||
.solarized .xml-text {
|
||||
color: oklch(0.75 0.02 90); /* Base text */
|
||||
}
|
||||
111
apps/ui/src/styles/themes/sunset.css
Normal file
111
apps/ui/src/styles/themes/sunset.css
Normal file
@@ -0,0 +1,111 @@
|
||||
/* Sunset Theme */
|
||||
|
||||
.sunset {
|
||||
/* Sunset Theme - Mellow oranges and soft purples */
|
||||
--background: oklch(0.15 0.02 280); /* Deep twilight blue-purple */
|
||||
--background-50: oklch(0.15 0.02 280 / 0.5);
|
||||
--background-80: oklch(0.15 0.02 280 / 0.8);
|
||||
|
||||
--foreground: oklch(0.95 0.01 80); /* Warm white */
|
||||
--foreground-secondary: oklch(0.75 0.02 60);
|
||||
--foreground-muted: oklch(0.6 0.02 60);
|
||||
|
||||
--card: oklch(0.2 0.025 280);
|
||||
--card-foreground: oklch(0.95 0.01 80);
|
||||
--popover: oklch(0.18 0.02 280);
|
||||
--popover-foreground: oklch(0.95 0.01 80);
|
||||
|
||||
--primary: oklch(0.68 0.18 45); /* Mellow sunset orange */
|
||||
--primary-foreground: oklch(0.15 0.02 280);
|
||||
|
||||
--brand-400: oklch(0.72 0.17 45);
|
||||
--brand-500: oklch(0.68 0.18 45); /* Soft sunset orange */
|
||||
--brand-600: oklch(0.64 0.19 42);
|
||||
|
||||
--secondary: oklch(0.25 0.03 280);
|
||||
--secondary-foreground: oklch(0.95 0.01 80);
|
||||
|
||||
--muted: oklch(0.27 0.03 280);
|
||||
--muted-foreground: oklch(0.6 0.02 60);
|
||||
|
||||
--accent: oklch(0.35 0.04 310);
|
||||
--accent-foreground: oklch(0.95 0.01 80);
|
||||
|
||||
--destructive: oklch(0.6 0.2 25); /* Muted red */
|
||||
|
||||
--border: oklch(0.32 0.04 280);
|
||||
--border-glass: oklch(0.68 0.18 45 / 0.3);
|
||||
|
||||
--input: oklch(0.2 0.025 280);
|
||||
--ring: oklch(0.68 0.18 45);
|
||||
|
||||
--chart-1: oklch(0.68 0.18 45); /* Mellow orange */
|
||||
--chart-2: oklch(0.75 0.16 340); /* Soft pink sunset */
|
||||
--chart-3: oklch(0.78 0.18 70); /* Soft golden */
|
||||
--chart-4: oklch(0.66 0.19 42); /* Subtle coral */
|
||||
--chart-5: oklch(0.72 0.14 310); /* Pastel purple */
|
||||
|
||||
--sidebar: oklch(0.13 0.015 280);
|
||||
--sidebar-foreground: oklch(0.95 0.01 80);
|
||||
--sidebar-primary: oklch(0.68 0.18 45);
|
||||
--sidebar-primary-foreground: oklch(0.15 0.02 280);
|
||||
--sidebar-accent: oklch(0.25 0.03 280);
|
||||
--sidebar-accent-foreground: oklch(0.95 0.01 80);
|
||||
--sidebar-border: oklch(0.32 0.04 280);
|
||||
--sidebar-ring: oklch(0.68 0.18 45);
|
||||
|
||||
/* Action button colors - Mellow sunset palette */
|
||||
--action-view: oklch(0.68 0.18 45); /* Mellow orange */
|
||||
--action-view-hover: oklch(0.64 0.19 42);
|
||||
--action-followup: oklch(0.75 0.16 340); /* Soft pink */
|
||||
--action-followup-hover: oklch(0.7 0.17 340);
|
||||
--action-commit: oklch(0.65 0.16 140); /* Soft green */
|
||||
--action-commit-hover: oklch(0.6 0.17 140);
|
||||
--action-verify: oklch(0.65 0.16 140); /* Soft green */
|
||||
--action-verify-hover: oklch(0.6 0.17 140);
|
||||
|
||||
/* Running indicator - Mellow orange */
|
||||
--running-indicator: oklch(0.68 0.18 45);
|
||||
--running-indicator-text: oklch(0.72 0.17 45);
|
||||
|
||||
/* Status colors - Sunset theme */
|
||||
--status-success: oklch(0.65 0.16 140);
|
||||
--status-success-bg: oklch(0.65 0.16 140 / 0.2);
|
||||
--status-warning: oklch(0.78 0.18 70);
|
||||
--status-warning-bg: oklch(0.78 0.18 70 / 0.2);
|
||||
--status-error: oklch(0.65 0.2 25);
|
||||
--status-error-bg: oklch(0.65 0.2 25 / 0.2);
|
||||
--status-info: oklch(0.75 0.16 340);
|
||||
--status-info-bg: oklch(0.75 0.16 340 / 0.2);
|
||||
--status-backlog: oklch(0.65 0.02 280);
|
||||
--status-in-progress: oklch(0.78 0.18 70);
|
||||
--status-waiting: oklch(0.72 0.17 60);
|
||||
}
|
||||
|
||||
|
||||
/* Theme-specific overrides */
|
||||
|
||||
/* Sunset theme scrollbar */
|
||||
.sunset ::-webkit-scrollbar-thumb,
|
||||
.sunset .scrollbar-visible::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.5 0.14 45);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sunset ::-webkit-scrollbar-thumb:hover,
|
||||
.sunset .scrollbar-visible::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.58 0.16 45);
|
||||
}
|
||||
|
||||
.sunset ::-webkit-scrollbar-track,
|
||||
.sunset .scrollbar-visible::-webkit-scrollbar-track {
|
||||
background: oklch(0.18 0.03 280);
|
||||
}
|
||||
|
||||
.sunset .scrollbar-styled::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.5 0.14 45);
|
||||
}
|
||||
|
||||
.sunset .scrollbar-styled::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.58 0.16 45);
|
||||
}
|
||||
149
apps/ui/src/styles/themes/synthwave.css
Normal file
149
apps/ui/src/styles/themes/synthwave.css
Normal file
@@ -0,0 +1,149 @@
|
||||
/* Synthwave Theme */
|
||||
|
||||
.synthwave {
|
||||
--background: oklch(0.15 0.05 290); /* #262335 */
|
||||
--background-50: oklch(0.15 0.05 290 / 0.5);
|
||||
--background-80: oklch(0.15 0.05 290 / 0.8);
|
||||
|
||||
--foreground: oklch(0.95 0.02 320); /* #ffffff with warm tint */
|
||||
--foreground-secondary: oklch(0.75 0.05 320);
|
||||
--foreground-muted: oklch(0.55 0.08 290);
|
||||
|
||||
--card: oklch(0.2 0.06 290); /* #34294f */
|
||||
--card-foreground: oklch(0.95 0.02 320);
|
||||
--popover: oklch(0.18 0.05 290);
|
||||
--popover-foreground: oklch(0.95 0.02 320);
|
||||
|
||||
--primary: oklch(0.7 0.28 350); /* #f97e72 hot pink */
|
||||
--primary-foreground: oklch(0.15 0.05 290);
|
||||
|
||||
--brand-400: oklch(0.75 0.28 350);
|
||||
--brand-500: oklch(0.7 0.28 350); /* Hot pink */
|
||||
--brand-600: oklch(0.65 0.3 350);
|
||||
|
||||
--secondary: oklch(0.25 0.07 290);
|
||||
--secondary-foreground: oklch(0.95 0.02 320);
|
||||
|
||||
--muted: oklch(0.25 0.07 290);
|
||||
--muted-foreground: oklch(0.55 0.08 290);
|
||||
|
||||
--accent: oklch(0.3 0.08 290);
|
||||
--accent-foreground: oklch(0.95 0.02 320);
|
||||
|
||||
--destructive: oklch(0.6 0.25 15);
|
||||
|
||||
--border: oklch(0.4 0.1 290);
|
||||
--border-glass: oklch(0.7 0.28 350 / 0.3);
|
||||
|
||||
--input: oklch(0.2 0.06 290);
|
||||
--ring: oklch(0.7 0.28 350);
|
||||
|
||||
--chart-1: oklch(0.7 0.28 350); /* Hot pink */
|
||||
--chart-2: oklch(0.8 0.25 200); /* Cyan #72f1b8 */
|
||||
--chart-3: oklch(0.85 0.2 60); /* Yellow #fede5d */
|
||||
--chart-4: oklch(0.7 0.25 280); /* Purple #ff7edb */
|
||||
--chart-5: oklch(0.7 0.2 30); /* Orange #f97e72 */
|
||||
|
||||
--sidebar: oklch(0.13 0.05 290);
|
||||
--sidebar-foreground: oklch(0.95 0.02 320);
|
||||
--sidebar-primary: oklch(0.7 0.28 350);
|
||||
--sidebar-primary-foreground: oklch(0.15 0.05 290);
|
||||
--sidebar-accent: oklch(0.25 0.07 290);
|
||||
--sidebar-accent-foreground: oklch(0.95 0.02 320);
|
||||
--sidebar-border: oklch(0.4 0.1 290);
|
||||
--sidebar-ring: oklch(0.7 0.28 350);
|
||||
|
||||
/* Action button colors - Synthwave hot pink/cyan theme */
|
||||
--action-view: oklch(0.7 0.28 350); /* Hot pink */
|
||||
--action-view-hover: oklch(0.65 0.3 350);
|
||||
--action-followup: oklch(0.8 0.25 200); /* Cyan */
|
||||
--action-followup-hover: oklch(0.75 0.27 200);
|
||||
--action-commit: oklch(0.85 0.2 60); /* Yellow */
|
||||
--action-commit-hover: oklch(0.8 0.22 60);
|
||||
--action-verify: oklch(0.85 0.2 60); /* Yellow */
|
||||
--action-verify-hover: oklch(0.8 0.22 60);
|
||||
|
||||
/* Running indicator - Hot pink */
|
||||
--running-indicator: oklch(0.7 0.28 350);
|
||||
--running-indicator-text: oklch(0.75 0.26 350);
|
||||
}
|
||||
|
||||
/* Red Theme - Bold crimson/red aesthetic */
|
||||
|
||||
/* Theme-specific overrides */
|
||||
|
||||
.synthwave .animated-outline-gradient {
|
||||
background: conic-gradient(from 90deg at 50% 50%, #f97e72 0%, #72f1b8 25%, #ff7edb 50%, #72f1b8 75%, #f97e72 100%);
|
||||
animation: spin 2s linear infinite, synthwave-glow 1.5s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.synthwave .animated-outline-inner {
|
||||
background: oklch(0.15 0.05 290) !important;
|
||||
color: #f97e72 !important;
|
||||
text-shadow: 0 0 8px #f97e72;
|
||||
}
|
||||
|
||||
.synthwave [data-slot="button"][class*="animated-outline"]:hover .animated-outline-inner {
|
||||
background: oklch(0.22 0.07 290) !important;
|
||||
color: #72f1b8 !important;
|
||||
text-shadow: 0 0 12px #72f1b8;
|
||||
box-shadow: 0 0 15px rgba(114, 241, 184, 0.3);
|
||||
}
|
||||
|
||||
.synthwave .slider-track {
|
||||
background: oklch(0.25 0.07 290);
|
||||
}
|
||||
|
||||
.synthwave .slider-range {
|
||||
background: linear-gradient(to right, #f97e72, #ff7edb);
|
||||
box-shadow: 0 0 10px #f97e72, 0 0 5px #ff7edb;
|
||||
}
|
||||
|
||||
.synthwave .slider-thumb {
|
||||
background: oklch(0.2 0.06 290);
|
||||
border-color: #f97e72;
|
||||
box-shadow: 0 0 8px #f97e72;
|
||||
}
|
||||
|
||||
.synthwave .xml-highlight {
|
||||
color: oklch(0.95 0.02 320); /* Warm white */
|
||||
}
|
||||
|
||||
.synthwave .xml-tag-bracket {
|
||||
color: oklch(0.7 0.28 350); /* #f97e72 hot pink */
|
||||
}
|
||||
|
||||
.synthwave .xml-tag-name {
|
||||
color: oklch(0.7 0.28 350); /* Hot pink */
|
||||
text-shadow: 0 0 8px oklch(0.7 0.28 350 / 0.5);
|
||||
}
|
||||
|
||||
.synthwave .xml-attribute-name {
|
||||
color: oklch(0.7 0.25 280); /* #ff7edb purple */
|
||||
}
|
||||
|
||||
.synthwave .xml-attribute-equals {
|
||||
color: oklch(0.8 0.02 320); /* White-ish */
|
||||
}
|
||||
|
||||
.synthwave .xml-attribute-value {
|
||||
color: oklch(0.85 0.2 60); /* #fede5d yellow */
|
||||
text-shadow: 0 0 5px oklch(0.85 0.2 60 / 0.3);
|
||||
}
|
||||
|
||||
.synthwave .xml-comment {
|
||||
color: oklch(0.55 0.08 290); /* Dim purple */
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.synthwave .xml-cdata {
|
||||
color: oklch(0.8 0.25 200); /* #72f1b8 cyan */
|
||||
}
|
||||
|
||||
.synthwave .xml-doctype {
|
||||
color: oklch(0.8 0.25 200); /* Cyan */
|
||||
}
|
||||
|
||||
.synthwave .xml-text {
|
||||
color: oklch(0.95 0.02 320); /* White */
|
||||
}
|
||||
144
apps/ui/src/styles/themes/tokyonight.css
Normal file
144
apps/ui/src/styles/themes/tokyonight.css
Normal file
@@ -0,0 +1,144 @@
|
||||
/* Tokyonight Theme */
|
||||
|
||||
.tokyonight {
|
||||
--background: oklch(0.16 0.03 260); /* #1a1b26 */
|
||||
--background-50: oklch(0.16 0.03 260 / 0.5);
|
||||
--background-80: oklch(0.16 0.03 260 / 0.8);
|
||||
|
||||
--foreground: oklch(0.85 0.02 250); /* #a9b1d6 */
|
||||
--foreground-secondary: oklch(0.7 0.03 250);
|
||||
--foreground-muted: oklch(0.5 0.04 250); /* #565f89 */
|
||||
|
||||
--card: oklch(0.2 0.03 260); /* #24283b */
|
||||
--card-foreground: oklch(0.85 0.02 250);
|
||||
--popover: oklch(0.18 0.03 260);
|
||||
--popover-foreground: oklch(0.85 0.02 250);
|
||||
|
||||
--primary: oklch(0.7 0.18 280); /* #7aa2f7 blue */
|
||||
--primary-foreground: oklch(0.16 0.03 260);
|
||||
|
||||
--brand-400: oklch(0.75 0.18 280);
|
||||
--brand-500: oklch(0.7 0.18 280); /* #7aa2f7 */
|
||||
--brand-600: oklch(0.65 0.2 280); /* #7dcfff */
|
||||
|
||||
--secondary: oklch(0.24 0.03 260); /* #292e42 */
|
||||
--secondary-foreground: oklch(0.85 0.02 250);
|
||||
|
||||
--muted: oklch(0.24 0.03 260);
|
||||
--muted-foreground: oklch(0.5 0.04 250);
|
||||
|
||||
--accent: oklch(0.28 0.04 260);
|
||||
--accent-foreground: oklch(0.85 0.02 250);
|
||||
|
||||
--destructive: oklch(0.65 0.2 15); /* #f7768e */
|
||||
|
||||
--border: oklch(0.32 0.04 260);
|
||||
--border-glass: oklch(0.7 0.18 280 / 0.3);
|
||||
|
||||
--input: oklch(0.2 0.03 260);
|
||||
--ring: oklch(0.7 0.18 280);
|
||||
|
||||
--chart-1: oklch(0.7 0.18 280); /* Blue #7aa2f7 */
|
||||
--chart-2: oklch(0.75 0.18 200); /* Cyan #7dcfff */
|
||||
--chart-3: oklch(0.75 0.18 140); /* Green #9ece6a */
|
||||
--chart-4: oklch(0.7 0.2 320); /* Magenta #bb9af7 */
|
||||
--chart-5: oklch(0.8 0.18 70); /* Yellow #e0af68 */
|
||||
|
||||
--sidebar: oklch(0.14 0.03 260);
|
||||
--sidebar-foreground: oklch(0.85 0.02 250);
|
||||
--sidebar-primary: oklch(0.7 0.18 280);
|
||||
--sidebar-primary-foreground: oklch(0.16 0.03 260);
|
||||
--sidebar-accent: oklch(0.24 0.03 260);
|
||||
--sidebar-accent-foreground: oklch(0.85 0.02 250);
|
||||
--sidebar-border: oklch(0.32 0.04 260);
|
||||
--sidebar-ring: oklch(0.7 0.18 280);
|
||||
|
||||
/* Action button colors - Tokyo Night blue/magenta theme */
|
||||
--action-view: oklch(0.7 0.18 280); /* Blue */
|
||||
--action-view-hover: oklch(0.65 0.2 280);
|
||||
--action-followup: oklch(0.75 0.18 200); /* Cyan */
|
||||
--action-followup-hover: oklch(0.7 0.2 200);
|
||||
--action-commit: oklch(0.75 0.18 140); /* Green */
|
||||
--action-commit-hover: oklch(0.7 0.2 140);
|
||||
--action-verify: oklch(0.75 0.18 140); /* Green */
|
||||
--action-verify-hover: oklch(0.7 0.2 140);
|
||||
|
||||
/* Running indicator - Blue */
|
||||
--running-indicator: oklch(0.7 0.18 280);
|
||||
--running-indicator-text: oklch(0.75 0.16 280);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
SOLARIZED DARK THEME
|
||||
The classic color scheme by Ethan Schoonover
|
||||
======================================== */
|
||||
|
||||
/* Theme-specific overrides */
|
||||
|
||||
.tokyonight .animated-outline-gradient {
|
||||
background: conic-gradient(from 90deg at 50% 50%, #7aa2f7 0%, #bb9af7 50%, #7aa2f7 100%);
|
||||
}
|
||||
|
||||
.tokyonight .animated-outline-inner {
|
||||
background: oklch(0.16 0.03 260) !important;
|
||||
color: #7aa2f7 !important;
|
||||
}
|
||||
|
||||
.tokyonight [data-slot="button"][class*="animated-outline"]:hover .animated-outline-inner {
|
||||
background: oklch(0.22 0.04 260) !important;
|
||||
color: #bb9af7 !important;
|
||||
}
|
||||
|
||||
.tokyonight .slider-track {
|
||||
background: oklch(0.24 0.03 260);
|
||||
}
|
||||
|
||||
.tokyonight .slider-range {
|
||||
background: linear-gradient(to right, #7aa2f7, #bb9af7);
|
||||
}
|
||||
|
||||
.tokyonight .slider-thumb {
|
||||
background: oklch(0.2 0.03 260);
|
||||
border-color: #7aa2f7;
|
||||
}
|
||||
|
||||
.tokyonight .xml-highlight {
|
||||
color: oklch(0.85 0.02 250); /* #a9b1d6 */
|
||||
}
|
||||
|
||||
.tokyonight .xml-tag-bracket {
|
||||
color: oklch(0.65 0.2 15); /* #f7768e red */
|
||||
}
|
||||
|
||||
.tokyonight .xml-tag-name {
|
||||
color: oklch(0.65 0.2 15); /* Red for tags */
|
||||
}
|
||||
|
||||
.tokyonight .xml-attribute-name {
|
||||
color: oklch(0.7 0.2 320); /* #bb9af7 purple */
|
||||
}
|
||||
|
||||
.tokyonight .xml-attribute-equals {
|
||||
color: oklch(0.75 0.02 250); /* Dim text */
|
||||
}
|
||||
|
||||
.tokyonight .xml-attribute-value {
|
||||
color: oklch(0.75 0.18 140); /* #9ece6a green */
|
||||
}
|
||||
|
||||
.tokyonight .xml-comment {
|
||||
color: oklch(0.5 0.04 250); /* #565f89 */
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.tokyonight .xml-cdata {
|
||||
color: oklch(0.75 0.18 200); /* #7dcfff cyan */
|
||||
}
|
||||
|
||||
.tokyonight .xml-doctype {
|
||||
color: oklch(0.7 0.18 280); /* #7aa2f7 blue */
|
||||
}
|
||||
|
||||
.tokyonight .xml-text {
|
||||
color: oklch(0.85 0.02 250); /* Text color */
|
||||
}
|
||||
48
apps/ui/src/types/electron.d.ts
vendored
48
apps/ui/src/types/electron.d.ts
vendored
@@ -667,6 +667,13 @@ export interface WorktreeAPI {
|
||||
hasWorktree: boolean; // Does this branch have an active worktree?
|
||||
hasChanges?: boolean;
|
||||
changedFilesCount?: number;
|
||||
pr?: {
|
||||
number: number;
|
||||
url: string;
|
||||
title: string;
|
||||
state: string;
|
||||
createdAt: string;
|
||||
};
|
||||
}>;
|
||||
removedWorktrees?: Array<{
|
||||
path: string;
|
||||
@@ -737,6 +744,7 @@ export interface WorktreeAPI {
|
||||
createPR: (
|
||||
worktreePath: string,
|
||||
options?: {
|
||||
projectPath?: string;
|
||||
commitMessage?: string;
|
||||
prTitle?: string;
|
||||
prBody?: string;
|
||||
@@ -751,7 +759,9 @@ export interface WorktreeAPI {
|
||||
commitHash?: string;
|
||||
pushed: boolean;
|
||||
prUrl?: string;
|
||||
prNumber?: number;
|
||||
prCreated: boolean;
|
||||
prAlreadyExisted?: boolean;
|
||||
prError?: string;
|
||||
browserUrl?: string;
|
||||
ghCliAvailable?: boolean;
|
||||
@@ -894,6 +904,44 @@ export interface WorktreeAPI {
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Get PR info and comments for a branch
|
||||
getPRInfo: (
|
||||
worktreePath: string,
|
||||
branchName: string
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
result?: {
|
||||
hasPR: boolean;
|
||||
ghCliAvailable: boolean;
|
||||
prInfo?: {
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
state: string;
|
||||
author: string;
|
||||
body: string;
|
||||
comments: Array<{
|
||||
id: number;
|
||||
author: string;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
isReviewComment: boolean;
|
||||
}>;
|
||||
reviewComments: Array<{
|
||||
id: number;
|
||||
author: string;
|
||||
body: string;
|
||||
path?: string;
|
||||
line?: number;
|
||||
createdAt: string;
|
||||
isReviewComment: boolean;
|
||||
}>;
|
||||
};
|
||||
error?: string;
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface GitAPI {
|
||||
|
||||
@@ -53,13 +53,14 @@ test.describe("Spec Editor Persistence", () => {
|
||||
// Step 6: Wait for CodeMirror to initialize (it has a .cm-content element)
|
||||
await specEditor.locator(".cm-content").waitFor({ state: "visible", timeout: 10000 });
|
||||
|
||||
// Small delay to ensure editor is fully initialized
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Step 7: Modify the editor content to "hello world"
|
||||
await setEditorContent(page, "hello world");
|
||||
|
||||
// Step 8: Click the save button
|
||||
// Verify content was set before saving
|
||||
const contentBeforeSave = await getEditorContent(page);
|
||||
expect(contentBeforeSave.trim()).toBe("hello world");
|
||||
|
||||
// Step 8: Click the save button and wait for save to complete
|
||||
await clickSaveButton(page);
|
||||
|
||||
// Step 9: Refresh the page
|
||||
@@ -77,8 +78,43 @@ test.describe("Spec Editor Persistence", () => {
|
||||
const specEditorAfterReload = await getByTestId(page, "spec-editor");
|
||||
await specEditorAfterReload.locator(".cm-content").waitFor({ state: "visible", timeout: 10000 });
|
||||
|
||||
// Small delay to ensure editor content is loaded
|
||||
await page.waitForTimeout(500);
|
||||
// Wait for CodeMirror content to update with the loaded spec
|
||||
// The spec might need time to load into the editor after page reload
|
||||
let contentMatches = false;
|
||||
let attempts = 0;
|
||||
const maxAttempts = 30; // Try for up to 30 seconds with 1-second intervals
|
||||
|
||||
while (!contentMatches && attempts < maxAttempts) {
|
||||
try {
|
||||
const contentElement = page.locator('[data-testid="spec-editor"] .cm-content');
|
||||
const text = await contentElement.textContent();
|
||||
if (text && text.trim() === "hello world") {
|
||||
contentMatches = true;
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
// Element might not be ready yet, continue
|
||||
}
|
||||
|
||||
if (!contentMatches) {
|
||||
await page.waitForTimeout(1000);
|
||||
attempts++;
|
||||
}
|
||||
}
|
||||
|
||||
// If we didn't get the right content with our polling, use the fallback
|
||||
if (!contentMatches) {
|
||||
await page.waitForFunction(
|
||||
(expectedContent) => {
|
||||
const contentElement = document.querySelector('[data-testid="spec-editor"] .cm-content');
|
||||
if (!contentElement) return false;
|
||||
const text = (contentElement.textContent || "").trim();
|
||||
return text === expectedContent;
|
||||
},
|
||||
"hello world",
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
}
|
||||
|
||||
// Step 11: Verify the content was persisted
|
||||
const persistedContent = await getEditorContent(page);
|
||||
|
||||
@@ -57,3 +57,22 @@ export async function getCategoryOption(
|
||||
.replace(/\s+/g, "-")}`;
|
||||
return page.locator(`[data-testid="${optionTestId}"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the "Create new" option for a category that doesn't exist
|
||||
*/
|
||||
export async function clickCreateNewCategoryOption(
|
||||
page: Page
|
||||
): Promise<void> {
|
||||
const option = page.locator('[data-testid="category-option-create-new"]');
|
||||
await option.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the "Create new" option element for categories
|
||||
*/
|
||||
export async function getCreateNewCategoryOption(
|
||||
page: Page
|
||||
): Promise<Locator> {
|
||||
return page.locator('[data-testid="category-option-create-new"]');
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user