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:
Kacper
2025-12-20 22:20:17 +01:00
108 changed files with 10834 additions and 3489 deletions

6
apps/app/next-env.d.ts vendored Normal file
View 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.

View File

@@ -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

View File

@@ -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);

View 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
}
}

View File

@@ -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;
}

View 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);
}
};
}

View 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);

View 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;
}

View 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) });
}
};
}

View 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) });
}
};
}

View 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) });
}
};
}

View 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) });
}
};
}

View 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) });
}
};
}

View 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) });
}
};
}

View 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) });
}
};
}

View 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) });
}
};
}

View File

@@ -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";

View File

@@ -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());

View File

@@ -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,

View File

@@ -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,

View 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) });
}
};
}

View 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;
}
}

View 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;

View File

@@ -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);
});
});
});

View File

@@ -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);

View File

@@ -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();
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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"

View 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();
});
});
});

View File

@@ -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", () => {

View 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
View 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
View 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;
}
}

View File

@@ -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} />}
</>
);
}

View File

@@ -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]
);

View File

@@ -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>

View File

@@ -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",

View 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>
);
}

View File

@@ -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}

View File

@@ -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"
/>

View File

@@ -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);
}}

View File

@@ -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>
)}

View File

@@ -1,2 +1,2 @@
export { KanbanCard } from "./kanban-card";
export { KanbanCard } from "./kanban-card/kanban-card";
export { KanbanColumn } from "./kanban-column";

View File

@@ -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}
/>
)}
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
)}
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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;
});

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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(

View File

@@ -194,7 +194,6 @@ export function KanbanBoard({
onMoveBackToInProgress(feature)
}
onFollowUp={() => onFollowUp(feature)}
onCommit={() => onCommit(feature)}
onComplete={() => onComplete(feature)}
onImplement={() => onImplement(feature)}
onViewPlan={() => onViewPlan(feature)}

View File

@@ -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 />

View File

@@ -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}

View File

@@ -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) => {

View File

@@ -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[];

View File

@@ -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>
);
}

View File

@@ -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":

View File

@@ -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

View File

@@ -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>

View File

@@ -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";

View 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>
);
}

View 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,
};
}

View 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;
}
}

View File

@@ -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}` };
},
};
}

View File

@@ -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: (

View File

@@ -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) {

View File

@@ -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]);

View File

@@ -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,
};
}
}
});

View File

@@ -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

View 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";

View 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 */
}

View 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);
}

View 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 */
}

View 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 */
}

View 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);
}

View 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 */
}

View 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 */
}

View 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 */
}

View 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 */
}

View 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 */
}

View 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);
}

View 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 */
}

View 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 */
}

View 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);
}

View 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 */
}

View 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 */
}

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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"]');
}

View File

@@ -60,12 +60,16 @@ export async function navigateToSpecEditor(page: Page): Promise<void> {
/**
* Get the CodeMirror editor content
* Waits for CodeMirror to be ready and returns the content
*/
export async function getEditorContent(page: Page): Promise<string> {
// CodeMirror uses a contenteditable div with class .cm-content
const content = await page
.locator('[data-testid="spec-editor"] .cm-content')
.textContent();
// Wait for it to be visible and then read its textContent
const contentElement = page.locator('[data-testid="spec-editor"] .cm-content');
await contentElement.waitFor({ state: "visible", timeout: 10000 });
// Read the content - CodeMirror should have updated its DOM by now
const content = await contentElement.textContent();
return content || "";
}

View File

@@ -850,6 +850,58 @@ test.describe("Worktree Integration Tests", () => {
expect(featureData.status).toBe("backlog");
});
test("should auto-select worktree after creating feature with new branch", async ({
page,
}) => {
await setupProjectWithPath(page, testRepo.path);
await page.goto("/");
await waitForNetworkIdle(page);
await waitForBoardView(page);
// Use a branch name that doesn't exist yet
const branchName = "feature/auto-select-worktree";
// Verify branch does NOT exist before we create the feature
const branchesBefore = await listBranches(testRepo.path);
expect(branchesBefore).not.toContain(branchName);
// Click add feature button
await clickAddFeature(page);
// Fill in the feature details with the new branch
await fillAddFeatureDialog(page, "Feature with auto-select worktree", {
branch: branchName,
category: "Testing",
});
// Confirm
await confirmAddFeature(page);
// Wait for feature to be saved and worktree to be created
// Also wait for the worktree to appear in the UI and be auto-selected
await page.waitForTimeout(2000);
// Wait for the worktree button to appear in the UI
// Worktree buttons are actual <button> elements (not divs with role="button" like kanban cards)
// and have a title attribute like "Click to switch to this worktree's branch"
const worktreeButton = page
.locator('button[title*="worktree"], button[title*="branch"]')
.filter({ hasText: new RegExp(branchName.replace("/", "\\/"), "i") })
.first();
await expect(worktreeButton).toBeVisible({ timeout: 10000 });
// Verify the worktree is auto-selected by checking if the feature is visible
// Features are filtered by the selected worktree, so if the feature is visible,
// it means the worktree was auto-selected after creation
const featureText = page.getByText("Feature with auto-select worktree");
await expect(featureText).toBeVisible({ timeout: 10000 });
// Additional verification: Check that the button has the selected styling
// Selected worktree buttons have variant="default" which applies bg-primary class
// We verify this by checking the button has the primary background styling
await expect(worktreeButton).toHaveClass(/bg-primary/, { timeout: 5000 });
});
test("should reset feature branch and worktree when worktree is deleted", async ({
page,
}) => {
@@ -1217,7 +1269,11 @@ test.describe("Worktree Integration Tests", () => {
// Worktree Feature Flag Disabled
// ==========================================================================
test("should not show worktree panel when useWorktrees is disabled", async ({
// Skip: This test is flaky because the WorktreePanel component always renders
// the "Branch:" label and switch branch button, even when useWorktrees is disabled.
// The component only conditionally hides the "Worktrees:" section, not the entire panel.
// The test expectations don't match the current implementation.
test.skip("should not show worktree panel when useWorktrees is disabled", async ({
page,
}) => {
// Use the setup function that disables worktrees
@@ -1235,7 +1291,12 @@ test.describe("Worktree Integration Tests", () => {
await expect(branchSwitchButton).not.toBeVisible();
});
test("should allow creating and moving features when worktrees are disabled", async ({
// Skip: The WorktreePanel component always renders the "Branch:" label
// and main worktree tab, regardless of useWorktrees setting.
// It only conditionally hides the "Worktrees:" section.
// This test is unreliable because it tests implementation details that
// don't match the current component behavior.
test.skip("should allow creating and moving features when worktrees are disabled", async ({
page,
}) => {
// Use the setup function that disables worktrees
@@ -2615,4 +2676,248 @@ test.describe("Worktree Integration Tests", () => {
// worktreePath should not exist in the feature data (worktrees are created at execution time)
expect(featureData.worktreePath).toBeUndefined();
});
// ==========================================================================
// PR URL Tracking Tests
// ==========================================================================
test("feature should support prUrl field for tracking pull request URLs", async ({
page,
}) => {
await setupProjectWithPath(page, testRepo.path);
await page.goto("/");
await waitForNetworkIdle(page);
await waitForBoardView(page);
// Create a feature
await clickAddFeature(page);
await fillAddFeatureDialog(page, "Feature for PR URL test", {
category: "Testing",
});
await confirmAddFeature(page);
await page.waitForTimeout(1000);
// Verify feature was created
const featuresDir = path.join(testRepo.path, ".automaker", "features");
const featureDirs = fs.readdirSync(featuresDir);
const featureDir = featureDirs.find((dir) => {
const featureFilePath = path.join(featuresDir, dir, "feature.json");
if (fs.existsSync(featureFilePath)) {
const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
return data.description === "Feature for PR URL test";
}
return false;
});
expect(featureDir).toBeDefined();
// Manually update the feature.json file to add prUrl (simulating what happens after PR creation)
const featureFilePath = path.join(featuresDir, featureDir!, "feature.json");
const featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
featureData.prUrl = "https://github.com/test/repo/pull/123";
fs.writeFileSync(featureFilePath, JSON.stringify(featureData, null, 2));
// Reload the page to pick up the change
await page.reload();
await waitForNetworkIdle(page);
await waitForBoardView(page);
await page.waitForTimeout(1000);
// Verify the PR URL link is displayed on the card
const prUrlLink = page.locator(`[data-testid="pr-url-${featureData.id}"]`);
await expect(prUrlLink).toBeVisible({ timeout: 5000 });
await expect(prUrlLink).toHaveText(/Pull Request/);
await expect(prUrlLink).toHaveAttribute(
"href",
"https://github.com/test/repo/pull/123"
);
});
test("prUrl should persist when updating feature", async ({ page }) => {
await setupProjectWithPath(page, testRepo.path);
await page.goto("/");
await waitForNetworkIdle(page);
await waitForBoardView(page);
// Create a feature
await clickAddFeature(page);
await fillAddFeatureDialog(page, "Feature with PR URL persistence", {
category: "Testing",
});
await confirmAddFeature(page);
await page.waitForTimeout(1000);
// Find the feature file
const featuresDir = path.join(testRepo.path, ".automaker", "features");
const featureDirs = fs.readdirSync(featuresDir);
const featureDir = featureDirs.find((dir) => {
const featureFilePath = path.join(featuresDir, dir, "feature.json");
if (fs.existsSync(featureFilePath)) {
const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
return data.description === "Feature with PR URL persistence";
}
return false;
});
expect(featureDir).toBeDefined();
// Add prUrl to the feature
const featureFilePath = path.join(featuresDir, featureDir!, "feature.json");
let featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
const originalPrUrl = "https://github.com/test/repo/pull/456";
featureData.prUrl = originalPrUrl;
fs.writeFileSync(featureFilePath, JSON.stringify(featureData, null, 2));
// Reload the page
await page.reload();
await waitForNetworkIdle(page);
await waitForBoardView(page);
await page.waitForTimeout(1000);
// Open edit dialog by double-clicking the feature card
const featureCard = page.getByText("Feature with PR URL persistence");
await featureCard.dblclick();
await page.waitForTimeout(500);
// Wait for edit dialog to open
const editDialog = page.locator('[data-testid="edit-feature-dialog"]');
await expect(editDialog).toBeVisible({ timeout: 5000 });
// Update the description - wait for the textarea to be visible
const descInput = page.locator(
'[data-testid="feature-description-input"]'
);
await expect(descInput).toBeVisible({ timeout: 5000 });
await descInput.fill("Feature with PR URL persistence - updated");
// Save the feature
const saveButton = page.locator('[data-testid="confirm-edit-feature"]');
await saveButton.click();
await page.waitForTimeout(1000);
// Verify prUrl was preserved
featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
expect(featureData.prUrl).toBe(originalPrUrl);
expect(featureData.description).toBe(
"Feature with PR URL persistence - updated"
);
});
test("feature in waiting_approval with prUrl should show Verify button instead of Commit", async ({
page,
}) => {
await setupProjectWithPath(page, testRepo.path);
await page.goto("/");
await waitForNetworkIdle(page);
await waitForBoardView(page);
// Create a feature
await clickAddFeature(page);
await fillAddFeatureDialog(page, "Feature with PR for verify test", {
category: "Testing",
});
await confirmAddFeature(page);
await page.waitForTimeout(1000);
// Find the feature file
const featuresDir = path.join(testRepo.path, ".automaker", "features");
const featureDirs = fs.readdirSync(featuresDir);
const featureDir = featureDirs.find((dir) => {
const featureFilePath = path.join(featuresDir, dir, "feature.json");
if (fs.existsSync(featureFilePath)) {
const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
return data.description === "Feature with PR for verify test";
}
return false;
});
expect(featureDir).toBeDefined();
// Update the feature to waiting_approval status with a prUrl
const featureFilePath = path.join(featuresDir, featureDir!, "feature.json");
let featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
featureData.status = "waiting_approval";
featureData.prUrl = "https://github.com/test/repo/pull/789";
fs.writeFileSync(featureFilePath, JSON.stringify(featureData, null, 2));
// Reload the page to pick up the changes
await page.reload();
await waitForNetworkIdle(page);
await waitForBoardView(page);
await page.waitForTimeout(1000);
// Verify the feature card is in the waiting_approval column
const waitingApprovalColumn = page.locator(
'[data-testid="kanban-column-waiting_approval"]'
);
const featureCard = waitingApprovalColumn.locator(
`[data-testid="kanban-card-${featureData.id}"]`
);
await expect(featureCard).toBeVisible({ timeout: 5000 });
// Verify the Verify button is visible (not Commit button)
const verifyButton = page.locator(`[data-testid="verify-${featureData.id}"]`);
await expect(verifyButton).toBeVisible({ timeout: 5000 });
// Verify the Commit button is NOT visible
const commitButton = page.locator(`[data-testid="commit-${featureData.id}"]`);
await expect(commitButton).not.toBeVisible({ timeout: 2000 });
});
test("feature in waiting_approval without prUrl should show Mark as Verified button", async ({
page,
}) => {
await setupProjectWithPath(page, testRepo.path);
await page.goto("/");
await waitForNetworkIdle(page);
await waitForBoardView(page);
// Create a feature
await clickAddFeature(page);
await fillAddFeatureDialog(page, "Feature without PR for mark as verified test", {
category: "Testing",
});
await confirmAddFeature(page);
await page.waitForTimeout(1000);
// Find the feature file
const featuresDir = path.join(testRepo.path, ".automaker", "features");
const featureDirs = fs.readdirSync(featuresDir);
const featureDir = featureDirs.find((dir) => {
const featureFilePath = path.join(featuresDir, dir, "feature.json");
if (fs.existsSync(featureFilePath)) {
const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
return data.description === "Feature without PR for mark as verified test";
}
return false;
});
expect(featureDir).toBeDefined();
// Update the feature to waiting_approval status WITHOUT prUrl
const featureFilePath = path.join(featuresDir, featureDir!, "feature.json");
let featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
featureData.status = "waiting_approval";
// Explicitly do NOT set prUrl
fs.writeFileSync(featureFilePath, JSON.stringify(featureData, null, 2));
// Reload the page to pick up the changes
await page.reload();
await waitForNetworkIdle(page);
await waitForBoardView(page);
await page.waitForTimeout(1000);
// Verify the feature card is in the waiting_approval column
const waitingApprovalColumn = page.locator(
'[data-testid="kanban-column-waiting_approval"]'
);
const featureCard = waitingApprovalColumn.locator(
`[data-testid="kanban-card-${featureData.id}"]`
);
await expect(featureCard).toBeVisible({ timeout: 5000 });
// Verify the Mark as Verified button is visible
const markAsVerifiedButton = page.locator(`[data-testid="mark-as-verified-${featureData.id}"]`);
await expect(markAsVerifiedButton).toBeVisible({ timeout: 5000 });
// Verify the Verify button is NOT visible
const verifyButton = page.locator(`[data-testid="verify-${featureData.id}"]`);
await expect(verifyButton).not.toBeVisible({ timeout: 2000 });
});
});