Compare commits

..

2 Commits

Author SHA1 Message Date
Kacper
d4ce1f331b feat: add onboarding wizard components and logic
- Introduced a new onboarding wizard for guiding users through the board features.
- Added shared onboarding components including OnboardingWizard, useOnboardingWizard, and related types and constants.
- Implemented onboarding logic in the BoardView, integrating sample feature generation for a quick start.
- Updated UI components to support onboarding interactions and improve user experience.
- Enhanced onboarding state management with localStorage persistence for user progress tracking.
2026-01-11 16:05:04 +01:00
Shirone
66b68ea4eb feat: implement onboarding wizard for board view
- Added a new onboarding wizard to guide users through the board features.
- Integrated sample feature generation for quick start onboarding.
- Enhanced BoardView component to manage onboarding state and actions.
- Updated BoardControls and BoardHeader to include tour functionality.
- Introduced utility functions for sample feature management in constants.
- Improved user experience with toast notifications for onboarding actions.
2026-01-11 12:49:29 +01:00
118 changed files with 3003 additions and 7685 deletions

1
.gitignore vendored
View File

@@ -95,4 +95,3 @@ check-sync.sh
# API key files
data/.api-key
data/credentials.json
data/

View File

@@ -1,6 +1,6 @@
{
"name": "@automaker/server",
"version": "0.10.0",
"version": "0.9.0",
"description": "Backend server for Automaker - provides API for both web and Electron modes",
"author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE",

View File

@@ -217,7 +217,7 @@ app.use('/api/sessions', createSessionsRoutes(agentService));
app.use('/api/features', createFeaturesRoutes(featureLoader));
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
app.use('/api/worktree', createWorktreeRoutes(events));
app.use('/api/worktree', createWorktreeRoutes());
app.use('/api/git', createGitRoutes());
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService));
app.use('/api/models', createModelsRoutes());

View File

@@ -21,12 +21,6 @@ export interface WorktreeMetadata {
branch: string;
createdAt: string;
pr?: WorktreePRInfo;
/** Whether the init script has been executed for this worktree */
initScriptRan?: boolean;
/** Status of the init script execution */
initScriptStatus?: 'running' | 'success' | 'failed';
/** Error message if init script failed */
initScriptError?: string;
}
/**

File diff suppressed because it is too large Load Diff

View File

@@ -12,22 +12,11 @@ const featureLoader = new FeatureLoader();
export function createApplyHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const {
projectPath,
plan,
branchName: rawBranchName,
} = req.body as {
const { projectPath, plan } = req.body as {
projectPath: string;
plan: BacklogPlanResult;
branchName?: string;
};
// Validate branchName: must be undefined or a non-empty trimmed string
const branchName =
typeof rawBranchName === 'string' && rawBranchName.trim().length > 0
? rawBranchName.trim()
: undefined;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath required' });
return;
@@ -93,7 +82,6 @@ export function createApplyHandler() {
dependencies: change.feature.dependencies,
priority: change.feature.priority,
status: 'backlog',
branchName,
});
appliedChanges.push(`added:${newFeature.id}`);

View File

@@ -12,7 +12,6 @@ import { resolveModelString } from '@automaker/model-resolver';
import {
CLAUDE_MODEL_MAP,
isCursorModel,
isOpencodeModel,
stripProviderPrefix,
ThinkingLevel,
getThinkingTokenBudget,
@@ -92,13 +91,13 @@ async function extractTextFromStream(
}
/**
* Execute enhancement using a provider (Cursor, OpenCode, etc.)
* Execute enhancement using Cursor provider
*
* @param prompt - The enhancement prompt
* @param model - The model to use
* @param model - The Cursor model to use
* @returns The enhanced text
*/
async function executeWithProvider(prompt: string, model: string): Promise<string> {
async function executeWithCursor(prompt: string, model: string): Promise<string> {
const provider = ProviderFactory.getProviderForModel(model);
// Strip provider prefix - providers expect bare model IDs
const bareModel = stripProviderPrefix(model);
@@ -111,11 +110,7 @@ async function executeWithProvider(prompt: string, model: string): Promise<strin
cwd: process.cwd(), // Enhancement doesn't need a specific working directory
readOnly: true, // Prompt enhancement only generates text, doesn't write files
})) {
if (msg.type === 'error') {
// Throw error with the message from the provider
const errorMessage = msg.error || 'Provider returned an error';
throw new Error(errorMessage);
} else if (msg.type === 'assistant' && msg.message?.content) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
@@ -217,14 +212,7 @@ export function createEnhanceHandler(
// Cursor doesn't have a separate system prompt concept, so combine them
const combinedPrompt = `${systemPrompt}\n\n${userPrompt}`;
enhancedText = await executeWithProvider(combinedPrompt, resolvedModel);
} else if (isOpencodeModel(resolvedModel)) {
// Use OpenCode provider for OpenCode models (static and dynamic)
logger.info(`Using OpenCode provider for model: ${resolvedModel}`);
// OpenCode CLI handles the system prompt, so combine them
const combinedPrompt = `${systemPrompt}\n\n${userPrompt}`;
enhancedText = await executeWithProvider(combinedPrompt, resolvedModel);
enhancedText = await executeWithCursor(combinedPrompt, resolvedModel);
} else {
// Use Claude SDK for Claude models
logger.info(`Using Claude provider for model: ${resolvedModel}`);

View File

@@ -10,21 +10,14 @@ import { getErrorMessage, logError } from '../common.js';
export function createUpdateHandler(featureLoader: FeatureLoader) {
return async (req: Request, res: Response): Promise<void> => {
try {
const {
projectPath,
featureId,
updates,
descriptionHistorySource,
enhancementMode,
preEnhancementDescription,
} = req.body as {
projectPath: string;
featureId: string;
updates: Partial<Feature>;
descriptionHistorySource?: 'enhance' | 'edit';
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer';
preEnhancementDescription?: string;
};
const { projectPath, featureId, updates, descriptionHistorySource, enhancementMode } =
req.body as {
projectPath: string;
featureId: string;
updates: Partial<Feature>;
descriptionHistorySource?: 'enhance' | 'edit';
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer';
};
if (!projectPath || !featureId || !updates) {
res.status(400).json({
@@ -39,8 +32,7 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
featureId,
updates,
descriptionHistorySource,
enhancementMode,
preEnhancementDescription
enhancementMode
);
res.json({ success: true, feature: updated });
} catch (error) {

View File

@@ -24,12 +24,6 @@ import { createDeauthCursorHandler } from './routes/deauth-cursor.js';
import { createAuthOpencodeHandler } from './routes/auth-opencode.js';
import { createDeauthOpencodeHandler } from './routes/deauth-opencode.js';
import { createOpencodeStatusHandler } from './routes/opencode-status.js';
import {
createGetOpencodeModelsHandler,
createRefreshOpencodeModelsHandler,
createGetOpencodeProvidersHandler,
createClearOpencodeCacheHandler,
} from './routes/opencode-models.js';
import {
createGetCursorConfigHandler,
createSetCursorDefaultModelHandler,
@@ -71,12 +65,6 @@ export function createSetupRoutes(): Router {
router.get('/opencode-status', createOpencodeStatusHandler());
router.post('/auth-opencode', createAuthOpencodeHandler());
router.post('/deauth-opencode', createDeauthOpencodeHandler());
// OpenCode Dynamic Model Discovery routes
router.get('/opencode/models', createGetOpencodeModelsHandler());
router.post('/opencode/models/refresh', createRefreshOpencodeModelsHandler());
router.get('/opencode/providers', createGetOpencodeProvidersHandler());
router.post('/opencode/cache/clear', createClearOpencodeCacheHandler());
router.get('/cursor-config', createGetCursorConfigHandler());
router.post('/cursor-config/default-model', createSetCursorDefaultModelHandler());
router.post('/cursor-config/models', createSetCursorModelsHandler());

View File

@@ -1,189 +0,0 @@
/**
* OpenCode Dynamic Models API Routes
*
* Provides endpoints for:
* - GET /api/setup/opencode/models - Get available models (cached or refreshed)
* - POST /api/setup/opencode/models/refresh - Force refresh models from CLI
* - GET /api/setup/opencode/providers - Get authenticated providers
*/
import type { Request, Response } from 'express';
import {
OpencodeProvider,
type OpenCodeProviderInfo,
} from '../../../providers/opencode-provider.js';
import { getErrorMessage, logError } from '../common.js';
import type { ModelDefinition } from '@automaker/types';
import { createLogger } from '@automaker/utils';
const logger = createLogger('OpenCodeModelsRoute');
// Singleton provider instance for caching
let providerInstance: OpencodeProvider | null = null;
function getProvider(): OpencodeProvider {
if (!providerInstance) {
providerInstance = new OpencodeProvider();
}
return providerInstance;
}
/**
* Response type for models endpoint
*/
interface ModelsResponse {
success: boolean;
models?: ModelDefinition[];
count?: number;
cached?: boolean;
error?: string;
}
/**
* Response type for providers endpoint
*/
interface ProvidersResponse {
success: boolean;
providers?: OpenCodeProviderInfo[];
authenticated?: OpenCodeProviderInfo[];
error?: string;
}
/**
* Creates handler for GET /api/setup/opencode/models
*
* Returns currently available models (from cache if available).
* Query params:
* - refresh=true: Force refresh from CLI before returning
*
* Note: If cache is empty, this will trigger a refresh to get dynamic models.
*/
export function createGetOpencodeModelsHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const provider = getProvider();
const forceRefresh = req.query.refresh === 'true';
let models: ModelDefinition[];
let cached = true;
if (forceRefresh) {
models = await provider.refreshModels();
cached = false;
} else {
// Check if we have cached models
const cachedModels = provider.getAvailableModels();
// If cache only has default models (provider.hasCachedModels() would be false),
// trigger a refresh to get dynamic models
if (!provider.hasCachedModels()) {
models = await provider.refreshModels();
cached = false;
} else {
models = cachedModels;
}
}
const response: ModelsResponse = {
success: true,
models,
count: models.length,
cached,
};
res.json(response);
} catch (error) {
logError(error, 'Get OpenCode models failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
} as ModelsResponse);
}
};
}
/**
* Creates handler for POST /api/setup/opencode/models/refresh
*
* Forces a refresh of models from the OpenCode CLI.
*/
export function createRefreshOpencodeModelsHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const provider = getProvider();
const models = await provider.refreshModels();
const response: ModelsResponse = {
success: true,
models,
count: models.length,
cached: false,
};
res.json(response);
} catch (error) {
logError(error, 'Refresh OpenCode models failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
} as ModelsResponse);
}
};
}
/**
* Creates handler for GET /api/setup/opencode/providers
*
* Returns authenticated providers from OpenCode CLI.
* This calls `opencode auth list` to get provider status.
*/
export function createGetOpencodeProvidersHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const provider = getProvider();
const providers = await provider.fetchAuthenticatedProviders();
// Filter to only authenticated providers
const authenticated = providers.filter((p) => p.authenticated);
const response: ProvidersResponse = {
success: true,
providers,
authenticated,
};
res.json(response);
} catch (error) {
logError(error, 'Get OpenCode providers failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
} as ProvidersResponse);
}
};
}
/**
* Creates handler for POST /api/setup/opencode/cache/clear
*
* Clears the model cache, forcing a fresh fetch on next access.
*/
export function createClearOpencodeCacheHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const provider = getProvider();
provider.clearModelCache();
res.json({
success: true,
message: 'OpenCode model cache cleared',
});
} catch (error) {
logError(error, 'Clear OpenCode cache failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -3,51 +3,15 @@
*/
import { createLogger } from '@automaker/utils';
import { spawnProcess } from '@automaker/platform';
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
import { FeatureLoader } from '../../services/feature-loader.js';
const logger = createLogger('Worktree');
export const execAsync = promisify(exec);
// ============================================================================
// Secure Command Execution
// ============================================================================
/**
* Execute git command with array arguments to prevent command injection.
* Uses spawnProcess from @automaker/platform for secure, cross-platform execution.
*
* @param args - Array of git command arguments (e.g., ['worktree', 'add', path])
* @param cwd - Working directory to execute the command in
* @returns Promise resolving to stdout output
* @throws Error with stderr message if command fails
*
* @example
* ```typescript
* // Safe: no injection possible
* await execGitCommand(['branch', '-D', branchName], projectPath);
*
* // Instead of unsafe:
* // await execAsync(`git branch -D ${branchName}`, { cwd });
* ```
*/
export async function execGitCommand(args: string[], cwd: string): Promise<string> {
const result = await spawnProcess({
command: 'git',
args,
cwd,
});
// spawnProcess returns { stdout, stderr, exitCode }
if (result.exitCode === 0) {
return result.stdout;
} else {
const errorMessage = result.stderr || `Git command failed with code ${result.exitCode}`;
throw new Error(errorMessage);
}
}
const featureLoader = new FeatureLoader();
// ============================================================================
// Constants
@@ -135,6 +99,18 @@ export function normalizePath(p: string): string {
return p.replace(/\\/g, '/');
}
/**
* Check if a path is a git repo
*/
export async function isGitRepo(repoPath: string): Promise<boolean> {
try {
await execAsync('git rev-parse --is-inside-work-tree', { cwd: repoPath });
return true;
} catch {
return false;
}
}
/**
* Check if a git repository has at least one commit (i.e., HEAD exists)
* Returns false for freshly initialized repos with no commits

View File

@@ -3,7 +3,6 @@
*/
import { Router } from 'express';
import type { EventEmitter } from '../../lib/events.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { requireValidWorktree, requireValidProject, requireGitRepoOnly } from './middleware.js';
import { createInfoHandler } from './routes/info.js';
@@ -25,22 +24,14 @@ import { createSwitchBranchHandler } from './routes/switch-branch.js';
import {
createOpenInEditorHandler,
createGetDefaultEditorHandler,
createGetAvailableEditorsHandler,
createRefreshEditorsHandler,
} from './routes/open-in-editor.js';
import { createInitGitHandler } from './routes/init-git.js';
import { createMigrateHandler } from './routes/migrate.js';
import { createStartDevHandler } from './routes/start-dev.js';
import { createStopDevHandler } from './routes/stop-dev.js';
import { createListDevServersHandler } from './routes/list-dev-servers.js';
import {
createGetInitScriptHandler,
createPutInitScriptHandler,
createDeleteInitScriptHandler,
createRunInitScriptHandler,
} from './routes/init-script.js';
export function createWorktreeRoutes(events: EventEmitter): Router {
export function createWorktreeRoutes(): Router {
const router = Router();
router.post('/info', validatePathParams('projectPath'), createInfoHandler());
@@ -54,7 +45,7 @@ export function createWorktreeRoutes(events: EventEmitter): Router {
requireValidProject,
createMergeHandler()
);
router.post('/create', validatePathParams('projectPath'), createCreateHandler(events));
router.post('/create', validatePathParams('projectPath'), createCreateHandler());
router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler());
router.post('/create-pr', createCreatePRHandler());
router.post('/pr-info', createPRInfoHandler());
@@ -86,8 +77,6 @@ export function createWorktreeRoutes(events: EventEmitter): Router {
router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler());
router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
router.get('/default-editor', createGetDefaultEditorHandler());
router.get('/available-editors', createGetAvailableEditorsHandler());
router.post('/refresh-editors', createRefreshEditorsHandler());
router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler());
router.post('/migrate', createMigrateHandler());
router.post(
@@ -98,15 +87,5 @@ export function createWorktreeRoutes(events: EventEmitter): Router {
router.post('/stop-dev', createStopDevHandler());
router.post('/list-dev-servers', createListDevServersHandler());
// Init script routes
router.get('/init-script', createGetInitScriptHandler());
router.put('/init-script', validatePathParams('projectPath'), createPutInitScriptHandler());
router.delete('/init-script', validatePathParams('projectPath'), createDeleteInitScriptHandler());
router.post(
'/run-init-script',
validatePathParams('projectPath', 'worktreePath'),
createRunInitScriptHandler(events)
);
return router;
}

View File

@@ -3,8 +3,7 @@
*/
import type { Request, Response, NextFunction } from 'express';
import { isGitRepo } from '@automaker/git-utils';
import { hasCommits } from './common.js';
import { isGitRepo, hasCommits } from './common.js';
interface ValidationOptions {
/** Check if the path is a git repository (default: true) */

View File

@@ -12,19 +12,15 @@ import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js';
import type { EventEmitter } from '../../../lib/events.js';
import { isGitRepo } from '@automaker/git-utils';
import {
isGitRepo,
getErrorMessage,
logError,
normalizePath,
ensureInitialCommit,
isValidBranchName,
execGitCommand,
} from '../common.js';
import { trackBranch } from './branch-tracking.js';
import { createLogger } from '@automaker/utils';
import { runInitScript } from '../../../services/init-script-service.js';
const logger = createLogger('Worktree');
@@ -81,7 +77,7 @@ async function findExistingWorktreeForBranch(
}
}
export function createCreateHandler(events: EventEmitter) {
export function createCreateHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, branchName, baseBranch } = req.body as {
@@ -98,26 +94,6 @@ export function createCreateHandler(events: EventEmitter) {
return;
}
// Validate branch name to prevent command injection
if (!isValidBranchName(branchName)) {
res.status(400).json({
success: false,
error:
'Invalid branch name. Branch names must contain only letters, numbers, dots, hyphens, underscores, and forward slashes.',
});
return;
}
// Validate base branch if provided
if (baseBranch && !isValidBranchName(baseBranch) && baseBranch !== 'HEAD') {
res.status(400).json({
success: false,
error:
'Invalid base branch name. Branch names must contain only letters, numbers, dots, hyphens, underscores, and forward slashes.',
});
return;
}
if (!(await isGitRepo(projectPath))) {
res.status(400).json({
success: false,
@@ -167,28 +143,30 @@ export function createCreateHandler(events: EventEmitter) {
// Create worktrees directory if it doesn't exist
await secureFs.mkdir(worktreesDir, { recursive: true });
// Check if branch exists (using array arguments to prevent injection)
// Check if branch exists
let branchExists = false;
try {
await execGitCommand(['rev-parse', '--verify', branchName], projectPath);
await execAsync(`git rev-parse --verify ${branchName}`, {
cwd: projectPath,
});
branchExists = true;
} catch {
// Branch doesn't exist
}
// Create worktree (using array arguments to prevent injection)
// Create worktree
let createCmd: string;
if (branchExists) {
// Use existing branch
await execGitCommand(['worktree', 'add', worktreePath, branchName], projectPath);
createCmd = `git worktree add "${worktreePath}" ${branchName}`;
} else {
// Create new branch from base or HEAD
const base = baseBranch || 'HEAD';
await execGitCommand(
['worktree', 'add', '-b', branchName, worktreePath, base],
projectPath
);
createCmd = `git worktree add -b ${branchName} "${worktreePath}" ${base}`;
}
await execAsync(createCmd, { cwd: projectPath });
// Note: We intentionally do NOT symlink .automaker to worktrees
// Features and config are always accessed from the main project path
// This avoids symlink loop issues when activating worktrees
@@ -199,8 +177,6 @@ export function createCreateHandler(events: EventEmitter) {
// Resolve to absolute path for cross-platform compatibility
// normalizePath converts to forward slashes for API consistency
const absoluteWorktreePath = path.resolve(worktreePath);
// Respond immediately (non-blocking)
res.json({
success: true,
worktree: {
@@ -209,17 +185,6 @@ export function createCreateHandler(events: EventEmitter) {
isNew: !branchExists,
},
});
// Trigger init script asynchronously after response
// runInitScript internally checks if script exists and hasn't already run
runInitScript({
projectPath,
worktreePath: absoluteWorktreePath,
branch: branchName,
emitter: events,
}).catch((err) => {
logger.error(`Init script failed for ${branchName}:`, err);
});
} catch (error) {
logError(error, 'Create worktree failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });

View File

@@ -6,11 +6,9 @@ import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { isGitRepo } from '@automaker/git-utils';
import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);
const logger = createLogger('Worktree');
export function createDeleteHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -48,28 +46,22 @@ export function createDeleteHandler() {
// Could not get branch name
}
// Remove the worktree (using array arguments to prevent injection)
// Remove the worktree
try {
await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath);
await execAsync(`git worktree remove "${worktreePath}" --force`, {
cwd: projectPath,
});
} catch (error) {
// Try with prune if remove fails
await execGitCommand(['worktree', 'prune'], projectPath);
await execAsync('git worktree prune', { cwd: projectPath });
}
// Optionally delete the branch
let branchDeleted = false;
if (deleteBranch && branchName && branchName !== 'main' && branchName !== 'master') {
// Validate branch name to prevent command injection
if (!isValidBranchName(branchName)) {
logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`);
} else {
try {
await execGitCommand(['branch', '-D', branchName], projectPath);
branchDeleted = true;
} catch {
// Branch deletion failed, not critical
logger.warn(`Failed to delete branch: ${branchName}`);
}
try {
await execAsync(`git branch -D ${branchName}`, { cwd: projectPath });
} catch {
// Branch deletion failed, not critical
}
}
@@ -77,8 +69,7 @@ export function createDeleteHandler() {
success: true,
deleted: {
worktreePath,
branch: branchDeleted ? branchName : null,
branchDeleted,
branch: deleteBranch ? branchName : null,
},
});
} catch (error) {

View File

@@ -1,280 +0,0 @@
/**
* Init Script routes - Read/write/run the worktree-init.sh file
*
* POST /init-script - Read the init script content
* PUT /init-script - Write content to the init script file
* DELETE /init-script - Delete the init script file
* POST /run-init-script - Run the init script for a worktree
*/
import type { Request, Response } from 'express';
import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js';
import { getErrorMessage, logError, isValidBranchName } from '../common.js';
import { createLogger } from '@automaker/utils';
import type { EventEmitter } from '../../../lib/events.js';
import { forceRunInitScript } from '../../../services/init-script-service.js';
const logger = createLogger('InitScript');
/** Fixed path for init script within .automaker directory */
const INIT_SCRIPT_FILENAME = 'worktree-init.sh';
/** Maximum allowed size for init scripts (1MB) */
const MAX_SCRIPT_SIZE_BYTES = 1024 * 1024;
/**
* Get the full path to the init script for a project
*/
function getInitScriptPath(projectPath: string): string {
return path.join(projectPath, '.automaker', INIT_SCRIPT_FILENAME);
}
/**
* GET /init-script - Read the init script content
*/
export function createGetInitScriptHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const rawProjectPath = req.query.projectPath;
// Validate projectPath is a non-empty string (not an array or undefined)
if (!rawProjectPath || typeof rawProjectPath !== 'string') {
res.status(400).json({
success: false,
error: 'projectPath query parameter is required',
});
return;
}
const projectPath = rawProjectPath.trim();
if (!projectPath) {
res.status(400).json({
success: false,
error: 'projectPath cannot be empty',
});
return;
}
const scriptPath = getInitScriptPath(projectPath);
try {
const content = await secureFs.readFile(scriptPath, 'utf-8');
res.json({
success: true,
exists: true,
content: content as string,
path: scriptPath,
});
} catch {
// File doesn't exist
res.json({
success: true,
exists: false,
content: '',
path: scriptPath,
});
}
} catch (error) {
logError(error, 'Read init script failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}
/**
* PUT /init-script - Write content to the init script file
*/
export function createPutInitScriptHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, content } = req.body as {
projectPath: string;
content: string;
};
if (!projectPath) {
res.status(400).json({
success: false,
error: 'projectPath is required',
});
return;
}
if (typeof content !== 'string') {
res.status(400).json({
success: false,
error: 'content must be a string',
});
return;
}
// Validate script size to prevent disk exhaustion
const sizeBytes = Buffer.byteLength(content, 'utf-8');
if (sizeBytes > MAX_SCRIPT_SIZE_BYTES) {
res.status(400).json({
success: false,
error: `Script size (${Math.round(sizeBytes / 1024)}KB) exceeds maximum allowed size (${Math.round(MAX_SCRIPT_SIZE_BYTES / 1024)}KB)`,
});
return;
}
// Log warning if potentially dangerous patterns are detected (non-blocking)
const dangerousPatterns = [
/rm\s+-rf\s+\/(?!\s*\$)/i, // rm -rf / (not followed by variable)
/curl\s+.*\|\s*(?:bash|sh)/i, // curl | bash
/wget\s+.*\|\s*(?:bash|sh)/i, // wget | sh
];
for (const pattern of dangerousPatterns) {
if (pattern.test(content)) {
logger.warn(
`Init script contains potentially dangerous pattern: ${pattern.source}. User responsibility to verify script safety.`
);
}
}
const scriptPath = getInitScriptPath(projectPath);
const automakerDir = path.dirname(scriptPath);
// Ensure .automaker directory exists
await secureFs.mkdir(automakerDir, { recursive: true });
// Write the script content
await secureFs.writeFile(scriptPath, content, 'utf-8');
logger.info(`Wrote init script to ${scriptPath}`);
res.json({
success: true,
path: scriptPath,
});
} catch (error) {
logError(error, 'Write init script failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}
/**
* DELETE /init-script - Delete the init script file
*/
export function createDeleteInitScriptHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as { projectPath: string };
if (!projectPath) {
res.status(400).json({
success: false,
error: 'projectPath is required',
});
return;
}
const scriptPath = getInitScriptPath(projectPath);
await secureFs.rm(scriptPath, { force: true });
logger.info(`Deleted init script at ${scriptPath}`);
res.json({
success: true,
});
} catch (error) {
logError(error, 'Delete init script failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}
/**
* POST /run-init-script - Run (or re-run) the init script for a worktree
*/
export function createRunInitScriptHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, worktreePath, branch } = req.body as {
projectPath: string;
worktreePath: string;
branch: string;
};
if (!projectPath) {
res.status(400).json({
success: false,
error: 'projectPath is required',
});
return;
}
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath is required',
});
return;
}
if (!branch) {
res.status(400).json({
success: false,
error: 'branch is required',
});
return;
}
// Validate branch name to prevent injection via environment variables
if (!isValidBranchName(branch)) {
res.status(400).json({
success: false,
error:
'Invalid branch name. Branch names must contain only letters, numbers, dots, hyphens, underscores, and forward slashes.',
});
return;
}
const scriptPath = getInitScriptPath(projectPath);
// Check if script exists
try {
await secureFs.access(scriptPath);
} catch {
res.status(404).json({
success: false,
error: 'No init script found. Create one in Settings > Worktrees.',
});
return;
}
logger.info(`Running init script for branch "${branch}" (forced)`);
// Run the script asynchronously (non-blocking)
forceRunInitScript({
projectPath,
worktreePath,
branch,
emitter: events,
});
// Return immediately - progress will be streamed via WebSocket events
res.json({
success: true,
message: 'Init script started',
});
} catch (error) {
logError(error, 'Run init script failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -1,40 +1,78 @@
/**
* POST /open-in-editor endpoint - Open a worktree directory in the default code editor
* GET /default-editor endpoint - Get the name of the default code editor
* POST /refresh-editors endpoint - Clear editor cache and re-detect available editors
*
* This module uses @automaker/platform for cross-platform editor detection and launching.
*/
import type { Request, Response } from 'express';
import { isAbsolute } from 'path';
import {
clearEditorCache,
detectAllEditors,
detectDefaultEditor,
openInEditor,
openInFileManager,
} from '@automaker/platform';
import { createLogger } from '@automaker/utils';
import { exec } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('open-in-editor');
const execAsync = promisify(exec);
export function createGetAvailableEditorsHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const editors = await detectAllEditors();
res.json({
success: true,
result: {
editors,
},
});
} catch (error) {
logError(error, 'Get available editors failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
// Editor detection with caching
interface EditorInfo {
name: string;
command: string;
}
let cachedEditor: EditorInfo | null = null;
/**
* Detect which code editor is available on the system
*/
async function detectDefaultEditor(): Promise<EditorInfo> {
// Return cached result if available
if (cachedEditor) {
return cachedEditor;
}
// Try Cursor first (if user has Cursor, they probably prefer it)
try {
await execAsync('which cursor || where cursor');
cachedEditor = { name: 'Cursor', command: 'cursor' };
return cachedEditor;
} catch {
// Cursor not found
}
// Try VS Code
try {
await execAsync('which code || where code');
cachedEditor = { name: 'VS Code', command: 'code' };
return cachedEditor;
} catch {
// VS Code not found
}
// Try Zed
try {
await execAsync('which zed || where zed');
cachedEditor = { name: 'Zed', command: 'zed' };
return cachedEditor;
} catch {
// Zed not found
}
// Try Sublime Text
try {
await execAsync('which subl || where subl');
cachedEditor = { name: 'Sublime Text', command: 'subl' };
return cachedEditor;
} catch {
// Sublime not found
}
// Fallback to file manager
const platform = process.platform;
if (platform === 'darwin') {
cachedEditor = { name: 'Finder', command: 'open' };
} else if (platform === 'win32') {
cachedEditor = { name: 'Explorer', command: 'explorer' };
} else {
cachedEditor = { name: 'File Manager', command: 'xdg-open' };
}
return cachedEditor;
}
export function createGetDefaultEditorHandler() {
@@ -55,41 +93,11 @@ export function createGetDefaultEditorHandler() {
};
}
/**
* Handler to refresh the editor cache and re-detect available editors
* Useful when the user has installed/uninstalled editors
*/
export function createRefreshEditorsHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
// Clear the cache
clearEditorCache();
// Re-detect editors (this will repopulate the cache)
const editors = await detectAllEditors();
logger.info(`Editor cache refreshed, found ${editors.length} editors`);
res.json({
success: true,
result: {
editors,
message: `Found ${editors.length} available editors`,
},
});
} catch (error) {
logError(error, 'Refresh editors failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
export function createOpenInEditorHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, editorCommand } = req.body as {
const { worktreePath } = req.body as {
worktreePath: string;
editorCommand?: string;
};
if (!worktreePath) {
@@ -100,44 +108,42 @@ export function createOpenInEditorHandler() {
return;
}
// Security: Validate that worktreePath is an absolute path
if (!isAbsolute(worktreePath)) {
res.status(400).json({
success: false,
error: 'worktreePath must be an absolute path',
});
return;
}
const editor = await detectDefaultEditor();
try {
// Use the platform utility to open in editor
const result = await openInEditor(worktreePath, editorCommand);
await execAsync(`${editor.command} "${worktreePath}"`);
res.json({
success: true,
result: {
message: `Opened ${worktreePath} in ${result.editorName}`,
editorName: result.editorName,
message: `Opened ${worktreePath} in ${editor.name}`,
editorName: editor.name,
},
});
} catch (editorError) {
// If the specified editor fails, try opening in default file manager as fallback
logger.warn(
`Failed to open in editor, falling back to file manager: ${getErrorMessage(editorError)}`
);
// If the detected editor fails, try opening in default file manager as fallback
const platform = process.platform;
let openCommand: string;
let fallbackName: string;
try {
const result = await openInFileManager(worktreePath);
res.json({
success: true,
result: {
message: `Opened ${worktreePath} in ${result.editorName}`,
editorName: result.editorName,
},
});
} catch (fallbackError) {
// Both editor and file manager failed
throw fallbackError;
if (platform === 'darwin') {
openCommand = `open "${worktreePath}"`;
fallbackName = 'Finder';
} else if (platform === 'win32') {
openCommand = `explorer "${worktreePath}"`;
fallbackName = 'Explorer';
} else {
openCommand = `xdg-open "${worktreePath}"`;
fallbackName = 'File Manager';
}
await execAsync(openCommand);
res.json({
success: true,
result: {
message: `Opened ${worktreePath} in ${fallbackName}`,
editorName: fallbackName,
},
});
}
} catch (error) {
logError(error, 'Open in editor failed');

View File

@@ -308,15 +308,13 @@ export class FeatureLoader {
* @param updates - Partial feature updates
* @param descriptionHistorySource - Source of description change ('enhance' or 'edit')
* @param enhancementMode - Enhancement mode if source is 'enhance'
* @param preEnhancementDescription - Description before enhancement (for restoring original)
*/
async update(
projectPath: string,
featureId: string,
updates: Partial<Feature>,
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
preEnhancementDescription?: string
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
): Promise<Feature> {
const feature = await this.get(projectPath, featureId);
if (!feature) {
@@ -340,31 +338,9 @@ export class FeatureLoader {
updates.description !== feature.description &&
updates.description.trim()
) {
const timestamp = new Date().toISOString();
// If this is an enhancement and we have the pre-enhancement description,
// add the original text to history first (so user can restore to it)
if (
descriptionHistorySource === 'enhance' &&
preEnhancementDescription &&
preEnhancementDescription.trim()
) {
// Check if this pre-enhancement text is different from the last history entry
const lastEntry = updatedHistory[updatedHistory.length - 1];
if (!lastEntry || lastEntry.description !== preEnhancementDescription) {
const preEnhanceEntry: DescriptionHistoryEntry = {
description: preEnhancementDescription,
timestamp,
source: updatedHistory.length === 0 ? 'initial' : 'edit',
};
updatedHistory = [...updatedHistory, preEnhanceEntry];
}
}
// Add the new/enhanced description to history
const historyEntry: DescriptionHistoryEntry = {
description: updates.description,
timestamp,
timestamp: new Date().toISOString(),
source: descriptionHistorySource || 'edit',
...(descriptionHistorySource === 'enhance' && enhancementMode ? { enhancementMode } : {}),
};

View File

@@ -1,360 +0,0 @@
/**
* Init Script Service - Executes worktree initialization scripts
*
* Runs the .automaker/worktree-init.sh script after worktree creation.
* Uses Git Bash on Windows for cross-platform shell script compatibility.
*/
import { spawn } from 'child_process';
import path from 'path';
import { createLogger } from '@automaker/utils';
import { systemPathExists, getShellPaths, findGitBashPath } from '@automaker/platform';
import { findCommand } from '../lib/cli-detection.js';
import type { EventEmitter } from '../lib/events.js';
import { readWorktreeMetadata, writeWorktreeMetadata } from '../lib/worktree-metadata.js';
import * as secureFs from '../lib/secure-fs.js';
const logger = createLogger('InitScript');
export interface InitScriptOptions {
/** Absolute path to the project root */
projectPath: string;
/** Absolute path to the worktree directory */
worktreePath: string;
/** Branch name for this worktree */
branch: string;
/** Event emitter for streaming output */
emitter: EventEmitter;
}
interface ShellCommand {
shell: string;
args: string[];
}
/**
* Init Script Service
*
* Handles execution of worktree initialization scripts with cross-platform
* shell detection and proper streaming of output via WebSocket events.
*/
export class InitScriptService {
private cachedShellCommand: ShellCommand | null | undefined = undefined;
/**
* Get the path to the init script for a project
*/
getInitScriptPath(projectPath: string): string {
return path.join(projectPath, '.automaker', 'worktree-init.sh');
}
/**
* Check if the init script has already been run for a worktree
*/
async hasInitScriptRun(projectPath: string, branch: string): Promise<boolean> {
const metadata = await readWorktreeMetadata(projectPath, branch);
return metadata?.initScriptRan === true;
}
/**
* Find the appropriate shell for running scripts
* Uses findGitBashPath() on Windows to avoid WSL bash, then falls back to PATH
*/
async findShellCommand(): Promise<ShellCommand | null> {
// Return cached result if available
if (this.cachedShellCommand !== undefined) {
return this.cachedShellCommand;
}
if (process.platform === 'win32') {
// On Windows, prioritize Git Bash over WSL bash (C:\Windows\System32\bash.exe)
// WSL bash may not be properly configured and causes ENOENT errors
// First try known Git Bash installation paths
const gitBashPath = await findGitBashPath();
if (gitBashPath) {
logger.debug(`Found Git Bash at: ${gitBashPath}`);
this.cachedShellCommand = { shell: gitBashPath, args: [] };
return this.cachedShellCommand;
}
// Fall back to finding bash in PATH, but skip WSL bash
const bashInPath = await findCommand(['bash']);
if (bashInPath && !bashInPath.toLowerCase().includes('system32')) {
logger.debug(`Found bash in PATH at: ${bashInPath}`);
this.cachedShellCommand = { shell: bashInPath, args: [] };
return this.cachedShellCommand;
}
logger.warn('Git Bash not found. WSL bash was skipped to avoid compatibility issues.');
this.cachedShellCommand = null;
return null;
}
// Unix-like systems: use getShellPaths() and check existence
const shellPaths = getShellPaths();
const posixShells = shellPaths.filter(
(p) => p.includes('bash') || p === '/bin/sh' || p === '/usr/bin/sh'
);
for (const shellPath of posixShells) {
try {
if (systemPathExists(shellPath)) {
this.cachedShellCommand = { shell: shellPath, args: [] };
return this.cachedShellCommand;
}
} catch {
// Path not allowed or doesn't exist, continue
}
}
// Ultimate fallback
if (systemPathExists('/bin/sh')) {
this.cachedShellCommand = { shell: '/bin/sh', args: [] };
return this.cachedShellCommand;
}
this.cachedShellCommand = null;
return null;
}
/**
* Run the worktree initialization script
* Non-blocking - returns immediately after spawning
*/
async runInitScript(options: InitScriptOptions): Promise<void> {
const { projectPath, worktreePath, branch, emitter } = options;
const scriptPath = this.getInitScriptPath(projectPath);
// Check if script exists using secureFs (respects ALLOWED_ROOT_DIRECTORY)
try {
await secureFs.access(scriptPath);
} catch {
logger.debug(`No init script found at ${scriptPath}`);
return;
}
// Check if already run
if (await this.hasInitScriptRun(projectPath, branch)) {
logger.info(`Init script already ran for branch "${branch}", skipping`);
return;
}
// Get shell command
const shellCmd = await this.findShellCommand();
if (!shellCmd) {
const error =
process.platform === 'win32'
? 'Git Bash not found. Please install Git for Windows to run init scripts.'
: 'No shell found (/bin/bash or /bin/sh)';
logger.error(error);
// Update metadata with error, preserving existing metadata
const existingMetadata = await readWorktreeMetadata(projectPath, branch);
await writeWorktreeMetadata(projectPath, branch, {
branch,
createdAt: existingMetadata?.createdAt || new Date().toISOString(),
pr: existingMetadata?.pr,
initScriptRan: true,
initScriptStatus: 'failed',
initScriptError: error,
});
emitter.emit('worktree:init-completed', {
projectPath,
worktreePath,
branch,
success: false,
error,
});
return;
}
logger.info(`Running init script for branch "${branch}" in ${worktreePath}`);
logger.debug(`Using shell: ${shellCmd.shell}`);
// Update metadata to mark as running
const existingMetadata = await readWorktreeMetadata(projectPath, branch);
await writeWorktreeMetadata(projectPath, branch, {
branch,
createdAt: existingMetadata?.createdAt || new Date().toISOString(),
pr: existingMetadata?.pr,
initScriptRan: false,
initScriptStatus: 'running',
});
// Emit started event
emitter.emit('worktree:init-started', {
projectPath,
worktreePath,
branch,
});
// Build safe environment - only pass necessary variables, not all of process.env
// This prevents exposure of sensitive credentials like ANTHROPIC_API_KEY
const safeEnv: Record<string, string> = {
// Automaker-specific variables
AUTOMAKER_PROJECT_PATH: projectPath,
AUTOMAKER_WORKTREE_PATH: worktreePath,
AUTOMAKER_BRANCH: branch,
// Essential system variables
PATH: process.env.PATH || '',
HOME: process.env.HOME || '',
USER: process.env.USER || '',
TMPDIR: process.env.TMPDIR || process.env.TEMP || process.env.TMP || '/tmp',
// Shell and locale
SHELL: process.env.SHELL || '',
LANG: process.env.LANG || 'en_US.UTF-8',
LC_ALL: process.env.LC_ALL || '',
// Force color output even though we're not a TTY
FORCE_COLOR: '1',
npm_config_color: 'always',
CLICOLOR_FORCE: '1',
// Git configuration
GIT_TERMINAL_PROMPT: '0',
};
// Platform-specific additions
if (process.platform === 'win32') {
safeEnv.USERPROFILE = process.env.USERPROFILE || '';
safeEnv.APPDATA = process.env.APPDATA || '';
safeEnv.LOCALAPPDATA = process.env.LOCALAPPDATA || '';
safeEnv.SystemRoot = process.env.SystemRoot || 'C:\\Windows';
safeEnv.TEMP = process.env.TEMP || '';
}
// Spawn the script with safe environment
const child = spawn(shellCmd.shell, [...shellCmd.args, scriptPath], {
cwd: worktreePath,
env: safeEnv,
stdio: ['ignore', 'pipe', 'pipe'],
});
// Stream stdout
child.stdout?.on('data', (data: Buffer) => {
const content = data.toString();
emitter.emit('worktree:init-output', {
projectPath,
branch,
type: 'stdout',
content,
});
});
// Stream stderr
child.stderr?.on('data', (data: Buffer) => {
const content = data.toString();
emitter.emit('worktree:init-output', {
projectPath,
branch,
type: 'stderr',
content,
});
});
// Handle completion
child.on('exit', async (code) => {
const success = code === 0;
const status = success ? 'success' : 'failed';
logger.info(`Init script for branch "${branch}" ${status} with exit code ${code}`);
// Update metadata
const metadata = await readWorktreeMetadata(projectPath, branch);
await writeWorktreeMetadata(projectPath, branch, {
branch,
createdAt: metadata?.createdAt || new Date().toISOString(),
pr: metadata?.pr,
initScriptRan: true,
initScriptStatus: status,
initScriptError: success ? undefined : `Exit code: ${code}`,
});
// Emit completion event
emitter.emit('worktree:init-completed', {
projectPath,
worktreePath,
branch,
success,
exitCode: code,
});
});
child.on('error', async (error) => {
logger.error(`Init script error for branch "${branch}":`, error);
// Update metadata
const metadata = await readWorktreeMetadata(projectPath, branch);
await writeWorktreeMetadata(projectPath, branch, {
branch,
createdAt: metadata?.createdAt || new Date().toISOString(),
pr: metadata?.pr,
initScriptRan: true,
initScriptStatus: 'failed',
initScriptError: error.message,
});
// Emit completion with error
emitter.emit('worktree:init-completed', {
projectPath,
worktreePath,
branch,
success: false,
error: error.message,
});
});
}
/**
* Force re-run the worktree initialization script
* Ignores the initScriptRan flag - useful for testing or re-setup
*/
async forceRunInitScript(options: InitScriptOptions): Promise<void> {
const { projectPath, branch } = options;
// Reset the initScriptRan flag so the script will run
const metadata = await readWorktreeMetadata(projectPath, branch);
if (metadata) {
await writeWorktreeMetadata(projectPath, branch, {
...metadata,
initScriptRan: false,
initScriptStatus: undefined,
initScriptError: undefined,
});
}
// Now run the script
await this.runInitScript(options);
}
}
// Singleton instance for convenience
let initScriptService: InitScriptService | null = null;
/**
* Get the singleton InitScriptService instance
*/
export function getInitScriptService(): InitScriptService {
if (!initScriptService) {
initScriptService = new InitScriptService();
}
return initScriptService;
}
// Export convenience functions that use the singleton
export const getInitScriptPath = (projectPath: string) =>
getInitScriptService().getInitScriptPath(projectPath);
export const hasInitScriptRun = (projectPath: string, branch: string) =>
getInitScriptService().hasInitScriptRun(projectPath, branch);
export const runInitScript = (options: InitScriptOptions) =>
getInitScriptService().runInitScript(options);
export const forceRunInitScript = (options: InitScriptOptions) =>
getInitScriptService().forceRunInitScript(options);

View File

@@ -17,14 +17,6 @@ import {
type EnhancementMode,
} from '@/lib/enhancement-prompts.js';
const ENHANCEMENT_MODES: EnhancementMode[] = [
'improve',
'technical',
'simplify',
'acceptance',
'ux-reviewer',
];
describe('enhancement-prompts.ts', () => {
describe('System Prompt Constants', () => {
it('should have non-empty improve system prompt', () => {
@@ -192,7 +184,8 @@ describe('enhancement-prompts.ts', () => {
});
it('should work with all enhancement modes', () => {
ENHANCEMENT_MODES.forEach((mode) => {
const modes: EnhancementMode[] = ['improve', 'technical', 'simplify', 'acceptance'];
modes.forEach((mode) => {
const prompt = buildUserPrompt(mode, testText);
expect(prompt).toContain(testText);
expect(prompt.length).toBeGreaterThan(100);
@@ -212,7 +205,6 @@ describe('enhancement-prompts.ts', () => {
expect(isValidEnhancementMode('technical')).toBe(true);
expect(isValidEnhancementMode('simplify')).toBe(true);
expect(isValidEnhancementMode('acceptance')).toBe(true);
expect(isValidEnhancementMode('ux-reviewer')).toBe(true);
});
it('should return false for invalid modes', () => {
@@ -224,12 +216,13 @@ describe('enhancement-prompts.ts', () => {
});
describe('getAvailableEnhancementModes', () => {
it('should return all enhancement modes', () => {
it('should return all four enhancement modes', () => {
const modes = getAvailableEnhancementModes();
expect(modes).toHaveLength(ENHANCEMENT_MODES.length);
ENHANCEMENT_MODES.forEach((mode) => {
expect(modes).toContain(mode);
});
expect(modes).toHaveLength(4);
expect(modes).toContain('improve');
expect(modes).toContain('technical');
expect(modes).toContain('simplify');
expect(modes).toContain('acceptance');
});
it('should return an array', () => {

View File

@@ -3,7 +3,7 @@ import {
OpencodeProvider,
resetToolUseIdCounter,
} from '../../../src/providers/opencode-provider.js';
import type { ProviderMessage, ModelDefinition } from '@automaker/types';
import type { ProviderMessage } from '@automaker/types';
import { collectAsyncGenerator } from '../../utils/helpers.js';
import { spawnJSONLProcess, getOpenCodeAuthIndicators } from '@automaker/platform';
@@ -51,38 +51,63 @@ describe('opencode-provider.ts', () => {
});
describe('getAvailableModels', () => {
it('should return 5 models', () => {
it('should return 10 models', () => {
const models = provider.getAvailableModels();
expect(models).toHaveLength(5);
expect(models).toHaveLength(10);
});
it('should include Big Pickle as default', () => {
it('should include Claude Sonnet 4.5 (Bedrock) as default', () => {
const models = provider.getAvailableModels();
const sonnet = models.find(
(m) => m.id === 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0'
);
expect(sonnet).toBeDefined();
expect(sonnet?.name).toBe('Claude Sonnet 4.5 (Bedrock)');
expect(sonnet?.provider).toBe('opencode');
expect(sonnet?.default).toBe(true);
expect(sonnet?.modelString).toBe('amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0');
});
it('should include Claude Opus 4.5 (Bedrock)', () => {
const models = provider.getAvailableModels();
const opus = models.find(
(m) => m.id === 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0'
);
expect(opus).toBeDefined();
expect(opus?.name).toBe('Claude Opus 4.5 (Bedrock)');
expect(opus?.modelString).toBe('amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0');
});
it('should include Claude Haiku 4.5 (Bedrock)', () => {
const models = provider.getAvailableModels();
const haiku = models.find(
(m) => m.id === 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0'
);
expect(haiku).toBeDefined();
expect(haiku?.name).toBe('Claude Haiku 4.5 (Bedrock)');
expect(haiku?.tier).toBe('standard');
});
it('should include free tier Big Pickle model', () => {
const models = provider.getAvailableModels();
const bigPickle = models.find((m) => m.id === 'opencode/big-pickle');
expect(bigPickle).toBeDefined();
expect(bigPickle?.name).toBe('Big Pickle (Free)');
expect(bigPickle?.provider).toBe('opencode');
expect(bigPickle?.default).toBe(true);
expect(bigPickle?.modelString).toBe('opencode/big-pickle');
expect(bigPickle?.tier).toBe('basic');
});
it('should include free tier GLM model', () => {
it('should include DeepSeek R1 (Bedrock)', () => {
const models = provider.getAvailableModels();
const glm = models.find((m) => m.id === 'opencode/glm-4.7-free');
const deepseek = models.find((m) => m.id === 'amazon-bedrock/deepseek.r1-v1:0');
expect(glm).toBeDefined();
expect(glm?.name).toBe('GLM 4.7 Free');
expect(glm?.tier).toBe('basic');
});
it('should include free tier MiniMax model', () => {
const models = provider.getAvailableModels();
const minimax = models.find((m) => m.id === 'opencode/minimax-m2.1-free');
expect(minimax).toBeDefined();
expect(minimax?.name).toBe('MiniMax M2.1 Free');
expect(minimax?.tier).toBe('basic');
expect(deepseek).toBeDefined();
expect(deepseek?.name).toBe('DeepSeek R1 (Bedrock)');
expect(deepseek?.tier).toBe('premium');
});
it('should have all models support tools', () => {
@@ -103,24 +128,6 @@ describe('opencode-provider.ts', () => {
});
});
describe('parseModelsOutput', () => {
it('should parse nested provider model IDs', () => {
const output = ['openrouter/anthropic/claude-3.5-sonnet', 'openai/gpt-4o'].join('\n');
const parseModelsOutput = (
provider as unknown as { parseModelsOutput: (output: string) => ModelDefinition[] }
).parseModelsOutput.bind(provider);
const models = parseModelsOutput(output);
expect(models).toHaveLength(2);
const openrouterModel = models.find((model) => model.id.startsWith('openrouter/'));
expect(openrouterModel).toBeDefined();
expect(openrouterModel?.provider).toBe('openrouter');
expect(openrouterModel?.modelString).toBe('openrouter/anthropic/claude-3.5-sonnet');
});
});
describe('supportsFeature', () => {
it("should support 'tools' feature", () => {
expect(provider.supportsFeature('tools')).toBe(true);
@@ -1236,7 +1243,7 @@ describe('opencode-provider.ts', () => {
const defaultModels = models.filter((m) => m.default === true);
expect(defaultModels).toHaveLength(1);
expect(defaultModels[0].id).toBe('opencode/big-pickle');
expect(defaultModels[0].id).toBe('amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0');
});
it('should have valid tier values for all models', () => {

View File

@@ -5,61 +5,59 @@ import {
getSpecRegenerationStatus,
} from '@/routes/app-spec/common.js';
const TEST_PROJECT_PATH = '/tmp/automaker-test-project';
describe('app-spec/common.ts', () => {
beforeEach(() => {
// Reset state before each test
setRunningState(TEST_PROJECT_PATH, false, null);
setRunningState(false, null);
});
describe('setRunningState', () => {
it('should set isRunning to true when running is true', () => {
setRunningState(TEST_PROJECT_PATH, true);
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).isRunning).toBe(true);
setRunningState(true);
expect(getSpecRegenerationStatus().isRunning).toBe(true);
});
it('should set isRunning to false when running is false', () => {
setRunningState(TEST_PROJECT_PATH, true);
setRunningState(TEST_PROJECT_PATH, false);
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).isRunning).toBe(false);
setRunningState(true);
setRunningState(false);
expect(getSpecRegenerationStatus().isRunning).toBe(false);
});
it('should set currentAbortController when provided', () => {
const controller = new AbortController();
setRunningState(TEST_PROJECT_PATH, true, controller);
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(controller);
setRunningState(true, controller);
expect(getSpecRegenerationStatus().currentAbortController).toBe(controller);
});
it('should set currentAbortController to null when not provided', () => {
const controller = new AbortController();
setRunningState(TEST_PROJECT_PATH, true, controller);
setRunningState(TEST_PROJECT_PATH, false);
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(null);
setRunningState(true, controller);
setRunningState(false);
expect(getSpecRegenerationStatus().currentAbortController).toBe(null);
});
it('should keep currentAbortController when explicitly passed null while running', () => {
it('should set currentAbortController to null when explicitly passed null', () => {
const controller = new AbortController();
setRunningState(TEST_PROJECT_PATH, true, controller);
setRunningState(TEST_PROJECT_PATH, true, null);
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(controller);
setRunningState(true, controller);
setRunningState(true, null);
expect(getSpecRegenerationStatus().currentAbortController).toBe(null);
});
it('should update state multiple times correctly', () => {
const controller1 = new AbortController();
const controller2 = new AbortController();
setRunningState(TEST_PROJECT_PATH, true, controller1);
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).isRunning).toBe(true);
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(controller1);
setRunningState(true, controller1);
expect(getSpecRegenerationStatus().isRunning).toBe(true);
expect(getSpecRegenerationStatus().currentAbortController).toBe(controller1);
setRunningState(TEST_PROJECT_PATH, true, controller2);
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).isRunning).toBe(true);
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(controller2);
setRunningState(true, controller2);
expect(getSpecRegenerationStatus().isRunning).toBe(true);
expect(getSpecRegenerationStatus().currentAbortController).toBe(controller2);
setRunningState(TEST_PROJECT_PATH, false, null);
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).isRunning).toBe(false);
expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(null);
setRunningState(false, null);
expect(getSpecRegenerationStatus().isRunning).toBe(false);
expect(getSpecRegenerationStatus().currentAbortController).toBe(null);
});
});

View File

@@ -8,7 +8,6 @@ import fs from 'fs/promises';
vi.mock('child_process', () => ({
spawn: vi.fn(),
execSync: vi.fn(),
execFile: vi.fn(),
}));
// Mock secure-fs

View File

@@ -1,6 +1,6 @@
{
"name": "@automaker/ui",
"version": "0.10.0",
"version": "0.9.0",
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
"homepage": "https://github.com/AutoMaker-Org/automaker",
"repository": {
@@ -42,8 +42,6 @@
"@automaker/dependency-resolver": "1.0.0",
"@automaker/types": "1.0.0",
"@codemirror/lang-xml": "6.1.0",
"@codemirror/language": "^6.12.1",
"@codemirror/legacy-modes": "^6.5.2",
"@codemirror/theme-one-dark": "6.1.3",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0",

View File

@@ -1,220 +0,0 @@
import type { ComponentType, ComponentProps } from 'react';
import { FolderOpen } from 'lucide-react';
type IconProps = ComponentProps<'svg'>;
type IconComponent = ComponentType<IconProps>;
const ANTIGRAVITY_COMMANDS = ['antigravity', 'agy'] as const;
const [PRIMARY_ANTIGRAVITY_COMMAND, LEGACY_ANTIGRAVITY_COMMAND] = ANTIGRAVITY_COMMANDS;
/**
* Cursor editor logo icon - from LobeHub icons
*/
export function CursorIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M22.106 5.68L12.5.135a.998.998 0 00-.998 0L1.893 5.68a.84.84 0 00-.419.726v11.186c0 .3.16.577.42.727l9.607 5.547a.999.999 0 00.998 0l9.608-5.547a.84.84 0 00.42-.727V6.407a.84.84 0 00-.42-.726zm-.603 1.176L12.228 22.92c-.063.108-.228.064-.228-.061V12.34a.59.59 0 00-.295-.51l-9.11-5.26c-.107-.062-.063-.228.062-.228h18.55c.264 0 .428.286.296.514z" />
</svg>
);
}
/**
* VS Code editor logo icon
*/
export function VSCodeIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M23.15 2.587L18.21.21a1.494 1.494 0 0 0-1.705.29l-9.46 8.63-4.12-3.128a.999.999 0 0 0-1.276.057L.327 7.261A1 1 0 0 0 .326 8.74L3.899 12 .326 15.26a1 1 0 0 0 .001 1.479L1.65 17.94a.999.999 0 0 0 1.276.057l4.12-3.128 9.46 8.63a1.492 1.492 0 0 0 1.704.29l4.942-2.377A1.5 1.5 0 0 0 24 20.06V3.939a1.5 1.5 0 0 0-.85-1.352zm-5.146 14.861L10.826 12l7.178-5.448v10.896z" />
</svg>
);
}
/**
* VS Code Insiders editor logo icon (same as VS Code)
*/
export function VSCodeInsidersIcon(props: IconProps) {
return <VSCodeIcon {...props} />;
}
/**
* Kiro editor logo icon (VS Code fork)
*/
export function KiroIcon(props: IconProps) {
return (
<svg viewBox="0 0 32 32" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M6.594.016A7.006 7.006 0 0 0 .742 3.875a6.996 6.996 0 0 0-.726 2.793C.004 6.878 0 9.93.004 16.227c.004 8.699.008 9.265.031 9.476.113.93.324 1.652.707 2.422a6.918 6.918 0 0 0 3.172 3.148c.75.372 1.508.59 2.398.692.227.027.77.027 9.688.027 8.945 0 9.457 0 9.688-.027.917-.106 1.66-.32 2.437-.707a6.918 6.918 0 0 0 3.148-3.172c.372-.75.59-1.508.692-2.398.027-.227.027-.77.027-9.665 0-9.976.004-9.53-.07-10.03a6.993 6.993 0 0 0-3.024-4.798 6.427 6.427 0 0 0-.757-.445 7.06 7.06 0 0 0-2.774-.734c-.328-.02-18.437-.02-18.773 0Zm10.789 5.406a7.556 7.556 0 0 1 6.008 3.805c.148.257.406.796.52 1.085.394 1 .632 2.157.769 3.75.035.38.05 1.965.023 2.407-.125 2.168-.625 4.183-1.515 6.078a9.77 9.77 0 0 1-.801 1.437c-.93 1.305-2.32 2.332-3.48 2.57-.895.184-1.602-.1-2.048-.827a3.42 3.42 0 0 1-.25-.528c-.035-.097-.062-.129-.086-.09-.003.008-.09.075-.191.153-.95.722-2.02 1.175-3.059 1.293-.273.03-.859.023-1.085-.016-.715-.121-1.286-.441-1.649-.93a2.563 2.563 0 0 1-.328-.632c-.117-.36-.156-.813-.117-1.227.054-.55.226-1.184.484-1.766a.48.48 0 0 0 .043-.117 2.11 2.11 0 0 0-.137.055c-.363.16-.898.305-1.308.351-.844.098-1.426-.14-1.715-.699-.106-.203-.149-.39-.16-.676-.008-.261.008-.43.066-.656.059-.23.121-.367.403-.89.382-.72.492-.946.636-1.348.328-.899.48-1.723.688-3.754.148-1.469.254-2.14.433-2.766.028-.09.078-.277.114-.414.796-3.074 3.113-5.183 6.148-5.601.129-.016.309-.04.399-.047.238-.016.96-.02 1.195 0Zm0 0" />
<path d="M16.754 11.336a.815.815 0 0 0-.375.219c-.176.18-.293.441-.356.804-.039.235-.058.602-.039.868.028.406.082.64.204.894.128.262.304.426.546.496.106.031.383.031.5 0 .422-.113.703-.531.801-1.191a4.822 4.822 0 0 0-.012-.95c-.062-.378-.183-.675-.359-.863a.808.808 0 0 0-.648-.293.804.804 0 0 0-.262.016ZM20.375 11.328a1.01 1.01 0 0 0-.363.188c-.164.144-.293.402-.364.718-.05.23-.07.426-.07.743 0 .32.02.511.07.742.11.496.352.808.688.898.121.031.379.031.5 0 .402-.105.68-.5.781-1.11.035-.198.047-.648.024-.87-.063-.63-.293-1.059-.649-1.23a1.513 1.513 0 0 0-.219-.079 1.362 1.362 0 0 0-.398 0Zm0 0" />
</svg>
);
}
/**
* Zed editor logo icon (from Simple Icons)
*/
export function ZedIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M2.25 1.5a.75.75 0 0 0-.75.75v16.5H0V2.25A2.25 2.25 0 0 1 2.25 0h20.095c1.002 0 1.504 1.212.795 1.92L10.764 14.298h3.486V12.75h1.5v1.922a1.125 1.125 0 0 1-1.125 1.125H9.264l-2.578 2.578h11.689V9h1.5v9.375a1.5 1.5 0 0 1-1.5 1.5H5.185L2.562 22.5H21.75a.75.75 0 0 0 .75-.75V5.25H24v16.5A2.25 2.25 0 0 1 21.75 24H1.655C.653 24 .151 22.788.86 22.08L13.19 9.75H9.75v1.5h-1.5V9.375A1.125 1.125 0 0 1 9.375 8.25h5.314l2.625-2.625H5.625V15h-1.5V5.625a1.5 1.5 0 0 1 1.5-1.5h13.19L21.438 1.5z" />
</svg>
);
}
/**
* Sublime Text editor logo icon
*/
export function SublimeTextIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M20.953.004a.397.397 0 0 0-.18.045L3.473 8.63a.397.397 0 0 0-.033.69l4.873 3.33-5.26 2.882a.397.397 0 0 0-.006.692l17.3 9.73a.397.397 0 0 0 .593-.344V15.094a.397.397 0 0 0-.203-.346l-4.917-2.763 5.233-2.725a.397.397 0 0 0 .207-.348V.397a.397.397 0 0 0-.307-.393z" />
</svg>
);
}
/**
* macOS Finder icon
*/
export function FinderIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M2.5 3A2.5 2.5 0 0 0 0 5.5v13A2.5 2.5 0 0 0 2.5 21h19a2.5 2.5 0 0 0 2.5-2.5v-13A2.5 2.5 0 0 0 21.5 3h-19zM7 8.5a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3zm10 0a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3zm-9 6c0-.276.336-.5.75-.5h6.5c.414 0 .75.224.75.5v1c0 .828-1.343 2.5-4 2.5s-4-1.672-4-2.5v-1z" />
</svg>
);
}
/**
* Windsurf editor logo icon (by Codeium) - from LobeHub icons
*/
export function WindsurfIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M23.78 5.004h-.228a2.187 2.187 0 00-2.18 2.196v4.912c0 .98-.804 1.775-1.76 1.775a1.818 1.818 0 01-1.472-.773L13.168 5.95a2.197 2.197 0 00-1.81-.95c-1.134 0-2.154.972-2.154 2.173v4.94c0 .98-.797 1.775-1.76 1.775-.57 0-1.136-.289-1.472-.773L.408 5.098C.282 4.918 0 5.007 0 5.228v4.284c0 .216.066.426.188.604l5.475 7.889c.324.466.8.812 1.351.938 1.377.316 2.645-.754 2.645-2.117V11.89c0-.98.787-1.775 1.76-1.775h.002c.586 0 1.135.288 1.472.773l4.972 7.163a2.15 2.15 0 001.81.95c1.158 0 2.151-.973 2.151-2.173v-4.939c0-.98.787-1.775 1.76-1.775h.194c.122 0 .22-.1.22-.222V5.225a.221.221 0 00-.22-.222z"
/>
</svg>
);
}
/**
* Trae editor logo icon (by ByteDance) - from LobeHub icons
*/
export function TraeIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M24 20.541H3.428v-3.426H0V3.4h24V20.54zM3.428 17.115h17.144V6.827H3.428v10.288zm8.573-5.196l-2.425 2.424-2.424-2.424 2.424-2.424 2.425 2.424zm6.857-.001l-2.424 2.423-2.425-2.423 2.425-2.425 2.424 2.425z" />
</svg>
);
}
/**
* JetBrains Rider logo icon
*/
export function RiderIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M0 0v24h24V0zm7.031 3.113A4.063 4.063 0 0 1 9.72 4.14a3.23 3.23 0 0 1 .84 2.28A3.16 3.16 0 0 1 8.4 9.54l2.46 3.6H8.28L6.12 9.9H4.38v3.24H2.16V3.12c1.61-.004 3.281.009 4.871-.007zm5.509.007h3.96c3.18 0 5.34 2.16 5.34 5.04 0 2.82-2.16 5.04-5.34 5.04h-3.96zm4.069 1.976c-.607.01-1.235.004-1.849.004v6.06h1.74a2.882 2.882 0 0 0 3.06-3 2.897 2.897 0 0 0-2.951-3.064zM4.319 5.1v2.88H6.6c1.08 0 1.68-.6 1.68-1.44 0-.96-.66-1.44-1.74-1.44zM2.16 19.5h9V21h-9Z" />
</svg>
);
}
/**
* JetBrains WebStorm logo icon
*/
export function WebStormIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M0 0v24h24V0H0zm17.889 2.889c1.444 0 2.667.444 3.667 1.278l-1.111 1.667c-.889-.611-1.722-1-2.556-1s-1.278.389-1.278.889v.056c0 .667.444.889 2.111 1.333 2 .556 3.111 1.278 3.111 3v.056c0 2-1.5 3.111-3.611 3.111-1.5-.056-3-.611-4.167-1.667l1.278-1.556c.889.722 1.833 1.222 2.944 1.222.889 0 1.389-.333 1.389-.944v-.056c0-.556-.333-.833-2-1.278-2-.5-3.222-1.056-3.222-3.056v-.056c0-1.833 1.444-3 3.444-3zm-16.111.222h2.278l1.5 5.778 1.722-5.778h1.667l1.667 5.778 1.5-5.778h2.333l-2.833 9.944H9.723L8.112 7.277l-1.667 5.778H4.612L1.779 3.111zm.5 16.389h9V21h-9v-1.5z" />
</svg>
);
}
/**
* Xcode logo icon
*/
export function XcodeIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M19.06 5.3327c.4517-.1936.7744-.2581 1.097-.1936.5163.1291.7744.5163.968.7098.1936.3872.9034.7744 1.2261.8389.2581.0645.7098-.6453 1.0325-1.2906.3227-.5808.5163-1.3552.4517-1.5488-.0645-.1936-.968-.5808-1.1616-.5808-.1291 0-.3872.1291-.8389.0645-.4517-.0645-.9034-.5808-1.1616-.968-.4517-.6453-1.097-1.0325-1.6778-1.3552-.6453-.3227-1.3552-.5163-2.065-.6453-1.0325-.2581-2.065-.4517-3.0975-.3227-.5808.0645-1.2906.1291-1.8069.3227-.0645 0-.1936.1936-.0645.1936s.5808.0645.5808.0645-.5807.1292-.5807.2583c0 .1291.0645.1291.1291.1291.0645 0 1.4842-.0645 2.065 0 .6453.1291 1.3552.4517 1.8069 1.2261.7744 1.4197.4517 2.7749.2581 3.2266-.968 2.1295-8.6472 15.2294-9.0344 16.1328-.3873.9034-.5163 1.4842.5807 2.065s1.6778.3227 2.0005-.0645c.3872-.5163 7.0339-17.1654 9.2925-18.2624zm-3.6138 8.7117h1.5488c1.0325 0 1.2261.5163 1.2261.7098.0645.5163-.1936 1.1616-1.2261 1.1616h-.968l.7744 1.2906c.4517.7744.2581 1.1616 0 1.4197-.3872.3872-1.2261.3872-1.6778-.4517l-.9034-1.5488c-.6453 1.4197-1.2906 2.9684-2.065 4.7753h4.0009c1.9359 0 3.5492-1.6133 3.5492-3.5492V6.5588c-.0645-.1291-.1936-.0645-.2581 0-.3872.4517-1.4842 2.0004-4.001 7.4856zm-9.8087 8.0019h-.3227c-2.3231 0-4.1945-1.8714-4.1945-4.1945V7.0105c0-2.3231 1.8714-4.1945 4.1945-4.1945h9.3571c-.1936-.1936-.968-.5163-1.7423-.4517-.3227 0-.968.1291-1.3552-.1291-.3872-.3227-.3227-.5163-.9034-.5163H4.9277c-2.6458 0-4.7753 2.1295-4.7753 4.7753v11.7447c0 2.6458 2.1295 4.7753 4.4527 4.7108.6452 0 .8388-.5162 1.0324-.9034zM20.4152 6.9459v10.9058c0 2.3231-1.8714 4.1945-4.1945 4.1945H11.897s-.3872 1.0325.8389 1.0325h3.8719c2.6458 0 4.7753-2.1295 4.7753-4.7753V8.8173c.0646-.9034-.7098-1.4842-.9679-1.8714zm-18.5851.0646v10.8413c0 1.9359 1.6133 3.5492 3.5492 3.5492h.5808c0-.0645.7744-1.4197 2.4522-4.2591.1936-.3872.4517-.7744.7098-1.2261H4.4114c-.5808 0-.9034-.3872-.968-.7098-.1291-.5163.1936-1.1616.9034-1.1616h2.3877l3.033-5.2916s-.7098-1.2906-.9034-1.6133c-.2582-.4517-.1291-.9034.129-1.1615.3872-.3872 1.0325-.5808 1.6778.4517l.2581.3872.2581-.3872c.5808-.8389.968-.7744 1.2906-.7098.5163.1291.8389.7098.3872 1.6133L8.864 14.0444h1.3552c.4517-.7744.9034-1.5488 1.3552-2.3877-.0645-.3227-.1291-.7098-.0645-1.0325.0645-.5163.3227-.968.6453-1.3552l.3872.6453c1.2261-2.1295 2.1295-3.9364 2.3877-4.6463.1291-.3872.3227-1.1616.1291-1.8069H5.3794c-2.0005.0001-3.5493 1.6134-3.5493 3.5494zM4.605 17.7872c0-.0645.7744-1.4197.7744-1.4197 1.2261-.3227 1.8069.4517 1.8714.5163 0 0-.8389 1.4842-1.097 1.7423s-.5808.3227-.9034.2581c-.5164-.129-.839-.6453-.6454-1.097z" />
</svg>
);
}
/**
* Android Studio logo icon
*/
export function AndroidStudioIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M19.2693 10.3368c-.3321 0-.6026.2705-.6026.6031v9.8324h-1.7379l-3.3355-6.9396c.476-.5387.6797-1.286.5243-2.0009a2.2862 2.2862 0 0 0-1.2893-1.6248v-.8124c.0121-.2871-.1426-.5787-.4043-.7407-.1391-.0825-.2884-.1234-.4402-.1234a.8478.8478 0 0 0-.4318.1182c-.2701.1671-.4248.4587-.4123.7662l-.0003.721c-1.0149.3668-1.6619 1.4153-1.4867 2.5197a2.282 2.282 0 0 0 .5916 1.2103l-3.2096 6.9064H4.0928c-1.0949-.007-1.9797-.8948-1.9832-1.9896V5.016c-.0055 1.1024.8836 2.0006 1.9859 2.0062a2.024 2.024 0 0 0 .1326-.0037h14.7453s2.5343-.2189 2.8619 1.5392c-.2491.0287-.4449.2321-.4449.4889 0 .7115-.5791 1.2901-1.3028 1.2901h-.8183zM17.222 22.5366c.2347.4837.0329 1.066-.4507 1.3007-.1296.0629-.2666.0895-.4018.0927a.9738.9738 0 0 1-.3194-.0455c-.024-.0078-.046-.0209-.0694-.0305a.9701.9701 0 0 1-.2277-.1321c-.0247-.0192-.0495-.038-.0724-.0598-.0825-.0783-.1574-.1672-.21-.2757l-1.2554-2.6143-1.5585-3.2452a.7725.7725 0 0 0-.6995-.4443h-.0024a.792.792 0 0 0-.7083.4443l-1.5109 3.2452-1.2321 2.6464a.9722.9722 0 0 1-.7985.5795c-.0626.0053-.1238-.0024-.185-.0087-.0344-.0036-.069-.0053-.1025-.0124-.0489-.0103-.0954-.0278-.142-.0452-.0301-.0113-.0613-.0197-.0901-.0339-.0496-.0244-.0948-.0565-.1397-.0889-.0217-.0156-.0457-.0275-.0662-.045a.9862.9862 0 0 1-.1695-.1844.9788.9788 0 0 1-.0708-.9852l.8469-1.8223 3.2676-7.0314a1.7964 1.7964 0 0 1-.7072-1.1637c-.1555-.9799.5129-1.9003 1.4928-2.0559V9.3946a.3542.3542 0 0 1 .1674-.3155.3468.3468 0 0 1 .3541 0 .354.354 0 0 1 .1674.3155v1.159l.0129.0064a1.8028 1.8028 0 0 1 1.2878 1.378 1.7835 1.7835 0 0 1-.6439 1.7836l3.3889 7.0507.8481 1.7643zM12.9841 12.306c.0042-.6081-.4854-1.1044-1.0935-1.1085a1.1204 1.1204 0 0 0-.7856.3219 1.101 1.101 0 0 0-.323.7716c-.0042.6081.4854 1.1044 1.0935 1.1085h.0077c.6046 0 1.0967-.488 1.1009-1.0935zm-1.027 5.2768c-.1119.0005-.2121.0632-.2571.1553l-1.4127 3.0342h3.3733l-1.4564-3.0328a.274.274 0 0 0-.2471-.1567zm8.1432-6.7459l-.0129-.0001h-.8177a.103.103 0 0 0-.103.103v12.9103a.103.103 0 0 0 .0966.103h.8435c.9861-.0035 1.7836-.804 1.7836-1.79V9.0468c0 .9887-.8014 1.7901-1.7901 1.7901zM2.6098 5.0161v.019c.0039.816.6719 1.483 1.4874 1.4869a12.061 12.061 0 0 1 .1309-.0034h1.1286c.1972-1.315.7607-2.525 1.638-3.4859H4.0993c-.9266.0031-1.6971.6401-1.9191 1.4975.2417.0355.4296.235.4296.4859zm6.3381-2.8977L7.9112.3284a.219.219 0 0 1 0-.2189A.2384.2384 0 0 1 8.098 0a.219.219 0 0 1 .1867.1094l1.0496 1.8158a6.4907 6.4907 0 0 1 5.3186 0L15.696.1094a.2189.2189 0 0 1 .3734.2189l-1.0302 1.79c1.6671.9125 2.7974 2.5439 3.0975 4.4018l-12.286-.0014c.3004-1.8572 1.4305-3.488 3.0972-4.4003zm5.3774 2.6202a.515.515 0 0 0 .5271.5028.515.515 0 0 0 .5151-.5151.5213.5213 0 0 0-.8885-.367.5151.5151 0 0 0-.1537.3793zm-5.7178-.0067a.5151.5151 0 0 0 .5207.5095.5086.5086 0 0 0 .367-.1481.5215.5215 0 1 0-.734-.7341.515.515 0 0 0-.1537.3727z" />
</svg>
);
}
/**
* Google Antigravity IDE logo icon - stylized "A" arch shape
*/
export function AntigravityIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 1C11 1 9.5 3 8 7c-1.5 4-3 8.5-4 11.5-.5 1.5-.3 2.8.5 3.3.8.5 2 .2 3-.8.8-.8 1.3-2 1.8-3.2.3-.8.8-1.3 1.5-1.3h2.4c.7 0 1.2.5 1.5 1.3.5 1.2 1 2.4 1.8 3.2 1 1 2.2 1.3 3 .8.8-.5 1-1.8.5-3.3-1-3-2.5-7.5-4-11.5C14.5 3 13 1 12 1zm0 5c.8 2 2 5.5 3 8.5H9c1-3 2.2-6.5 3-8.5z"
/>
</svg>
);
}
/**
* Get the appropriate icon component for an editor command
*/
export function getEditorIcon(command: string): IconComponent {
// Handle direct CLI commands
const cliIcons: Record<string, IconComponent> = {
cursor: CursorIcon,
code: VSCodeIcon,
'code-insiders': VSCodeInsidersIcon,
kido: KiroIcon,
zed: ZedIcon,
subl: SublimeTextIcon,
windsurf: WindsurfIcon,
trae: TraeIcon,
rider: RiderIcon,
webstorm: WebStormIcon,
xed: XcodeIcon,
studio: AndroidStudioIcon,
[PRIMARY_ANTIGRAVITY_COMMAND]: AntigravityIcon,
[LEGACY_ANTIGRAVITY_COMMAND]: AntigravityIcon,
open: FinderIcon,
explorer: FolderOpen,
'xdg-open': FolderOpen,
};
// Check direct match first
if (cliIcons[command]) {
return cliIcons[command];
}
// Handle 'open' commands (macOS) - both 'open -a AppName' and 'open "/path/to/App.app"'
if (command.startsWith('open')) {
const cmdLower = command.toLowerCase();
if (cmdLower.includes('cursor')) return CursorIcon;
if (cmdLower.includes('visual studio code - insiders')) return VSCodeInsidersIcon;
if (cmdLower.includes('visual studio code')) return VSCodeIcon;
if (cmdLower.includes('kiro')) return KiroIcon;
if (cmdLower.includes('zed')) return ZedIcon;
if (cmdLower.includes('sublime')) return SublimeTextIcon;
if (cmdLower.includes('windsurf')) return WindsurfIcon;
if (cmdLower.includes('trae')) return TraeIcon;
if (cmdLower.includes('rider')) return RiderIcon;
if (cmdLower.includes('webstorm')) return WebStormIcon;
if (cmdLower.includes('xcode')) return XcodeIcon;
if (cmdLower.includes('android studio')) return AndroidStudioIcon;
if (cmdLower.includes('antigravity')) return AntigravityIcon;
// If just 'open' without app name, it's Finder
if (command === 'open') return FinderIcon;
}
return FolderOpen;
}

View File

@@ -257,122 +257,110 @@ export function Sidebar() {
};
return (
<>
{/* Mobile overlay backdrop */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={toggleSidebar}
aria-hidden="true"
/>
<aside
className={cn(
'flex-shrink-0 flex flex-col z-30 relative',
// Glass morphism background with gradient
'bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl',
// Premium border with subtle glow
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]',
// Smooth width transition
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
sidebarOpen ? 'w-16 lg:w-72' : 'w-16'
)}
<aside
className={cn(
'flex-shrink-0 flex flex-col z-50 relative',
// Glass morphism background with gradient
'bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl',
// Premium border with subtle glow
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]',
// Smooth width transition
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
// Mobile: hidden when closed, full width overlay when open
// Desktop: always visible, toggle between narrow and wide
sidebarOpen ? 'fixed lg:relative left-0 top-0 h-full w-72' : 'hidden lg:flex w-16'
)}
data-testid="sidebar"
>
<CollapseToggleButton
data-testid="sidebar"
>
<CollapseToggleButton
sidebarOpen={sidebarOpen}
toggleSidebar={toggleSidebar}
shortcut={shortcuts.toggleSidebar}
/>
<div className="flex-1 flex flex-col overflow-hidden">
<SidebarHeader sidebarOpen={sidebarOpen} navigate={navigate} />
<ProjectSelectorWithOptions
sidebarOpen={sidebarOpen}
toggleSidebar={toggleSidebar}
shortcut={shortcuts.toggleSidebar}
isProjectPickerOpen={isProjectPickerOpen}
setIsProjectPickerOpen={setIsProjectPickerOpen}
setShowDeleteProjectDialog={setShowDeleteProjectDialog}
/>
<div className="flex-1 flex flex-col overflow-hidden">
<SidebarHeader sidebarOpen={sidebarOpen} navigate={navigate} />
<ProjectSelectorWithOptions
sidebarOpen={sidebarOpen}
isProjectPickerOpen={isProjectPickerOpen}
setIsProjectPickerOpen={setIsProjectPickerOpen}
setShowDeleteProjectDialog={setShowDeleteProjectDialog}
/>
<SidebarNavigation
currentProject={currentProject}
sidebarOpen={sidebarOpen}
navSections={navSections}
isActiveRoute={isActiveRoute}
navigate={navigate}
/>
</div>
<SidebarFooter
<SidebarNavigation
currentProject={currentProject}
sidebarOpen={sidebarOpen}
navSections={navSections}
isActiveRoute={isActiveRoute}
navigate={navigate}
hideWiki={hideWiki}
hideRunningAgents={hideRunningAgents}
runningAgentsCount={runningAgentsCount}
shortcuts={{ settings: shortcuts.settings }}
/>
<TrashDialog
open={showTrashDialog}
onOpenChange={setShowTrashDialog}
trashedProjects={trashedProjects}
activeTrashId={activeTrashId}
handleRestoreProject={handleRestoreProject}
handleDeleteProjectFromDisk={handleDeleteProjectFromDisk}
deleteTrashedProject={deleteTrashedProject}
handleEmptyTrash={handleEmptyTrash}
isEmptyingTrash={isEmptyingTrash}
/>
</div>
{/* New Project Setup Dialog */}
<CreateSpecDialog
open={showSetupDialog}
onOpenChange={setShowSetupDialog}
projectOverview={projectOverview}
onProjectOverviewChange={setProjectOverview}
generateFeatures={generateFeatures}
onGenerateFeaturesChange={setGenerateFeatures}
analyzeProject={analyzeProject}
onAnalyzeProjectChange={setAnalyzeProject}
featureCount={featureCount}
onFeatureCountChange={setFeatureCount}
onCreateSpec={handleCreateInitialSpec}
onSkip={handleSkipSetup}
isCreatingSpec={isCreatingSpec}
showSkipButton={true}
title="Set Up Your Project"
description="We didn't find an app_spec.txt file. Let us help you generate your app_spec.txt to help describe your project for our system. We'll analyze your project's tech stack and create a comprehensive specification."
/>
<SidebarFooter
sidebarOpen={sidebarOpen}
isActiveRoute={isActiveRoute}
navigate={navigate}
hideWiki={hideWiki}
hideRunningAgents={hideRunningAgents}
runningAgentsCount={runningAgentsCount}
shortcuts={{ settings: shortcuts.settings }}
/>
<TrashDialog
open={showTrashDialog}
onOpenChange={setShowTrashDialog}
trashedProjects={trashedProjects}
activeTrashId={activeTrashId}
handleRestoreProject={handleRestoreProject}
handleDeleteProjectFromDisk={handleDeleteProjectFromDisk}
deleteTrashedProject={deleteTrashedProject}
handleEmptyTrash={handleEmptyTrash}
isEmptyingTrash={isEmptyingTrash}
/>
<OnboardingDialog
open={showOnboardingDialog}
onOpenChange={setShowOnboardingDialog}
newProjectName={newProjectName}
onSkip={handleOnboardingSkip}
onGenerateSpec={handleOnboardingGenerateSpec}
/>
{/* New Project Setup Dialog */}
<CreateSpecDialog
open={showSetupDialog}
onOpenChange={setShowSetupDialog}
projectOverview={projectOverview}
onProjectOverviewChange={setProjectOverview}
generateFeatures={generateFeatures}
onGenerateFeaturesChange={setGenerateFeatures}
analyzeProject={analyzeProject}
onAnalyzeProjectChange={setAnalyzeProject}
featureCount={featureCount}
onFeatureCountChange={setFeatureCount}
onCreateSpec={handleCreateInitialSpec}
onSkip={handleSkipSetup}
isCreatingSpec={isCreatingSpec}
showSkipButton={true}
title="Set Up Your Project"
description="We didn't find an app_spec.txt file. Let us help you generate your app_spec.txt to help describe your project for our system. We'll analyze your project's tech stack and create a comprehensive specification."
/>
{/* Delete Project Confirmation Dialog */}
<DeleteProjectDialog
open={showDeleteProjectDialog}
onOpenChange={setShowDeleteProjectDialog}
project={currentProject}
onConfirm={moveProjectToTrash}
/>
<OnboardingDialog
open={showOnboardingDialog}
onOpenChange={setShowOnboardingDialog}
newProjectName={newProjectName}
onSkip={handleOnboardingSkip}
onGenerateSpec={handleOnboardingGenerateSpec}
/>
{/* New Project Modal */}
<NewProjectModal
open={showNewProjectModal}
onOpenChange={setShowNewProjectModal}
onCreateBlankProject={handleCreateBlankProject}
onCreateFromTemplate={handleCreateFromTemplate}
onCreateFromCustomUrl={handleCreateFromCustomUrl}
isCreating={isCreatingProject}
/>
</aside>
</>
{/* Delete Project Confirmation Dialog */}
<DeleteProjectDialog
open={showDeleteProjectDialog}
onOpenChange={setShowDeleteProjectDialog}
project={currentProject}
onConfirm={moveProjectToTrash}
/>
{/* New Project Modal */}
<NewProjectModal
open={showNewProjectModal}
onOpenChange={setShowNewProjectModal}
onCreateBlankProject={handleCreateBlankProject}
onCreateFromTemplate={handleCreateFromTemplate}
onCreateFromCustomUrl={handleCreateFromCustomUrl}
isCreating={isCreatingProject}
/>
</aside>
);
}

View File

@@ -35,11 +35,11 @@ export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
onClick={() => navigate({ to: '/dashboard' })}
data-testid="logo-button"
>
{/* Collapsed logo - only shown when sidebar is closed */}
{/* Collapsed logo - shown when sidebar is closed OR on small screens when sidebar is open */}
<div
className={cn(
'relative flex flex-col items-center justify-center rounded-lg gap-0.5',
sidebarOpen ? 'hidden' : 'flex'
sidebarOpen ? 'flex lg:hidden' : 'flex'
)}
>
<svg
@@ -90,16 +90,16 @@ export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
</span>
</div>
{/* Expanded logo - shown when sidebar is open */}
{/* Expanded logo - only shown when sidebar is open on large screens */}
{sidebarOpen && (
<div className="flex flex-col">
<div className="hidden lg:flex flex-col">
<div className="flex items-center gap-1">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
role="img"
aria-label="automaker"
className="h-8 w-8 lg:h-[36.8px] lg:w-[36.8px] shrink-0 group-hover:rotate-12 transition-transform duration-300 ease-out"
className="h-[36.8px] w-[36.8px] group-hover:rotate-12 transition-transform duration-300 ease-out"
>
<defs>
<linearGradient
@@ -137,11 +137,11 @@ export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
<path d="M164 92 L204 128 L164 164" />
</g>
</svg>
<span className="font-bold text-foreground text-xl lg:text-[1.7rem] tracking-tight leading-none translate-y-[-2px]">
<span className="font-bold text-foreground text-[1.7rem] tracking-tight leading-none translate-y-[-2px]">
automaker<span className="text-brand-500">.</span>
</span>
</div>
<span className="text-[0.625rem] text-muted-foreground leading-none font-medium ml-9 lg:ml-[38.8px]">
<span className="text-[0.625rem] text-muted-foreground leading-none font-medium ml-[38.8px]">
v{appVersion} {versionSuffix}
</span>
</div>

View File

@@ -17,9 +17,7 @@ export function CollapseToggleButton({
<button
onClick={toggleSidebar}
className={cn(
// Show on desktop always, show on mobile only when sidebar is open
sidebarOpen ? 'flex' : 'hidden lg:flex',
'absolute top-[68px] -right-3 z-9999',
'hidden lg:flex absolute top-[68px] -right-3 z-9999',
'group/toggle items-center justify-center w-7 h-7 rounded-full',
// Glass morphism button
'bg-card/95 backdrop-blur-sm border border-border/80',

View File

@@ -117,7 +117,7 @@ export function ProjectSelectorWithOptions({
</div>
<div className="flex items-center gap-1.5">
<span
className="hidden sm:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md bg-muted text-muted-foreground"
className="hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md bg-muted text-muted-foreground"
data-testid="project-picker-shortcut"
>
{formatShortcut(shortcuts.projectPicker, true)}
@@ -219,7 +219,7 @@ export function ProjectSelectorWithOptions({
<DropdownMenuTrigger asChild>
<button
className={cn(
'flex items-center justify-center w-[42px] h-[42px] rounded-lg',
'hidden lg:flex items-center justify-center w-[42px] h-[42px] rounded-lg',
'text-muted-foreground hover:text-foreground',
'bg-transparent hover:bg-accent/60',
'border border-border/50 hover:border-border',

View File

@@ -72,7 +72,7 @@ export function SidebarFooter({
<span
className={cn(
'ml-3 font-medium text-sm flex-1 text-left',
sidebarOpen ? 'block' : 'hidden'
sidebarOpen ? 'hidden lg:block' : 'hidden'
)}
>
Wiki
@@ -148,7 +148,7 @@ export function SidebarFooter({
<span
className={cn(
'ml-3 font-medium text-sm flex-1 text-left',
sidebarOpen ? 'block' : 'hidden'
sidebarOpen ? 'hidden lg:block' : 'hidden'
)}
>
Running Agents
@@ -157,7 +157,7 @@ export function SidebarFooter({
{sidebarOpen && runningAgentsCount > 0 && (
<span
className={cn(
'flex items-center justify-center',
'hidden lg:flex items-center justify-center',
'min-w-6 h-6 px-1.5 text-xs font-semibold rounded-full',
'bg-brand-500 text-white shadow-sm',
'animate-in fade-in zoom-in duration-200',
@@ -227,7 +227,7 @@ export function SidebarFooter({
<span
className={cn(
'ml-3 font-medium text-sm flex-1 text-left',
sidebarOpen ? 'block' : 'hidden'
sidebarOpen ? 'hidden lg:block' : 'hidden'
)}
>
Settings
@@ -235,7 +235,7 @@ export function SidebarFooter({
{sidebarOpen && (
<span
className={cn(
'hidden sm:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
'hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
isActiveRoute('settings')
? 'bg-brand-500/20 text-brand-400'
: 'bg-muted text-muted-foreground group-hover:bg-accent'

View File

@@ -20,9 +20,9 @@ export function SidebarHeader({ sidebarOpen, navigate }: SidebarHeaderProps) {
// Background gradient for depth
'bg-gradient-to-b from-transparent to-background/5',
'flex items-center',
sidebarOpen ? 'px-4 lg:px-5 justify-start' : 'px-3 justify-center',
// Add padding on macOS to avoid overlapping with traffic light buttons
isMac && sidebarOpen && 'pt-4',
sidebarOpen ? 'px-3 lg:px-5 justify-start' : 'px-3 justify-center',
// Add left padding on macOS to avoid overlapping with traffic light buttons (only when expanded)
isMac && sidebarOpen && 'pt-4 pl-20',
// Smaller top padding on macOS when collapsed
isMac && !sidebarOpen && 'pt-4'
)}

View File

@@ -35,7 +35,7 @@ export function SidebarNavigation({
<div key={sectionIdx} className={sectionIdx > 0 && sidebarOpen ? 'mt-6' : ''}>
{/* Section Label */}
{section.label && sidebarOpen && (
<div className="px-3 mb-2">
<div className="hidden lg:block px-3 mb-2">
<span className="text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-widest">
{section.label}
</span>
@@ -115,7 +115,7 @@ export function SidebarNavigation({
<span
className={cn(
'ml-3 font-medium text-sm flex-1 text-left',
sidebarOpen ? 'block' : 'hidden'
sidebarOpen ? 'hidden lg:block' : 'hidden'
)}
>
{item.label}
@@ -124,7 +124,7 @@ export function SidebarNavigation({
{item.count !== undefined && item.count > 0 && sidebarOpen && (
<span
className={cn(
'flex items-center justify-center',
'hidden lg:flex items-center justify-center',
'min-w-5 h-5 px-1.5 text-[10px] font-bold rounded-full',
'bg-primary text-primary-foreground shadow-sm',
'animate-in fade-in zoom-in duration-200'
@@ -137,7 +137,7 @@ export function SidebarNavigation({
{item.shortcut && sidebarOpen && !item.count && (
<span
className={cn(
'hidden sm:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
'hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
isActive
? 'bg-brand-500/20 text-brand-400'
: 'bg-muted text-muted-foreground group-hover:bg-accent'

View File

@@ -5,3 +5,17 @@ export {
type UseModelOverrideOptions,
type UseModelOverrideResult,
} from './use-model-override';
// Onboarding Wizard Components
export {
OnboardingWizard,
useOnboardingWizard,
ONBOARDING_STORAGE_PREFIX,
ONBOARDING_TARGET_ATTRIBUTE,
ONBOARDING_ANALYTICS,
type OnboardingStep,
type OnboardingState,
type OnboardingWizardProps,
type UseOnboardingWizardOptions,
type UseOnboardingWizardResult,
} from './onboarding';

View File

@@ -0,0 +1,55 @@
/**
* Shared Onboarding Wizard Constants
*
* Layout, positioning, and timing constants for the onboarding wizard.
*/
/** Storage key prefix for onboarding state */
export const ONBOARDING_STORAGE_PREFIX = 'automaker:onboarding';
/** Padding around spotlight highlight elements (px) */
export const SPOTLIGHT_PADDING = 8;
/** Padding between target element and tooltip (px) */
export const TOOLTIP_OFFSET = 16;
/** Vertical offset from top of target to tooltip (px) */
export const TOOLTIP_TOP_OFFSET = 40;
/** Maximum tooltip width (px) */
export const TOOLTIP_MAX_WIDTH = 400;
/** Minimum safe margin from viewport edges (px) */
export const VIEWPORT_SAFE_MARGIN = 16;
/** Threshold for placing tooltip to the right of target (30% of viewport) */
export const TOOLTIP_POSITION_RIGHT_THRESHOLD = 0.3;
/** Threshold for placing tooltip to the left of target (70% of viewport) */
export const TOOLTIP_POSITION_LEFT_THRESHOLD = 0.7;
/** Threshold from bottom of viewport to trigger alternate positioning (px) */
export const BOTTOM_THRESHOLD = 450;
/** Debounce delay for resize handler (ms) */
export const RESIZE_DEBOUNCE_MS = 100;
/** Animation duration for step transitions (ms) */
export const STEP_TRANSITION_DURATION = 200;
/** ID for the wizard description element (for aria-describedby) */
export const WIZARD_DESCRIPTION_ID = 'onboarding-wizard-description';
/** ID for the wizard title element (for aria-labelledby) */
export const WIZARD_TITLE_ID = 'onboarding-wizard-title';
/** Data attribute name for targeting elements */
export const ONBOARDING_TARGET_ATTRIBUTE = 'data-onboarding-target';
/** Analytics event names for onboarding tracking */
export const ONBOARDING_ANALYTICS = {
STARTED: 'onboarding_started',
COMPLETED: 'onboarding_completed',
SKIPPED: 'onboarding_skipped',
STEP_VIEWED: 'onboarding_step_viewed',
} as const;

View File

@@ -0,0 +1,21 @@
/**
* Shared Onboarding Components
*
* Generic onboarding wizard infrastructure for building
* interactive tutorials across different views.
*/
export { OnboardingWizard } from './onboarding-wizard';
export { useOnboardingWizard } from './use-onboarding-wizard';
export type {
OnboardingStep,
OnboardingState,
OnboardingWizardProps,
UseOnboardingWizardOptions,
UseOnboardingWizardResult,
} from './types';
export {
ONBOARDING_STORAGE_PREFIX,
ONBOARDING_TARGET_ATTRIBUTE,
ONBOARDING_ANALYTICS,
} from './constants';

View File

@@ -0,0 +1,545 @@
/**
* Generic Onboarding Wizard Component
*
* A multi-step wizard overlay that guides users through features
* with visual highlighting (spotlight effect) on target elements.
*
* Features:
* - Spotlight overlay targeting elements via data-onboarding-target
* - Responsive tooltip positioning (left/right/bottom)
* - Step navigation (keyboard & mouse)
* - Configurable children slot for view-specific content
* - Completion celebration animation
* - Full accessibility (ARIA, focus management)
*/
import { useEffect, useRef, useCallback, useState } from 'react';
import { createPortal } from 'react-dom';
import { X, ChevronLeft, ChevronRight, CheckCircle2, PartyPopper, Sparkles } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import {
SPOTLIGHT_PADDING,
TOOLTIP_OFFSET,
TOOLTIP_TOP_OFFSET,
TOOLTIP_MAX_WIDTH,
VIEWPORT_SAFE_MARGIN,
TOOLTIP_POSITION_RIGHT_THRESHOLD,
TOOLTIP_POSITION_LEFT_THRESHOLD,
BOTTOM_THRESHOLD,
RESIZE_DEBOUNCE_MS,
STEP_TRANSITION_DURATION,
WIZARD_DESCRIPTION_ID,
WIZARD_TITLE_ID,
ONBOARDING_TARGET_ATTRIBUTE,
} from './constants';
import type { OnboardingWizardProps, OnboardingStep } from './types';
interface HighlightRect {
top: number;
left: number;
right: number;
bottom: number;
width: number;
height: number;
}
export function OnboardingWizard({
isVisible,
currentStep,
currentStepData,
totalSteps,
onNext,
onPrevious,
onSkip,
onComplete,
steps,
children,
}: OnboardingWizardProps) {
const [highlightRect, setHighlightRect] = useState<HighlightRect | null>(null);
const [tooltipPosition, setTooltipPosition] = useState<'left' | 'right' | 'bottom'>('bottom');
const [isAnimating, setIsAnimating] = useState(false);
const [showCompletionCelebration, setShowCompletionCelebration] = useState(false);
// Refs for focus management
const dialogRef = useRef<HTMLDivElement>(null);
const nextButtonRef = useRef<HTMLButtonElement>(null);
// Detect if user is on a touch device
const [isTouchDevice, setIsTouchDevice] = useState(false);
useEffect(() => {
setIsTouchDevice('ontouchstart' in window || navigator.maxTouchPoints > 0);
}, []);
// Lock scroll when wizard is visible
useEffect(() => {
if (!isVisible) return;
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = originalOverflow;
};
}, [isVisible]);
// Focus management - move focus to dialog when opened
useEffect(() => {
if (!isVisible) return;
const timer = setTimeout(() => {
nextButtonRef.current?.focus();
}, STEP_TRANSITION_DURATION);
return () => clearTimeout(timer);
}, [isVisible]);
// Animate step transitions
useEffect(() => {
if (!isVisible) return;
setIsAnimating(true);
const timer = setTimeout(() => {
setIsAnimating(false);
}, STEP_TRANSITION_DURATION);
return () => clearTimeout(timer);
}, [currentStep, isVisible]);
// Find and highlight the target element
useEffect(() => {
if (!isVisible || !currentStepData) {
setHighlightRect(null);
return;
}
const updateHighlight = () => {
// Find target element by data-onboarding-target attribute
const targetEl = document.querySelector(
`[${ONBOARDING_TARGET_ATTRIBUTE}="${currentStepData.targetId}"]`
);
if (targetEl) {
const rect = targetEl.getBoundingClientRect();
setHighlightRect({
top: rect.top,
left: rect.left,
right: rect.right,
bottom: rect.bottom,
width: rect.width,
height: rect.height,
});
// Determine tooltip position based on target position and available space
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const targetCenter = rect.left + rect.width / 2;
const tooltipWidth = Math.min(TOOLTIP_MAX_WIDTH, viewportWidth - VIEWPORT_SAFE_MARGIN * 2);
const spaceAtBottom = viewportHeight - rect.bottom - TOOLTIP_OFFSET;
const spaceAtRight = viewportWidth - rect.right - TOOLTIP_OFFSET;
const spaceAtLeft = rect.left - TOOLTIP_OFFSET;
// For leftmost targets, prefer right position
if (
targetCenter < viewportWidth * TOOLTIP_POSITION_RIGHT_THRESHOLD &&
spaceAtRight >= tooltipWidth
) {
setTooltipPosition('right');
}
// For rightmost targets, prefer left position
else if (
targetCenter > viewportWidth * TOOLTIP_POSITION_LEFT_THRESHOLD &&
spaceAtLeft >= tooltipWidth
) {
setTooltipPosition('left');
}
// For middle targets, check if bottom position would work
else if (spaceAtBottom >= BOTTOM_THRESHOLD) {
setTooltipPosition('bottom');
}
// Fallback logic
else if (spaceAtRight > spaceAtLeft && spaceAtRight >= tooltipWidth * 0.6) {
setTooltipPosition('right');
} else if (spaceAtLeft >= tooltipWidth * 0.6) {
setTooltipPosition('left');
} else {
setTooltipPosition('bottom');
}
}
};
updateHighlight();
// Debounced resize handler
let resizeTimeout: ReturnType<typeof setTimeout>;
const handleResize = () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(updateHighlight, RESIZE_DEBOUNCE_MS);
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
clearTimeout(resizeTimeout);
};
}, [isVisible, currentStepData]);
// Keyboard navigation
useEffect(() => {
if (!isVisible) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onSkip();
} else if (e.key === 'ArrowRight' || e.key === 'Enter') {
if (currentStep < totalSteps - 1) {
onNext();
} else {
handleComplete();
}
} else if (e.key === 'ArrowLeft') {
onPrevious();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isVisible, currentStep, totalSteps, onNext, onPrevious, onSkip]);
// Calculate tooltip styles based on position and highlight rect
const getTooltipStyles = useCallback((): React.CSSProperties => {
if (!highlightRect) return {};
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const tooltipWidth = Math.min(TOOLTIP_MAX_WIDTH, viewportWidth - VIEWPORT_SAFE_MARGIN * 2);
switch (tooltipPosition) {
case 'right': {
const topPos = Math.max(VIEWPORT_SAFE_MARGIN, highlightRect.top + TOOLTIP_TOP_OFFSET);
const availableHeight = viewportHeight - topPos - VIEWPORT_SAFE_MARGIN;
return {
position: 'fixed',
top: topPos,
left: highlightRect.right + TOOLTIP_OFFSET,
width: tooltipWidth,
maxWidth: `calc(100vw - ${highlightRect.right + TOOLTIP_OFFSET * 2}px)`,
maxHeight: Math.max(200, availableHeight),
};
}
case 'left': {
const topPos = Math.max(VIEWPORT_SAFE_MARGIN, highlightRect.top + TOOLTIP_TOP_OFFSET);
const availableHeight = viewportHeight - topPos - VIEWPORT_SAFE_MARGIN;
return {
position: 'fixed',
top: topPos,
right: viewportWidth - highlightRect.left + TOOLTIP_OFFSET,
width: tooltipWidth,
maxWidth: `calc(${highlightRect.left - TOOLTIP_OFFSET * 2}px)`,
maxHeight: Math.max(200, availableHeight),
};
}
case 'bottom':
default: {
const idealTop = highlightRect.bottom + TOOLTIP_OFFSET;
const availableHeight = viewportHeight - idealTop - VIEWPORT_SAFE_MARGIN;
const minTop = 100;
const topPos =
availableHeight < 250
? Math.max(
minTop,
viewportHeight - Math.max(300, availableHeight) - VIEWPORT_SAFE_MARGIN
)
: idealTop;
const idealLeft = highlightRect.left + highlightRect.width / 2 - tooltipWidth / 2;
const leftPos = Math.max(
VIEWPORT_SAFE_MARGIN,
Math.min(idealLeft, viewportWidth - tooltipWidth - VIEWPORT_SAFE_MARGIN)
);
return {
position: 'fixed',
top: topPos,
left: leftPos,
width: tooltipWidth,
maxHeight: Math.max(200, viewportHeight - topPos - VIEWPORT_SAFE_MARGIN),
};
}
}
}, [highlightRect, tooltipPosition]);
// Handle completion with celebration
const handleComplete = useCallback(() => {
setShowCompletionCelebration(true);
setTimeout(() => {
setShowCompletionCelebration(false);
onComplete();
}, 1200);
}, [onComplete]);
// Handle step indicator click for direct navigation
const handleStepClick = useCallback(
(stepIndex: number) => {
if (stepIndex === currentStep) return;
if (stepIndex > currentStep) {
for (let i = currentStep; i < stepIndex; i++) {
onNext();
}
} else {
for (let i = currentStep; i > stepIndex; i--) {
onPrevious();
}
}
},
[currentStep, onNext, onPrevious]
);
if (!isVisible || !currentStepData) return null;
const StepIcon = currentStepData.icon || Sparkles;
const isLastStep = currentStep === totalSteps - 1;
const isFirstStep = currentStep === 0;
const content = (
<div
ref={dialogRef}
className="fixed inset-0 z-[100]"
role="dialog"
aria-modal="true"
aria-labelledby={WIZARD_TITLE_ID}
aria-describedby={WIZARD_DESCRIPTION_ID}
>
{/* Completion celebration overlay */}
{showCompletionCelebration && (
<div className="absolute inset-0 z-[102] flex items-center justify-center pointer-events-none">
<div className="animate-in zoom-in-50 fade-in duration-300 flex flex-col items-center gap-4 text-white">
<PartyPopper className="w-16 h-16 text-yellow-400 animate-bounce" />
<p className="text-2xl font-bold">You're all set!</p>
</div>
</div>
)}
{/* Dark overlay with cutout for highlighted element */}
<svg
className="absolute inset-0 w-full h-full pointer-events-none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<defs>
<mask id="spotlight-mask">
<rect x="0" y="0" width="100%" height="100%" fill="white" />
{highlightRect && (
<rect
x={highlightRect.left - SPOTLIGHT_PADDING}
y={highlightRect.top - SPOTLIGHT_PADDING}
width={highlightRect.width + SPOTLIGHT_PADDING * 2}
height={highlightRect.height + SPOTLIGHT_PADDING * 2}
rx="16"
fill="black"
/>
)}
</mask>
</defs>
<rect
x="0"
y="0"
width="100%"
height="100%"
fill="rgba(0, 0, 0, 0.75)"
mask="url(#spotlight-mask)"
className="transition-all duration-300"
/>
</svg>
{/* Highlight border around the target element */}
{highlightRect && (
<div
className="absolute pointer-events-none transition-all duration-300 ease-out"
style={{
left: highlightRect.left - SPOTLIGHT_PADDING,
top: highlightRect.top - SPOTLIGHT_PADDING,
width: highlightRect.width + SPOTLIGHT_PADDING * 2,
height: highlightRect.height + SPOTLIGHT_PADDING * 2,
borderRadius: '16px',
border: '2px solid hsl(var(--primary))',
boxShadow:
'0 0 20px 4px hsl(var(--primary) / 0.3), inset 0 0 20px 4px hsl(var(--primary) / 0.1)',
}}
/>
)}
{/* Skip button - top right */}
<Button
variant="ghost"
size="sm"
className={cn(
'fixed top-4 right-4 z-[101]',
'text-white/70 hover:text-white hover:bg-white/10',
'focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-transparent',
'min-h-[44px] min-w-[44px] px-3'
)}
onClick={onSkip}
aria-label="Skip the onboarding tour"
>
<X className="w-4 h-4 mr-1.5" aria-hidden="true" />
<span>Skip Tour</span>
</Button>
{/* Tooltip card with step content */}
<div
className={cn(
'z-[101] bg-popover/95 backdrop-blur-xl rounded-xl shadow-2xl border border-border/50',
'p-6 animate-in fade-in-0 slide-in-from-bottom-4 duration-300',
'max-h-[calc(100vh-100px)] overflow-y-auto',
isAnimating && 'opacity-90 scale-[0.98]',
'transition-all duration-200 ease-out'
)}
style={getTooltipStyles()}
>
{/* Header */}
<div className="flex items-start gap-4 mb-4">
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-primary/10 border border-primary/20 shrink-0">
<StepIcon className="w-6 h-6 text-primary" />
</div>
<div className="flex-1 min-w-0">
<h3 id={WIZARD_TITLE_ID} className="text-lg font-semibold text-foreground truncate">
{currentStepData.title}
</h3>
<div className="flex items-center gap-2 mt-1.5">
<span className="text-xs text-muted-foreground" aria-live="polite">
Step {currentStep + 1} of {totalSteps}
</span>
{/* Step indicators - clickable for navigation */}
<nav aria-label="Wizard steps" className="flex items-center gap-1">
{Array.from({ length: totalSteps }).map((_, i) => (
<button
key={i}
type="button"
onClick={() => handleStepClick(i)}
className={cn(
'relative flex items-center justify-center',
'w-6 h-6',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1 focus-visible:rounded-full',
'transition-transform duration-200 hover:scale-110'
)}
aria-label={`Go to step ${i + 1}: ${steps[i]?.title}`}
aria-current={i === currentStep ? 'step' : undefined}
>
<span
className={cn(
'block rounded-full transition-all duration-200',
i === currentStep
? 'w-2.5 h-2.5 bg-primary ring-2 ring-primary/30 ring-offset-1 ring-offset-popover'
: i < currentStep
? 'w-2 h-2 bg-primary/60'
: 'w-2 h-2 bg-muted-foreground/40'
)}
/>
</button>
))}
</nav>
</div>
</div>
</div>
{/* Description */}
<p
id={WIZARD_DESCRIPTION_ID}
className="text-sm text-muted-foreground leading-relaxed mb-4"
>
{currentStepData.description}
</p>
{/* Tip box */}
{currentStepData.tip && (
<div className="rounded-lg bg-primary/5 border border-primary/10 p-3 mb-4">
<p className="text-xs text-muted-foreground">
<span className="font-medium text-foreground">Tip: </span>
{currentStepData.tip}
</p>
</div>
)}
{/* Custom content slot (e.g., Quick Start section) */}
{children}
{/* Navigation buttons */}
<div className="flex items-center justify-between gap-3 pt-2">
<Button
variant="ghost"
size="sm"
onClick={onPrevious}
disabled={isFirstStep}
className={cn(
'text-muted-foreground min-h-[44px]',
'focus-visible:ring-2 focus-visible:ring-primary',
isFirstStep && 'invisible'
)}
aria-label="Go to previous step"
>
<ChevronLeft className="w-4 h-4 mr-1" aria-hidden="true" />
<span>Previous</span>
</Button>
<Button
ref={nextButtonRef}
size="sm"
onClick={isLastStep ? handleComplete : onNext}
disabled={showCompletionCelebration}
className={cn(
'bg-primary hover:bg-primary/90 text-primary-foreground',
'min-w-[120px] min-h-[44px]',
'focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
'transition-all duration-200'
)}
aria-label={isLastStep ? 'Complete the tour and get started' : 'Go to next step'}
>
{isLastStep ? (
<>
<span>Get Started</span>
<CheckCircle2 className="w-4 h-4 ml-1.5" aria-hidden="true" />
</>
) : (
<>
<span>Next</span>
<ChevronRight className="w-4 h-4 ml-1" aria-hidden="true" />
</>
)}
</Button>
</div>
{/* Keyboard hints - hidden on touch devices */}
{!isTouchDevice && (
<div
className="mt-4 pt-3 border-t border-border/50 flex items-center justify-center gap-4 text-xs text-muted-foreground/70"
aria-hidden="true"
>
<span className="flex items-center gap-1.5">
<kbd className="px-2 py-1 rounded bg-muted text-muted-foreground font-mono text-[11px] shadow-sm">
ESC
</kbd>
<span>to skip</span>
</span>
<span className="flex items-center gap-1.5">
<kbd className="px-2 py-1 rounded bg-muted text-muted-foreground font-mono text-[11px] shadow-sm">
</kbd>
<kbd className="px-2 py-1 rounded bg-muted text-muted-foreground font-mono text-[11px] shadow-sm">
</kbd>
<span>to navigate</span>
</span>
</div>
)}
</div>
</div>
);
// Render in a portal to ensure it's above everything
return createPortal(content, document.body);
}

View File

@@ -0,0 +1,109 @@
/**
* Shared Onboarding Wizard Types
*
* Generic types for building onboarding wizards across different views.
*/
import type { ComponentType } from 'react';
/**
* Represents a single step in the onboarding wizard
*/
export interface OnboardingStep {
/** Unique identifier for this step */
id: string;
/** Target element ID - matches data-onboarding-target attribute */
targetId: string;
/** Step title displayed in the wizard */
title: string;
/** Main description explaining this step */
description: string;
/** Optional tip shown in a highlighted box */
tip?: string;
/** Optional icon component for visual identification */
icon?: ComponentType<{ className?: string }>;
}
/**
* Persisted onboarding state structure
*/
export interface OnboardingState {
/** Whether the wizard has been completed */
completed: boolean;
/** ISO timestamp when completed */
completedAt?: string;
/** Whether the wizard has been skipped */
skipped: boolean;
/** ISO timestamp when skipped */
skippedAt?: string;
}
/**
* Options for the useOnboardingWizard hook
*/
export interface UseOnboardingWizardOptions {
/** Unique storage key for localStorage persistence */
storageKey: string;
/** Array of wizard steps to display */
steps: OnboardingStep[];
/** Optional callback when wizard is completed */
onComplete?: () => void;
/** Optional callback when wizard is skipped */
onSkip?: () => void;
}
/**
* Return type for the useOnboardingWizard hook
*/
export interface UseOnboardingWizardResult {
/** Whether the wizard is currently visible */
isVisible: boolean;
/** Current step index (0-based) */
currentStep: number;
/** Current step data or null if not available */
currentStepData: OnboardingStep | null;
/** Total number of steps */
totalSteps: number;
/** Navigate to the next step */
goToNextStep: () => void;
/** Navigate to the previous step */
goToPreviousStep: () => void;
/** Navigate to a specific step by index */
goToStep: (step: number) => void;
/** Start/show the wizard from the beginning */
startWizard: () => void;
/** Complete the wizard and hide it */
completeWizard: () => void;
/** Skip the wizard and hide it */
skipWizard: () => void;
/** Whether the wizard has been completed */
isCompleted: boolean;
/** Whether the wizard has been skipped */
isSkipped: boolean;
}
/**
* Props for the OnboardingWizard component
*/
export interface OnboardingWizardProps {
/** Whether the wizard is visible */
isVisible: boolean;
/** Current step index */
currentStep: number;
/** Current step data */
currentStepData: OnboardingStep | null;
/** Total number of steps */
totalSteps: number;
/** Handler for next step navigation */
onNext: () => void;
/** Handler for previous step navigation */
onPrevious: () => void;
/** Handler for skipping the wizard */
onSkip: () => void;
/** Handler for completing the wizard */
onComplete: () => void;
/** Array of all steps (for step indicator navigation) */
steps: OnboardingStep[];
/** Optional content to render before navigation buttons (e.g., Quick Start) */
children?: React.ReactNode;
}

View File

@@ -0,0 +1,216 @@
/**
* Generic Onboarding Wizard Hook
*
* Manages the state and logic for interactive onboarding wizards.
* Can be used to create onboarding experiences for any view.
*
* Features:
* - Persists completion status to localStorage
* - Step navigation (next, previous, jump to step)
* - Analytics tracking hooks
* - No auto-show logic - wizard only shows via startWizard()
*/
import { useState, useCallback, useEffect, useMemo } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { getItem, setItem } from '@/lib/storage';
import { ONBOARDING_STORAGE_PREFIX, ONBOARDING_ANALYTICS } from './constants';
import type {
OnboardingState,
OnboardingStep,
UseOnboardingWizardOptions,
UseOnboardingWizardResult,
} from './types';
const logger = createLogger('OnboardingWizard');
/** Default state for new wizards */
const DEFAULT_ONBOARDING_STATE: OnboardingState = {
completed: false,
skipped: false,
};
/**
* Load onboarding state from localStorage
*/
function loadOnboardingState(storageKey: string): OnboardingState {
try {
const fullKey = `${ONBOARDING_STORAGE_PREFIX}:${storageKey}`;
const stored = getItem(fullKey);
if (stored) {
return JSON.parse(stored) as OnboardingState;
}
} catch (error) {
logger.error('Failed to load onboarding state:', error);
}
return { ...DEFAULT_ONBOARDING_STATE };
}
/**
* Save onboarding state to localStorage
*/
function saveOnboardingState(storageKey: string, state: OnboardingState): void {
try {
const fullKey = `${ONBOARDING_STORAGE_PREFIX}:${storageKey}`;
setItem(fullKey, JSON.stringify(state));
} catch (error) {
logger.error('Failed to save onboarding state:', error);
}
}
/**
* Track analytics event (placeholder - integrate with actual analytics service)
*/
function trackAnalytics(event: string, data?: Record<string, unknown>): void {
logger.debug(`[Analytics] ${event}`, data);
}
/**
* Generic hook for managing onboarding wizard state.
*
* @example
* ```tsx
* const wizard = useOnboardingWizard({
* storageKey: 'my-view-onboarding',
* steps: MY_WIZARD_STEPS,
* onComplete: () => console.log('Done!'),
* });
*
* // Start the wizard when user clicks help button
* <button onClick={wizard.startWizard}>Help</button>
*
* // Render the wizard
* <OnboardingWizard
* isVisible={wizard.isVisible}
* currentStep={wizard.currentStep}
* currentStepData={wizard.currentStepData}
* totalSteps={wizard.totalSteps}
* onNext={wizard.goToNextStep}
* onPrevious={wizard.goToPreviousStep}
* onSkip={wizard.skipWizard}
* onComplete={wizard.completeWizard}
* steps={MY_WIZARD_STEPS}
* />
* ```
*/
export function useOnboardingWizard({
storageKey,
steps,
onComplete,
onSkip,
}: UseOnboardingWizardOptions): UseOnboardingWizardResult {
const [currentStep, setCurrentStep] = useState(0);
const [isWizardVisible, setIsWizardVisible] = useState(false);
const [onboardingState, setOnboardingState] = useState<OnboardingState>(DEFAULT_ONBOARDING_STATE);
// Load persisted state on mount
useEffect(() => {
const state = loadOnboardingState(storageKey);
setOnboardingState(state);
}, [storageKey]);
// Update persisted state helper
const updateState = useCallback(
(updates: Partial<OnboardingState>) => {
setOnboardingState((prev) => {
const newState = { ...prev, ...updates };
saveOnboardingState(storageKey, newState);
return newState;
});
},
[storageKey]
);
// Current step data
const currentStepData = useMemo(() => steps[currentStep] || null, [steps, currentStep]);
const totalSteps = steps.length;
// Navigation handlers
const goToNextStep = useCallback(() => {
if (currentStep < totalSteps - 1) {
const nextStep = currentStep + 1;
setCurrentStep(nextStep);
trackAnalytics(ONBOARDING_ANALYTICS.STEP_VIEWED, {
storageKey,
step: nextStep,
stepId: steps[nextStep]?.id,
});
}
}, [currentStep, totalSteps, storageKey, steps]);
const goToPreviousStep = useCallback(() => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1);
}
}, [currentStep]);
const goToStep = useCallback(
(step: number) => {
if (step >= 0 && step < totalSteps) {
setCurrentStep(step);
trackAnalytics(ONBOARDING_ANALYTICS.STEP_VIEWED, {
storageKey,
step,
stepId: steps[step]?.id,
});
}
},
[totalSteps, storageKey, steps]
);
// Wizard lifecycle handlers
const startWizard = useCallback(() => {
setCurrentStep(0);
setIsWizardVisible(true);
trackAnalytics(ONBOARDING_ANALYTICS.STARTED, { storageKey });
}, [storageKey]);
const completeWizard = useCallback(() => {
setIsWizardVisible(false);
setCurrentStep(0);
updateState({
completed: true,
completedAt: new Date().toISOString(),
});
trackAnalytics(ONBOARDING_ANALYTICS.COMPLETED, { storageKey });
onComplete?.();
}, [storageKey, updateState, onComplete]);
const skipWizard = useCallback(() => {
setIsWizardVisible(false);
setCurrentStep(0);
updateState({
skipped: true,
skippedAt: new Date().toISOString(),
});
trackAnalytics(ONBOARDING_ANALYTICS.SKIPPED, {
storageKey,
skippedAtStep: currentStep,
});
onSkip?.();
}, [storageKey, currentStep, updateState, onSkip]);
return {
// Visibility
isVisible: isWizardVisible,
// Steps
currentStep,
currentStepData,
totalSteps,
// Navigation
goToNextStep,
goToPreviousStep,
goToStep,
// Actions
startWizard,
completeWizard,
skipWizard,
// State
isCompleted: onboardingState.completed,
isSkipped: onboardingState.skipped,
};
}

View File

@@ -1,276 +0,0 @@
import { useMemo } from 'react';
import { cn } from '@/lib/utils';
interface AnsiOutputProps {
text: string;
className?: string;
}
// ANSI color codes to CSS color mappings
const ANSI_COLORS: Record<number, string> = {
// Standard colors
30: '#6b7280', // Black (use gray for visibility on dark bg)
31: '#ef4444', // Red
32: '#22c55e', // Green
33: '#eab308', // Yellow
34: '#3b82f6', // Blue
35: '#a855f7', // Magenta
36: '#06b6d4', // Cyan
37: '#d1d5db', // White
// Bright colors
90: '#9ca3af', // Bright Black (Gray)
91: '#f87171', // Bright Red
92: '#4ade80', // Bright Green
93: '#facc15', // Bright Yellow
94: '#60a5fa', // Bright Blue
95: '#c084fc', // Bright Magenta
96: '#22d3ee', // Bright Cyan
97: '#ffffff', // Bright White
};
const ANSI_BG_COLORS: Record<number, string> = {
40: 'transparent',
41: '#ef4444',
42: '#22c55e',
43: '#eab308',
44: '#3b82f6',
45: '#a855f7',
46: '#06b6d4',
47: '#f3f4f6',
// Bright backgrounds
100: '#374151',
101: '#f87171',
102: '#4ade80',
103: '#facc15',
104: '#60a5fa',
105: '#c084fc',
106: '#22d3ee',
107: '#ffffff',
};
interface TextSegment {
text: string;
style: {
color?: string;
backgroundColor?: string;
fontWeight?: string;
fontStyle?: string;
textDecoration?: string;
};
}
/**
* Strip hyperlink escape sequences (OSC 8)
* Format: ESC]8;;url ESC\ text ESC]8;; ESC\
*/
function stripHyperlinks(text: string): string {
// Remove OSC 8 hyperlink sequences
// eslint-disable-next-line no-control-regex
return text.replace(/\x1b\]8;;[^\x07\x1b]*(?:\x07|\x1b\\)/g, '');
}
/**
* Strip other OSC sequences (title, etc.)
*/
function stripOtherOSC(text: string): string {
// Remove OSC sequences (ESC ] ... BEL or ESC ] ... ST)
// eslint-disable-next-line no-control-regex
return text.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '');
}
function parseAnsi(text: string): TextSegment[] {
// Pre-process: strip hyperlinks and other OSC sequences
let processedText = stripHyperlinks(text);
processedText = stripOtherOSC(processedText);
const segments: TextSegment[] = [];
// Match ANSI escape sequences: ESC[...m (SGR - Select Graphic Rendition)
// Also handle ESC[K (erase line) and other CSI sequences by stripping them
// The ESC character can be \x1b, \033, \u001b
// eslint-disable-next-line no-control-regex
const ansiRegex = /\x1b\[([0-9;]*)([a-zA-Z])/g;
let currentStyle: TextSegment['style'] = {};
let lastIndex = 0;
let match;
while ((match = ansiRegex.exec(processedText)) !== null) {
// Add text before this escape sequence
if (match.index > lastIndex) {
const content = processedText.slice(lastIndex, match.index);
if (content) {
segments.push({ text: content, style: { ...currentStyle } });
}
}
const params = match[1];
const command = match[2];
// Only process 'm' command (SGR - graphics/color)
// Ignore other commands like K (erase), H (cursor), J (clear), etc.
if (command === 'm') {
// Parse the escape sequence codes
const codes = params ? params.split(';').map((c) => parseInt(c, 10) || 0) : [0];
for (let i = 0; i < codes.length; i++) {
const code = codes[i];
if (code === 0) {
// Reset all attributes
currentStyle = {};
} else if (code === 1) {
// Bold
currentStyle.fontWeight = 'bold';
} else if (code === 2) {
// Dim/faint
currentStyle.color = 'var(--muted-foreground)';
} else if (code === 3) {
// Italic
currentStyle.fontStyle = 'italic';
} else if (code === 4) {
// Underline
currentStyle.textDecoration = 'underline';
} else if (code === 22) {
// Normal intensity (not bold, not dim)
currentStyle.fontWeight = undefined;
} else if (code === 23) {
// Not italic
currentStyle.fontStyle = undefined;
} else if (code === 24) {
// Not underlined
currentStyle.textDecoration = undefined;
} else if (code === 38) {
// Extended foreground color
if (codes[i + 1] === 5 && codes[i + 2] !== undefined) {
// 256 color mode: 38;5;n
const colorIndex = codes[i + 2];
currentStyle.color = get256Color(colorIndex);
i += 2;
} else if (codes[i + 1] === 2 && codes[i + 4] !== undefined) {
// RGB mode: 38;2;r;g;b
const r = codes[i + 2];
const g = codes[i + 3];
const b = codes[i + 4];
currentStyle.color = `rgb(${r}, ${g}, ${b})`;
i += 4;
}
} else if (code === 48) {
// Extended background color
if (codes[i + 1] === 5 && codes[i + 2] !== undefined) {
// 256 color mode: 48;5;n
const colorIndex = codes[i + 2];
currentStyle.backgroundColor = get256Color(colorIndex);
i += 2;
} else if (codes[i + 1] === 2 && codes[i + 4] !== undefined) {
// RGB mode: 48;2;r;g;b
const r = codes[i + 2];
const g = codes[i + 3];
const b = codes[i + 4];
currentStyle.backgroundColor = `rgb(${r}, ${g}, ${b})`;
i += 4;
}
} else if (ANSI_COLORS[code]) {
// Standard foreground color (30-37, 90-97)
currentStyle.color = ANSI_COLORS[code];
} else if (ANSI_BG_COLORS[code]) {
// Standard background color (40-47, 100-107)
currentStyle.backgroundColor = ANSI_BG_COLORS[code];
} else if (code === 39) {
// Default foreground
currentStyle.color = undefined;
} else if (code === 49) {
// Default background
currentStyle.backgroundColor = undefined;
}
}
}
lastIndex = match.index + match[0].length;
}
// Add remaining text after last escape sequence
if (lastIndex < processedText.length) {
const content = processedText.slice(lastIndex);
if (content) {
segments.push({ text: content, style: { ...currentStyle } });
}
}
// If no segments were created (no ANSI codes), return the whole text
if (segments.length === 0 && processedText) {
segments.push({ text: processedText, style: {} });
}
return segments;
}
/**
* Convert 256-color palette index to CSS color
*/
function get256Color(index: number): string {
// 0-15: Standard colors
if (index < 16) {
const standardColors = [
'#000000',
'#cd0000',
'#00cd00',
'#cdcd00',
'#0000ee',
'#cd00cd',
'#00cdcd',
'#e5e5e5',
'#7f7f7f',
'#ff0000',
'#00ff00',
'#ffff00',
'#5c5cff',
'#ff00ff',
'#00ffff',
'#ffffff',
];
return standardColors[index];
}
// 16-231: 6x6x6 color cube
if (index < 232) {
const n = index - 16;
const b = n % 6;
const g = Math.floor(n / 6) % 6;
const r = Math.floor(n / 36);
const toHex = (v: number) => (v === 0 ? 0 : 55 + v * 40);
return `rgb(${toHex(r)}, ${toHex(g)}, ${toHex(b)})`;
}
// 232-255: Grayscale
const gray = 8 + (index - 232) * 10;
return `rgb(${gray}, ${gray}, ${gray})`;
}
export function AnsiOutput({ text, className }: AnsiOutputProps) {
const segments = useMemo(() => parseAnsi(text), [text]);
return (
<pre
className={cn(
'font-mono text-xs whitespace-pre-wrap break-words text-muted-foreground',
className
)}
>
{segments.map((segment, index) => (
<span
key={index}
style={{
color: segment.style.color,
backgroundColor: segment.style.backgroundColor,
fontWeight: segment.style.fontWeight,
fontStyle: segment.style.fontStyle,
textDecoration: segment.style.textDecoration,
}}
>
{segment.text}
</span>
))}
</pre>
);
}

View File

@@ -6,7 +6,6 @@ import { getProviderFromModel } from '@/lib/utils';
const PROVIDER_ICON_KEYS = {
anthropic: 'anthropic',
openai: 'openai',
openrouter: 'openrouter',
cursor: 'cursor',
gemini: 'gemini',
grok: 'grok',
@@ -42,12 +41,6 @@ const PROVIDER_ICON_DEFINITIONS: Record<ProviderIconKey, ProviderIconDefinition>
path: 'M60.8734,57.2556v-14.9432c0-1.2586.4722-2.2029,1.5728-2.8314l30.0443-17.3023c4.0899-2.3593,8.9662-3.4599,13.9988-3.4599,18.8759,0,30.8307,14.6289,30.8307,30.2006,0,1.1007,0,2.3593-.158,3.6178l-31.1446-18.2467c-1.8872-1.1006-3.7754-1.1006-5.6629,0l-39.4812,22.9651ZM131.0276,115.4561v-35.7074c0-2.2028-.9446-3.7756-2.8318-4.8763l-39.481-22.9651,12.8982-7.3934c1.1007-.6285,2.0453-.6285,3.1458,0l30.0441,17.3024c8.6523,5.0341,14.4708,15.7296,14.4708,26.1107,0,11.9539-7.0769,22.965-18.2461,27.527v.0021ZM51.593,83.9964l-12.8982-7.5497c-1.1007-.6285-1.5728-1.5728-1.5728-2.8314v-34.6048c0-16.8303,12.8982-29.5722,30.3585-29.5722,6.607,0,12.7403,2.2029,17.9324,6.1349l-30.987,17.9324c-1.8871,1.1007-2.8314,2.6735-2.8314,4.8764v45.6159l-.0014-.0015ZM79.3562,100.0403l-18.4829-10.3811v-22.0209l18.4829-10.3811,18.4812,10.3811v22.0209l-18.4812,10.3811ZM91.2319,147.8591c-6.607,0-12.7403-2.2031-17.9324-6.1344l30.9866-17.9333c1.8872-1.1005,2.8318-2.6728,2.8318-4.8759v-45.616l13.0564,7.5498c1.1005.6285,1.5723,1.5728,1.5723,2.8314v34.6051c0,16.8297-13.0564,29.5723-30.5147,29.5723v.001ZM53.9522,112.7822l-30.0443-17.3024c-8.652-5.0343-14.471-15.7296-14.471-26.1107,0-12.1119,7.2356-22.9652,18.403-27.5272v35.8634c0,2.2028.9443,3.7756,2.8314,4.8763l39.3248,22.8068-12.8982,7.3938c-1.1007.6287-2.045.6287-3.1456,0ZM52.2229,138.5791c-17.7745,0-30.8306-13.3713-30.8306-29.8871,0-1.2585.1578-2.5169.3143-3.7754l30.987,17.9323c1.8871,1.1005,3.7757,1.1005,5.6628,0l39.4811-22.807v14.9435c0,1.2585-.4721,2.2021-1.5728,2.8308l-30.0443,17.3025c-4.0898,2.359-8.9662,3.4605-13.9989,3.4605h.0014ZM91.2319,157.296c19.0327,0,34.9188-13.5272,38.5383-31.4594,17.6164-4.562,28.9425-21.0779,28.9425-37.908,0-11.0112-4.719-21.7066-13.2133-29.4143.7867-3.3035,1.2595-6.607,1.2595-9.909,0-22.4929-18.2471-39.3247-39.3251-39.3247-4.2461,0-8.3363.6285-12.4262,2.045-7.0792-6.9213-16.8318-11.3254-27.5271-11.3254-19.0331,0-34.9191,13.5268-38.5384,31.4591C11.3255,36.0212,0,52.5373,0,69.3675c0,11.0112,4.7184,21.7065,13.2125,29.4142-.7865,3.3035-1.2586,6.6067-1.2586,9.9092,0,22.4923,18.2466,39.3241,39.3248,39.3241,4.2462,0,8.3362-.6277,12.426-2.0441,7.0776,6.921,16.8302,11.3251,27.5271,11.3251Z',
fill: '#74aa9c',
},
openrouter: {
viewBox: '0 0 24 24',
// OpenRouter logo from Simple Icons
path: 'M16.778 1.844v1.919q-.569-.026-1.138-.032-.708-.008-1.415.037c-1.93.126-4.023.728-6.149 2.237-2.911 2.066-2.731 1.95-4.14 2.75-.396.223-1.342.574-2.185.798-.841.225-1.753.333-1.751.333v4.229s.768.108 1.61.333c.842.224 1.789.575 2.185.799 1.41.798 1.228.683 4.14 2.75 2.126 1.509 4.22 2.11 6.148 2.236.88.058 1.716.041 2.555.005v1.918l7.222-4.168-7.222-4.17v2.176c-.86.038-1.611.065-2.278.021-1.364-.09-2.417-.357-3.979-1.465-2.244-1.593-2.866-2.027-3.68-2.508.889-.518 1.449-.906 3.822-2.59 1.56-1.109 2.614-1.377 3.978-1.466.667-.044 1.418-.017 2.278.02v2.176L24 6.014Z',
fill: '#94A3B8',
},
cursor: {
viewBox: '0 0 512 512',
// Official Cursor logo - hexagonal shape with triangular wedge
@@ -158,10 +151,6 @@ export function OpenAIIcon(props: Omit<ProviderIconProps, 'provider'>) {
return <ProviderIcon provider={PROVIDER_ICON_KEYS.openai} {...props} />;
}
export function OpenRouterIcon(props: Omit<ProviderIconProps, 'provider'>) {
return <ProviderIcon provider={PROVIDER_ICON_KEYS.openrouter} {...props} />;
}
export function CursorIcon(props: Omit<ProviderIconProps, 'provider'>) {
return <ProviderIcon provider={PROVIDER_ICON_KEYS.cursor} {...props} />;
}
@@ -406,11 +395,6 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey {
const modelStr = typeof model === 'string' ? model.toLowerCase() : model;
// Check for Amazon Bedrock models first (amazon-bedrock/...)
if (modelStr.startsWith('openrouter/')) {
return 'openrouter';
}
// Check for Amazon Bedrock models first (amazon-bedrock/...)
if (modelStr.startsWith('amazon-bedrock/')) {
// Bedrock-hosted models - detect the specific provider
@@ -458,71 +442,6 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey {
return 'opencode';
}
// Check for dynamic OpenCode provider models (provider/model format)
// e.g., zai-coding-plan/glm-4.5, github-copilot/gpt-4o, google/gemini-2.5-pro
// Only handle strings with exactly one slash (not URLs or paths)
if (!modelStr.includes('://')) {
const slashIndex = modelStr.indexOf('/');
if (slashIndex !== -1 && slashIndex === modelStr.lastIndexOf('/')) {
const providerName = modelStr.slice(0, slashIndex);
const modelName = modelStr.slice(slashIndex + 1);
// Skip if either part is empty
if (providerName && modelName) {
// Check model name for known patterns
if (modelName.includes('glm')) {
return 'glm';
}
if (
modelName.includes('claude') ||
modelName.includes('sonnet') ||
modelName.includes('opus')
) {
return 'anthropic';
}
if (modelName.includes('gpt') || modelName.includes('o1') || modelName.includes('o3')) {
return 'openai';
}
if (modelName.includes('gemini')) {
return 'gemini';
}
if (modelName.includes('grok')) {
return 'grok';
}
if (modelName.includes('deepseek')) {
return 'deepseek';
}
if (modelName.includes('llama')) {
return 'meta';
}
if (modelName.includes('qwen')) {
return 'qwen';
}
if (modelName.includes('mistral')) {
return 'mistral';
}
// Check provider name for hints
if (providerName.includes('google')) {
return 'gemini';
}
if (providerName.includes('anthropic')) {
return 'anthropic';
}
if (providerName.includes('openai')) {
return 'openai';
}
if (providerName.includes('openrouter')) {
return 'openrouter';
}
if (providerName.includes('xai')) {
return 'grok';
}
// Default for unknown dynamic models
return 'opencode';
}
}
}
// Check for Cursor-specific models with underlying providers
if (modelStr.includes('sonnet') || modelStr.includes('opus') || modelStr.includes('claude')) {
return 'anthropic';
@@ -556,7 +475,6 @@ export function getProviderIconForModel(
const iconMap: Record<ProviderIconKey, ComponentType<{ className?: string }>> = {
anthropic: AnthropicIcon,
openai: OpenAIIcon,
openrouter: OpenRouterIcon,
cursor: CursorIcon,
gemini: GeminiIcon,
grok: GrokIcon,

View File

@@ -1,142 +0,0 @@
import CodeMirror from '@uiw/react-codemirror';
import { StreamLanguage } from '@codemirror/language';
import { shell } from '@codemirror/legacy-modes/mode/shell';
import { EditorView } from '@codemirror/view';
import { Extension } from '@codemirror/state';
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { tags as t } from '@lezer/highlight';
import { cn } from '@/lib/utils';
interface ShellSyntaxEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
minHeight?: string;
maxHeight?: string;
'data-testid'?: string;
}
// Syntax highlighting using CSS variables for theme compatibility
const syntaxColors = HighlightStyle.define([
// Keywords (if, then, else, fi, for, while, do, done, case, esac, etc.)
{ tag: t.keyword, color: 'var(--chart-4, oklch(0.7 0.15 280))' },
// Strings (single and double quoted)
{ tag: t.string, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
// Comments
{ tag: t.comment, color: 'var(--muted-foreground)', fontStyle: 'italic' },
// Variables ($VAR, ${VAR})
{ tag: t.variableName, color: 'var(--chart-2, oklch(0.6 0.118 184.704))' },
// Operators
{ tag: t.operator, color: 'var(--muted-foreground)' },
// Numbers
{ tag: t.number, color: 'var(--chart-3, oklch(0.7 0.15 150))' },
// Function names / commands
{ tag: t.function(t.variableName), color: 'var(--primary)' },
{ tag: t.attributeName, color: 'var(--chart-5, oklch(0.65 0.2 30))' },
// Default text
{ tag: t.content, color: 'var(--foreground)' },
]);
// Editor theme using CSS variables
const editorTheme = EditorView.theme({
'&': {
height: '100%',
fontSize: '0.875rem',
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
backgroundColor: 'transparent',
color: 'var(--foreground)',
},
'.cm-scroller': {
overflow: 'auto',
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
},
'.cm-content': {
padding: '0.75rem',
minHeight: '100%',
caretColor: 'var(--primary)',
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--primary)',
},
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
backgroundColor: 'oklch(0.55 0.25 265 / 0.3)',
},
'.cm-activeLine': {
backgroundColor: 'var(--accent)',
opacity: '0.3',
},
'.cm-line': {
padding: '0 0.25rem',
},
'&.cm-focused': {
outline: 'none',
},
'.cm-gutters': {
backgroundColor: 'transparent',
color: 'var(--muted-foreground)',
border: 'none',
paddingRight: '0.5rem',
},
'.cm-lineNumbers .cm-gutterElement': {
minWidth: '2rem',
textAlign: 'right',
paddingRight: '0.5rem',
},
'.cm-placeholder': {
color: 'var(--muted-foreground)',
fontStyle: 'italic',
},
});
// Combine all extensions
const extensions: Extension[] = [
StreamLanguage.define(shell),
syntaxHighlighting(syntaxColors),
editorTheme,
];
export function ShellSyntaxEditor({
value,
onChange,
placeholder,
className,
minHeight = '200px',
maxHeight,
'data-testid': testId,
}: ShellSyntaxEditorProps) {
return (
<div
className={cn('w-full rounded-lg border border-border bg-muted/30', className)}
style={{ minHeight }}
data-testid={testId}
>
<CodeMirror
value={value}
onChange={onChange}
extensions={extensions}
theme="none"
placeholder={placeholder}
height={maxHeight}
minHeight={minHeight}
className="[&_.cm-editor]:min-h-[inherit]"
basicSetup={{
lineNumbers: true,
foldGutter: false,
highlightActiveLine: true,
highlightSelectionMatches: true,
autocompletion: false,
bracketMatching: true,
indentOnInput: true,
}}
/>
</div>
);
}

View File

@@ -72,11 +72,11 @@ import {
useBoardPersistence,
useFollowUpState,
useSelectionMode,
useBoardOnboarding,
} from './board-view/hooks';
import { SelectionActionBar } from './board-view/components';
import { SelectionActionBar, BoardOnboardingWizard } from './board-view/components';
import { MassEditDialog } from './board-view/dialogs';
import { InitScriptIndicator } from './board-view/init-script-indicator';
import { useInitScriptEvents } from '@/hooks/use-init-script-events';
import { generateSampleFeatures, isSampleFeature } from './board-view/constants';
// Stable empty array to avoid infinite loop in selector
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
@@ -101,8 +101,6 @@ export function BoardView() {
useWorktrees,
enableDependencyBlocking,
skipVerificationInAutoMode,
planUseSelectedWorktreeBranch,
addFeatureUseSelectedWorktreeBranch,
isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch,
setPipelineConfig,
@@ -111,12 +109,6 @@ export function BoardView() {
const pipelineConfigByProject = useAppStore((state) => state.pipelineConfigByProject);
// Subscribe to worktreePanelVisibleByProject to trigger re-renders when it changes
const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject);
// Subscribe to showInitScriptIndicatorByProject to trigger re-renders when it changes
const showInitScriptIndicatorByProject = useAppStore(
(state) => state.showInitScriptIndicatorByProject
);
const getShowInitScriptIndicator = useAppStore((state) => state.getShowInitScriptIndicator);
const getDefaultDeleteBranch = useAppStore((state) => state.getDefaultDeleteBranch);
const shortcuts = useKeyboardShortcutsConfig();
const {
features: hookFeatures,
@@ -171,14 +163,12 @@ export function BoardView() {
followUpPrompt,
followUpImagePaths,
followUpPreviewMap,
followUpPromptHistory,
setShowFollowUpDialog,
setFollowUpFeature,
setFollowUpPrompt,
setFollowUpImagePaths,
setFollowUpPreviewMap,
handleFollowUpDialogChange,
addToPromptHistory,
} = useFollowUpState();
// Selection mode hook for mass editing
@@ -198,6 +188,8 @@ export function BoardView() {
const [searchQuery, setSearchQuery] = useState('');
// Plan approval loading state
const [isPlanApprovalLoading, setIsPlanApprovalLoading] = useState(false);
// Quick start loading state for onboarding
const [isQuickStartLoading, setIsQuickStartLoading] = useState(false);
// Derive spec creation state from store - check if current project is the one being created
const isCreatingSpec = specCreatingForProject === currentProject?.path;
const creatingSpecProjectPath = specCreatingForProject ?? undefined;
@@ -262,9 +254,6 @@ export function BoardView() {
// Window state hook for compact dialog mode
const { isMaximized } = useWindowState();
// Init script events hook - subscribe to worktree init script events
useInitScriptEvents(currentProject?.path ?? null);
// Keyboard shortcuts hook will be initialized after actions hook
// Prevent hydration issues
@@ -1043,6 +1032,76 @@ export function BoardView() {
currentProject,
});
// Use onboarding wizard hook - triggered manually via help button
const onboarding = useBoardOnboarding({
projectPath: currentProject?.path || null,
});
// Handler for Quick Start - create sample features
const handleQuickStart = useCallback(async () => {
if (!currentProject) return;
setIsQuickStartLoading(true);
try {
const api = getHttpApiClient();
const sampleFeatures = generateSampleFeatures();
// Create each sample feature
for (const featureData of sampleFeatures) {
const result = await api.features.create(currentProject.path, featureData);
if (result.success && result.feature) {
useAppStore.getState().addFeature(result.feature);
}
}
onboarding.markQuickStartUsed();
toast.success('Sample tasks added!', {
description: 'Explore the board to see tasks at different stages.',
});
// Reload features to ensure state is in sync
loadFeatures();
} catch (error) {
logger.error('Failed to create sample features:', error);
toast.error('Failed to add sample tasks');
} finally {
setIsQuickStartLoading(false);
}
}, [currentProject, loadFeatures, onboarding]);
// Handler for clearing sample data
const handleClearSampleData = useCallback(async () => {
if (!currentProject) return;
const sampleFeatures = hookFeatures.filter((f) => isSampleFeature(f));
if (sampleFeatures.length === 0) {
onboarding.setHasSampleData(false);
return;
}
try {
const api = getHttpApiClient();
const featureIds = sampleFeatures.map((f) => f.id);
const result = await api.features.bulkDelete(currentProject.path, featureIds);
if (result.success || (result.results && result.results.some((r) => r.success))) {
// Remove from local state
const successfullyDeletedIds =
result.results?.filter((r) => r.success).map((r) => r.featureId) ?? featureIds;
successfullyDeletedIds.forEach((id) => {
useAppStore.getState().removeFeature(id);
});
onboarding.setHasSampleData(false);
toast.success('Sample tasks removed');
loadFeatures();
}
} catch (error) {
logger.error('Failed to clear sample data:', error);
toast.error('Failed to remove sample tasks');
}
}, [currentProject, hookFeatures, loadFeatures, onboarding]);
// Find feature for pending plan approval
const pendingApprovalFeature = useMemo(() => {
if (!pendingPlanApproval) return null;
@@ -1225,6 +1284,7 @@ export function BoardView() {
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
onShowCompletedModal={() => setShowCompletedModal(true)}
completedCount={completedFeatures.length}
onStartTour={onboarding.startWizard}
/>
{/* Worktree Panel - conditionally rendered based on visibility setting */}
@@ -1302,8 +1362,6 @@ export function BoardView() {
selectedFeatureIds={selectedFeatureIds}
onToggleFeatureSelection={toggleFeatureSelection}
onToggleSelectionMode={toggleSelectionMode}
isDragging={activeFeature !== null}
onAiSuggest={() => setShowPlanDialog(true)}
/>
</div>
@@ -1374,14 +1432,6 @@ export function BoardView() {
isMaximized={isMaximized}
parentFeature={spawnParentFeature}
allFeatures={hookFeatures}
// When setting is enabled and a non-main worktree is selected, pass its branch to default to 'custom' work mode
selectedNonMainWorktreeBranch={
addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null
? currentWorktreeBranch || undefined
: undefined
}
// When the worktree setting is disabled, force 'current' branch mode
forceCurrentBranchMode={!addFeatureUseSelectedWorktreeBranch}
/>
{/* Edit Feature Dialog */}
@@ -1447,8 +1497,6 @@ export function BoardView() {
onPreviewMapChange={setFollowUpPreviewMap}
onSend={handleSendFollowUp}
isMaximized={isMaximized}
promptHistory={followUpPromptHistory}
onHistoryAdd={addToPromptHistory}
/>
{/* Backlog Plan Dialog */}
@@ -1461,7 +1509,6 @@ export function BoardView() {
setPendingPlanResult={setPendingBacklogPlan}
isGeneratingPlan={isGeneratingPlan}
setIsGeneratingPlan={setIsGeneratingPlan}
currentBranch={planUseSelectedWorktreeBranch ? selectedWorktreeBranch : undefined}
/>
{/* Plan Approval Dialog */}
@@ -1529,7 +1576,6 @@ export function BoardView() {
? hookFeatures.filter((f) => f.branchName === selectedWorktreeForAction.branch).length
: 0
}
defaultDeleteBranch={getDefaultDeleteBranch(currentProject.path)}
onDeleted={(deletedWorktree, _deletedBranch) => {
// Reset features that were assigned to the deleted worktree (by branch)
hookFeatures.forEach((feature) => {
@@ -1598,10 +1644,22 @@ export function BoardView() {
}}
/>
{/* Init Script Indicator - floating overlay for worktree init script status */}
{getShowInitScriptIndicator(currentProject.path) && (
<InitScriptIndicator projectPath={currentProject.path} />
)}
{/* Board Onboarding Wizard */}
<BoardOnboardingWizard
isVisible={onboarding.isWizardVisible}
currentStep={onboarding.currentStep}
currentStepData={onboarding.currentStepData}
totalSteps={onboarding.totalSteps}
onNext={onboarding.goToNextStep}
onPrevious={onboarding.goToPreviousStep}
onSkip={onboarding.skipWizard}
onComplete={onboarding.completeWizard}
onQuickStart={handleQuickStart}
hasSampleData={onboarding.hasSampleData}
onClearSampleData={handleClearSampleData}
isQuickStartLoading={isQuickStartLoading}
steps={onboarding.steps}
/>
</div>
);
}

View File

@@ -1,12 +1,14 @@
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { ImageIcon, Archive } from 'lucide-react';
import { ImageIcon, Archive, HelpCircle } from 'lucide-react';
interface BoardControlsProps {
isMounted: boolean;
onShowBoardBackground: () => void;
onShowCompletedModal: () => void;
completedCount: number;
/** Callback to start the onboarding wizard tour */
onStartTour?: () => void;
}
export function BoardControls({
@@ -14,12 +16,34 @@ export function BoardControls({
onShowBoardBackground,
onShowCompletedModal,
completedCount,
onStartTour,
}: BoardControlsProps) {
if (!isMounted) return null;
return (
<TooltipProvider>
<div className="flex items-center gap-2">
{/* Board Tour Button - always visible when handler is provided */}
{onStartTour && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={onStartTour}
className="h-8 px-2 min-w-[32px] focus-visible:ring-2 focus-visible:ring-primary"
data-testid="board-tour-button"
aria-label="Take a board tour - learn how to use the kanban board"
>
<HelpCircle className="w-4 h-4" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Take a Board Tour</p>
</TooltipContent>
</Tooltip>
)}
{/* Board Background Button */}
<Tooltip>
<TooltipTrigger asChild>

View File

@@ -9,8 +9,6 @@ import { UsagePopover } from '@/components/usage-popover';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog';
import { WorktreeSettingsDialog } from './dialogs/worktree-settings-dialog';
import { PlanSettingsDialog } from './dialogs/plan-settings-dialog';
import { getHttpApiClient } from '@/lib/http-api-client';
import { BoardSearchBar } from './board-search-bar';
import { BoardControls } from './board-controls';
@@ -33,6 +31,8 @@ interface BoardHeaderProps {
onShowBoardBackground: () => void;
onShowCompletedModal: () => void;
completedCount: number;
// Tour/onboarding props
onStartTour?: () => void;
}
// Shared styles for header control containers
@@ -55,24 +55,13 @@ export function BoardHeader({
onShowBoardBackground,
onShowCompletedModal,
completedCount,
onStartTour,
}: BoardHeaderProps) {
const [showAutoModeSettings, setShowAutoModeSettings] = useState(false);
const [showWorktreeSettings, setShowWorktreeSettings] = useState(false);
const [showPlanSettings, setShowPlanSettings] = useState(false);
const apiKeys = useAppStore((state) => state.apiKeys);
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
const skipVerificationInAutoMode = useAppStore((state) => state.skipVerificationInAutoMode);
const setSkipVerificationInAutoMode = useAppStore((state) => state.setSkipVerificationInAutoMode);
const planUseSelectedWorktreeBranch = useAppStore((state) => state.planUseSelectedWorktreeBranch);
const setPlanUseSelectedWorktreeBranch = useAppStore(
(state) => state.setPlanUseSelectedWorktreeBranch
);
const addFeatureUseSelectedWorktreeBranch = useAppStore(
(state) => state.addFeatureUseSelectedWorktreeBranch
);
const setAddFeatureUseSelectedWorktreeBranch = useAppStore(
(state) => state.setAddFeatureUseSelectedWorktreeBranch
);
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
// Worktree panel visibility (per-project)
@@ -127,6 +116,7 @@ export function BoardHeader({
onShowBoardBackground={onShowBoardBackground}
onShowCompletedModal={onShowCompletedModal}
completedCount={completedCount}
onStartTour={onStartTour}
/>
</div>
<div className="flex gap-2 items-center">
@@ -146,25 +136,9 @@ export function BoardHeader({
onCheckedChange={handleWorktreePanelToggle}
data-testid="worktrees-toggle"
/>
<button
onClick={() => setShowWorktreeSettings(true)}
className="p-1 rounded hover:bg-accent/50 transition-colors"
title="Worktree Settings"
data-testid="worktree-settings-button"
>
<Settings2 className="w-4 h-4 text-muted-foreground" />
</button>
</div>
)}
{/* Worktree Settings Dialog */}
<WorktreeSettingsDialog
open={showWorktreeSettings}
onOpenChange={setShowWorktreeSettings}
addFeatureUseSelectedWorktreeBranch={addFeatureUseSelectedWorktreeBranch}
onAddFeatureUseSelectedWorktreeBranchChange={setAddFeatureUseSelectedWorktreeBranch}
/>
{/* Concurrency Control - only show after mount to prevent hydration issues */}
{isMounted && (
<Popover>
@@ -239,33 +213,15 @@ export function BoardHeader({
onSkipVerificationChange={setSkipVerificationInAutoMode}
/>
{/* Plan Button with Settings */}
<div className={controlContainerClass} data-testid="plan-button-container">
<button
onClick={onOpenPlanDialog}
className="flex items-center gap-1.5 hover:text-foreground transition-colors"
data-testid="plan-backlog-button"
>
<Wand2 className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">Plan</span>
</button>
<button
onClick={() => setShowPlanSettings(true)}
className="p-1 rounded hover:bg-accent/50 transition-colors"
title="Plan Settings"
data-testid="plan-settings-button"
>
<Settings2 className="w-4 h-4 text-muted-foreground" />
</button>
</div>
{/* Plan Settings Dialog */}
<PlanSettingsDialog
open={showPlanSettings}
onOpenChange={setShowPlanSettings}
planUseSelectedWorktreeBranch={planUseSelectedWorktreeBranch}
onPlanUseSelectedWorktreeBranchChange={setPlanUseSelectedWorktreeBranch}
/>
<Button
size="sm"
variant="outline"
onClick={onOpenPlanDialog}
data-testid="plan-backlog-button"
>
<Wand2 className="w-4 h-4 mr-2" />
Plan
</Button>
</div>
</div>
);

View File

@@ -0,0 +1,137 @@
/**
* Board Onboarding Wizard Component
*
* Board-specific wrapper around the shared OnboardingWizard component.
* Adds Quick Start functionality to generate sample tasks.
*/
import { Sparkles, CheckCircle2, Trash2, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { OnboardingWizard, type OnboardingStep } from '@/components/shared/onboarding';
interface BoardOnboardingWizardProps {
isVisible: boolean;
currentStep: number;
currentStepData: OnboardingStep | null;
totalSteps: number;
onNext: () => void;
onPrevious: () => void;
onSkip: () => void;
onComplete: () => void;
onQuickStart: () => void;
hasSampleData: boolean;
onClearSampleData: () => void;
isQuickStartLoading?: boolean;
steps: OnboardingStep[];
}
/**
* Quick Start section component - only shown on first step
*/
function QuickStartSection({
onQuickStart,
hasSampleData,
onClearSampleData,
isQuickStartLoading = false,
}: {
onQuickStart: () => void;
hasSampleData: boolean;
onClearSampleData: () => void;
isQuickStartLoading?: boolean;
}) {
return (
<div className="rounded-lg bg-muted/30 border border-border/50 p-4 mb-4">
<h4 className="text-sm font-medium text-foreground mb-2 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-primary" aria-hidden="true" />
Quick Start
</h4>
<p className="text-xs text-muted-foreground mb-3">
Want to see the board in action? We can add some sample tasks to demonstrate the workflow.
</p>
<div className="flex gap-2">
<Button
size="sm"
variant="secondary"
onClick={onQuickStart}
disabled={hasSampleData || isQuickStartLoading}
className={cn('flex-1 min-h-[40px]', 'focus-visible:ring-2 focus-visible:ring-primary')}
aria-busy={isQuickStartLoading}
>
{isQuickStartLoading ? (
<>
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" aria-hidden="true" />
<span>Adding tasks...</span>
</>
) : hasSampleData ? (
<>
<CheckCircle2 className="w-3.5 h-3.5 mr-1.5 text-green-500" aria-hidden="true" />
<span>Sample Data Added</span>
</>
) : (
<>
<Sparkles className="w-3.5 h-3.5 mr-1.5" aria-hidden="true" />
<span>Add Sample Tasks</span>
</>
)}
</Button>
{hasSampleData && (
<Button
size="sm"
variant="ghost"
onClick={onClearSampleData}
className={cn(
'min-w-[44px] min-h-[40px] px-3',
'focus-visible:ring-2 focus-visible:ring-destructive'
)}
aria-label="Remove sample tasks"
>
<Trash2 className="w-4 h-4" aria-hidden="true" />
</Button>
)}
</div>
</div>
);
}
export function BoardOnboardingWizard({
isVisible,
currentStep,
currentStepData,
totalSteps,
onNext,
onPrevious,
onSkip,
onComplete,
onQuickStart,
hasSampleData,
onClearSampleData,
isQuickStartLoading = false,
steps,
}: BoardOnboardingWizardProps) {
const isFirstStep = currentStep === 0;
return (
<OnboardingWizard
isVisible={isVisible}
currentStep={currentStep}
currentStepData={currentStepData}
totalSteps={totalSteps}
onNext={onNext}
onPrevious={onPrevious}
onSkip={onSkip}
onComplete={onComplete}
steps={steps}
>
{/* Board-specific Quick Start section - only on first step */}
{isFirstStep && (
<QuickStartSection
onQuickStart={onQuickStart}
hasSampleData={hasSampleData}
onClearSampleData={onClearSampleData}
isQuickStartLoading={isQuickStartLoading}
/>
)}
</OnboardingWizard>
);
}

View File

@@ -1,120 +0,0 @@
import { memo } from 'react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Kbd } from '@/components/ui/kbd';
import { formatShortcut } from '@/store/app-store';
import { getEmptyStateConfig, type EmptyStateConfig } from '../constants';
import { Lightbulb, Play, Clock, CheckCircle2, Sparkles, Wand2 } from 'lucide-react';
const ICON_MAP = {
lightbulb: Lightbulb,
play: Play,
clock: Clock,
check: CheckCircle2,
sparkles: Sparkles,
} as const;
interface EmptyStateCardProps {
columnId: string;
columnTitle?: string;
/** Keyboard shortcut for adding features (from settings) */
addFeatureShortcut?: string;
/** Whether the column is empty due to active filters */
isFilteredEmpty?: boolean;
/** Whether we're in read-only mode (hide actions) */
isReadOnly?: boolean;
/** Called when user clicks "Use AI Suggestions" */
onAiSuggest?: () => void;
/** Card opacity (matches board settings) */
opacity?: number;
/** Enable glassmorphism effect */
glassmorphism?: boolean;
/** Custom config override for pipeline steps */
customConfig?: Partial<EmptyStateConfig>;
}
export const EmptyStateCard = memo(function EmptyStateCard({
columnId,
addFeatureShortcut,
isFilteredEmpty = false,
isReadOnly = false,
onAiSuggest,
customConfig,
}: EmptyStateCardProps) {
// Get base config and merge with custom overrides
const baseConfig = getEmptyStateConfig(columnId);
const config: EmptyStateConfig = { ...baseConfig, ...customConfig };
const IconComponent = ICON_MAP[config.icon];
const showActions = !isReadOnly && !isFilteredEmpty;
const showShortcut = columnId === 'backlog' && addFeatureShortcut && showActions;
// Action button handler
const handlePrimaryAction = () => {
if (!config.primaryAction) return;
if (config.primaryAction.actionType === 'ai-suggest') {
onAiSuggest?.();
}
};
return (
<div
className={cn(
'w-full h-full min-h-[200px] flex-1',
'flex flex-col items-center justify-center',
'text-center px-4',
'transition-all duration-300 ease-out',
'animate-in fade-in duration-300',
'group'
)}
data-testid={`empty-state-card-${columnId}`}
>
{/* Icon */}
<div className="mb-3 text-muted-foreground/30">
<IconComponent className="w-8 h-8" />
</div>
{/* Title */}
<h4 className="font-medium text-sm text-muted-foreground/50 mb-1">
{isFilteredEmpty ? 'No Matching Items' : config.title}
</h4>
{/* Description */}
<p className="text-xs text-muted-foreground/40 leading-relaxed max-w-[180px]">
{isFilteredEmpty ? 'No features match your current filters.' : config.description}
</p>
{/* Keyboard shortcut hint for backlog */}
{showShortcut && (
<div className="flex items-center gap-1.5 mt-3 text-muted-foreground/40">
<span className="text-xs">Press</span>
<Kbd className="bg-muted/30 border-0 px-1.5 py-0.5 text-[10px] text-muted-foreground/50">
{formatShortcut(addFeatureShortcut, true)}
</Kbd>
<span className="text-xs">to add</span>
</div>
)}
{/* AI Suggest action for backlog */}
{showActions && config.primaryAction && config.primaryAction.actionType === 'ai-suggest' && (
<Button
variant="ghost"
size="sm"
className="mt-4 h-7 text-xs text-muted-foreground/50 hover:text-muted-foreground/70"
onClick={handlePrimaryAction}
data-testid={`empty-state-primary-action-${columnId}`}
>
<Wand2 className="w-3 h-3 mr-1.5" />
{config.primaryAction.label}
</Button>
)}
{/* Filtered empty state hint */}
{isFilteredEmpty && (
<p className="text-[10px] mt-2 text-muted-foreground/30 italic">
Clear filters to see all items
</p>
)}
</div>
);
});

View File

@@ -1,4 +1,4 @@
export { KanbanCard } from './kanban-card/kanban-card';
export { KanbanColumn } from './kanban-column';
export { SelectionActionBar } from './selection-action-bar';
export { EmptyStateCard } from './empty-state-card';
export { BoardOnboardingWizard } from './board-onboarding-wizard';

View File

@@ -50,6 +50,7 @@ export const KanbanColumn = memo(function KanbanColumn({
)}
style={widthStyle}
data-testid={`kanban-column-${id}`}
data-onboarding-target={id}
>
{/* Background layer with opacity */}
<div

View File

@@ -3,69 +3,6 @@ import type { PipelineConfig, FeatureStatusWithPipeline } from '@automaker/types
export type ColumnId = Feature['status'];
/**
* Empty state configuration for each column type
*/
export interface EmptyStateConfig {
title: string;
description: string;
icon: 'lightbulb' | 'play' | 'clock' | 'check' | 'sparkles';
shortcutKey?: string; // Keyboard shortcut label (e.g., 'N', 'A')
shortcutHint?: string; // Human-readable shortcut hint
primaryAction?: {
label: string;
actionType: 'ai-suggest' | 'none';
};
}
/**
* Default empty state configurations per column type
*/
export const EMPTY_STATE_CONFIGS: Record<string, EmptyStateConfig> = {
backlog: {
title: 'Ready for Ideas',
description:
'Add your first feature idea to get started using the button below, or let AI help generate ideas.',
icon: 'lightbulb',
shortcutHint: 'Press',
primaryAction: {
label: 'Use AI Suggestions',
actionType: 'none',
},
},
in_progress: {
title: 'Nothing in Progress',
description: 'Drag a feature from the backlog here or click implement to start working on it.',
icon: 'play',
},
waiting_approval: {
title: 'No Items Awaiting Approval',
description: 'Features will appear here after implementation is complete and need your review.',
icon: 'clock',
},
verified: {
title: 'No Verified Features',
description: 'Approved features will appear here. They can then be completed and archived.',
icon: 'check',
},
// Pipeline step default configuration
pipeline_default: {
title: 'Pipeline Step Empty',
description: 'Features will flow through this step during the automated pipeline process.',
icon: 'sparkles',
},
};
/**
* Get empty state config for a column, with fallback for pipeline columns
*/
export function getEmptyStateConfig(columnId: string): EmptyStateConfig {
if (columnId.startsWith('pipeline_')) {
return EMPTY_STATE_CONFIGS.pipeline_default;
}
return EMPTY_STATE_CONFIGS[columnId] || EMPTY_STATE_CONFIGS.default;
}
export interface Column {
id: FeatureStatusWithPipeline;
title: string;
@@ -152,3 +89,117 @@ export function getStepIdFromStatus(status: string): string | null {
}
return status.replace('pipeline_', '');
}
// ============================================================================
// SAMPLE DATA FOR ONBOARDING WIZARD
// ============================================================================
/**
* Prefix used to identify sample/demo features in the board
* This marker persists through the database and is used for cleanup
*/
export const SAMPLE_FEATURE_PREFIX = '[DEMO]';
/**
* Sample feature template for Quick Start onboarding
* These demonstrate a typical workflow progression across columns
*/
export interface SampleFeatureTemplate {
title: string;
description: string;
category: string;
status: Feature['status'];
priority: number;
isSampleData: true; // Marker to identify sample data
}
/**
* Sample features that demonstrate the workflow across all columns.
* Each feature shows a realistic task at different stages.
*/
export const SAMPLE_FEATURES: SampleFeatureTemplate[] = [
// Backlog items - awaiting work
{
title: '[DEMO] Add user profile page',
description:
'Create a user profile page where users can view and edit their account settings, change password, and manage preferences.\n\n---\n**This is sample data** - Click the trash icon in the wizard to remove all demo items.',
category: 'Feature',
status: 'backlog',
priority: 1,
isSampleData: true,
},
{
title: '[DEMO] Implement dark mode toggle',
description:
'Add a toggle in the settings to switch between light and dark themes. Should persist the preference across sessions.\n\n---\n**This is sample data** - Click the trash icon in the wizard to remove all demo items.',
category: 'Enhancement',
status: 'backlog',
priority: 2,
isSampleData: true,
},
// In Progress - currently being worked on
{
title: '[DEMO] Fix login timeout issue',
description:
'Users are being logged out after 5 minutes of inactivity. Investigate and increase the session timeout to 30 minutes.\n\n---\n**This is sample data** - Click the trash icon in the wizard to remove all demo items.',
category: 'Bug Fix',
status: 'in_progress',
priority: 1,
isSampleData: true,
},
// Waiting Approval - completed and awaiting review
{
title: '[DEMO] Update API documentation',
description:
'Update the API documentation to reflect recent endpoint changes and add examples for new authentication flow.\n\n---\n**This is sample data** - Click the trash icon in the wizard to remove all demo items.',
category: 'Documentation',
status: 'waiting_approval',
priority: 2,
isSampleData: true,
},
// Verified - approved and ready
{
title: '[DEMO] Add loading spinners',
description:
'Added loading spinner components to all async operations to improve user feedback during data fetching.\n\n---\n**This is sample data** - Click the trash icon in the wizard to remove all demo items.',
category: 'Enhancement',
status: 'verified',
priority: 3,
isSampleData: true,
},
];
/**
* Check if a feature is sample data
* Uses the SAMPLE_FEATURE_PREFIX in the title as the marker for sample data
*/
export function isSampleFeature(feature: Partial<Feature>): boolean {
// Check title prefix - this is the reliable marker that persists through the database
return feature.title?.startsWith(SAMPLE_FEATURE_PREFIX) ?? false;
}
/**
* Generate sample feature data with unique IDs
* @returns Array of sample features ready to be created
*/
export function generateSampleFeatures(): Array<Omit<Feature, 'id' | 'createdAt' | 'updatedAt'>> {
return SAMPLE_FEATURES.map((template) => ({
title: template.title,
description: template.description,
category: template.category,
status: template.status,
priority: template.priority,
images: [],
imagePaths: [],
skipTests: true,
model: 'sonnet' as const,
thinkingLevel: 'none' as const,
planningMode: 'skip' as const,
requirePlanApproval: false,
// Mark as sample data in a way that persists
// We use the title prefix [DEMO] as the marker
}));
}

View File

@@ -21,9 +21,11 @@ import {
FeatureTextFilePath as DescriptionTextFilePath,
ImagePreviewMap,
} from '@/components/ui/description-image-dropzone';
import { Play, Cpu, FolderKanban } from 'lucide-react';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Sparkles, ChevronDown, ChevronRight, Play, Cpu, FolderKanban } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { modelSupportsThinking } from '@/lib/utils';
import {
useAppStore,
@@ -41,12 +43,16 @@ import {
WorkModeSelector,
PlanningModeSelect,
AncestorContextSection,
EnhanceWithAI,
EnhancementHistoryButton,
type BaseHistoryEntry,
} from '../shared';
import type { WorkMode } from '../shared';
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import {
getAncestors,
@@ -56,32 +62,6 @@ import {
const logger = createLogger('AddFeatureDialog');
/**
* Determines the default work mode based on global settings and current worktree selection.
*
* Priority:
* 1. If forceCurrentBranchMode is true, always defaults to 'current' (work on current branch)
* 2. If a non-main worktree is selected in the board header, defaults to 'custom' (use that branch)
* 3. If useWorktrees global setting is enabled, defaults to 'auto' (automatic worktree creation)
* 4. Otherwise, defaults to 'current' (work on current branch without isolation)
*/
const getDefaultWorkMode = (
useWorktrees: boolean,
selectedNonMainWorktreeBranch?: string,
forceCurrentBranchMode?: boolean
): WorkMode => {
// If force current branch mode is enabled (worktree setting is off), always use 'current'
if (forceCurrentBranchMode) {
return 'current';
}
// If a non-main worktree is selected, default to 'custom' mode with that branch
if (selectedNonMainWorktreeBranch) {
return 'custom';
}
// Otherwise, respect the global worktree setting
return useWorktrees ? 'auto' : 'current';
};
type FeatureData = {
title: string;
category: string;
@@ -115,23 +95,6 @@ interface AddFeatureDialogProps {
isMaximized: boolean;
parentFeature?: Feature | null;
allFeatures?: Feature[];
/**
* When a non-main worktree is selected in the board header, this will be set to that worktree's branch.
* When set, the dialog will default to 'custom' work mode with this branch pre-filled.
*/
selectedNonMainWorktreeBranch?: string;
/**
* When true, forces the dialog to default to 'current' work mode (work on current branch).
* This is used when the "Use selected worktree branch" setting is disabled.
*/
forceCurrentBranchMode?: boolean;
}
/**
* A single entry in the description history
*/
interface DescriptionHistoryEntry extends BaseHistoryEntry {
description: string;
}
export function AddFeatureDialog({
@@ -148,8 +111,6 @@ export function AddFeatureDialog({
isMaximized,
parentFeature = null,
allFeatures = [],
selectedNonMainWorktreeBranch,
forceCurrentBranchMode,
}: AddFeatureDialogProps) {
const isSpawnMode = !!parentFeature;
const [workMode, setWorkMode] = useState<WorkMode>('current');
@@ -178,16 +139,21 @@ export function AddFeatureDialog({
// UI state
const [previewMap, setPreviewMap] = useState<ImagePreviewMap>(() => new Map());
const [descriptionError, setDescriptionError] = useState(false);
// Description history state
const [descriptionHistory, setDescriptionHistory] = useState<DescriptionHistoryEntry[]>([]);
const [isEnhancing, setIsEnhancing] = useState(false);
const [enhancementMode, setEnhancementMode] = useState<
'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
>('improve');
const [enhanceOpen, setEnhanceOpen] = useState(false);
// Spawn mode state
const [ancestors, setAncestors] = useState<AncestorContext[]>([]);
const [selectedAncestorIds, setSelectedAncestorIds] = useState<Set<string>>(new Set());
// Get defaults from store
const { defaultPlanningMode, defaultRequirePlanApproval, useWorktrees } = useAppStore();
const { defaultPlanningMode, defaultRequirePlanApproval } = useAppStore();
// Enhancement model override
const enhancementOverride = useModelOverride({ phase: 'enhancementModel' });
// Track previous open state to detect when dialog opens
const wasOpenRef = useRef(false);
@@ -199,19 +165,12 @@ export function AddFeatureDialog({
if (justOpened) {
setSkipTests(defaultSkipTests);
// When a non-main worktree is selected, use its branch name for custom mode
// Otherwise, use the default branch
setBranchName(selectedNonMainWorktreeBranch || defaultBranch || '');
setWorkMode(
getDefaultWorkMode(useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode)
);
setBranchName(defaultBranch || '');
setWorkMode('current');
setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval);
setModelEntry({ model: 'opus' });
// Initialize description history (empty for new feature)
setDescriptionHistory([]);
// Initialize ancestors for spawn mode
if (parentFeature) {
const ancestorList = getAncestors(parentFeature, allFeatures);
@@ -228,9 +187,6 @@ export function AddFeatureDialog({
defaultBranch,
defaultPlanningMode,
defaultRequirePlanApproval,
useWorktrees,
selectedNonMainWorktreeBranch,
forceCurrentBranchMode,
parentFeature,
allFeatures,
]);
@@ -315,18 +271,15 @@ export function AddFeatureDialog({
setImagePaths([]);
setTextFilePaths([]);
setSkipTests(defaultSkipTests);
// When a non-main worktree is selected, use its branch name for custom mode
setBranchName(selectedNonMainWorktreeBranch || '');
setBranchName('');
setPriority(2);
setModelEntry({ model: 'opus' });
setWorkMode(
getDefaultWorkMode(useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode)
);
setWorkMode('current');
setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval);
setPreviewMap(new Map());
setDescriptionError(false);
setDescriptionHistory([]);
setEnhanceOpen(false);
onOpenChange(false);
};
@@ -349,6 +302,33 @@ export function AddFeatureDialog({
}
};
const handleEnhanceDescription = async () => {
if (!description.trim() || isEnhancing) return;
setIsEnhancing(true);
try {
const api = getElectronAPI();
const result = await api.enhancePrompt?.enhance(
description,
enhancementMode,
enhancementOverride.effectiveModel,
enhancementOverride.effectiveModelEntry.thinkingLevel
);
if (result?.success && result.enhancedText) {
setDescription(result.enhancedText);
toast.success('Description enhanced!');
} else {
toast.error(result?.error || 'Failed to enhance description');
}
} catch (error) {
logger.error('Enhancement failed:', error);
toast.error('Failed to enhance description');
} finally {
setIsEnhancing(false);
}
};
// Shared card styling
const cardClass = 'rounded-lg border border-border/50 bg-muted/30 p-4 space-y-3';
const sectionHeaderClass = 'flex items-center gap-2 text-sm font-medium text-foreground';
@@ -400,18 +380,7 @@ export function AddFeatureDialog({
{/* Task Details Section */}
<div className={cardClass}>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="description">Description</Label>
{/* Version History Button */}
<EnhancementHistoryButton
history={descriptionHistory}
currentValue={description}
onRestore={setDescription}
valueAccessor={(entry) => entry.description}
title="Version History"
restoreMessage="Description restored from history"
/>
</div>
<Label htmlFor="description">Description</Label>
<DescriptionImageDropZone
value={description}
onChange={(value) => {
@@ -440,35 +409,75 @@ export function AddFeatureDialog({
/>
</div>
{/* Enhancement Section */}
<EnhanceWithAI
value={description}
onChange={setDescription}
onHistoryAdd={({ mode, originalText, enhancedText }) => {
const timestamp = new Date().toISOString();
setDescriptionHistory((prev) => {
const newHistory = [...prev];
// Add original text first (so user can restore to pre-enhancement state)
// Only add if it's different from the last entry to avoid duplicates
const lastEntry = prev[prev.length - 1];
if (!lastEntry || lastEntry.description !== originalText) {
newHistory.push({
description: originalText,
timestamp,
source: prev.length === 0 ? 'initial' : 'edit',
});
}
// Add enhanced text
newHistory.push({
description: enhancedText,
timestamp,
source: 'enhance',
enhancementMode: mode,
});
return newHistory;
});
}}
/>
{/* Collapsible Enhancement Section */}
<Collapsible open={enhanceOpen} onOpenChange={setEnhanceOpen}>
<CollapsibleTrigger asChild>
<button className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full py-1">
{enhanceOpen ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
<Sparkles className="w-4 h-4" />
<span>Enhance with AI</span>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-3">
<div className="flex flex-wrap items-center gap-2 pl-6">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 text-xs">
{enhancementMode === 'improve' && 'Improve Clarity'}
{enhancementMode === 'technical' && 'Add Technical Details'}
{enhancementMode === 'simplify' && 'Simplify'}
{enhancementMode === 'acceptance' && 'Add Acceptance Criteria'}
{enhancementMode === 'ux-reviewer' && 'User Experience'}
<ChevronDown className="w-3 h-3 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}>
Improve Clarity
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}>
Add Technical Details
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}>
Simplify
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
Add Acceptance Criteria
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('ux-reviewer')}>
User Experience
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant="default"
size="sm"
className="h-8 text-xs"
onClick={handleEnhanceDescription}
disabled={!description.trim() || isEnhancing}
loading={isEnhancing}
>
<Sparkles className="w-3 h-3 mr-1" />
Enhance
</Button>
<ModelOverrideTrigger
currentModelEntry={enhancementOverride.effectiveModelEntry}
onModelChange={enhancementOverride.setOverride}
phase="enhancementModel"
isOverridden={enhancementOverride.isOverridden}
size="sm"
variant="icon"
/>
</div>
</CollapsibleContent>
</Collapsible>
</div>
{/* AI & Execution Section */}

View File

@@ -63,8 +63,6 @@ interface BacklogPlanDialogProps {
setPendingPlanResult: (result: BacklogPlanResult | null) => void;
isGeneratingPlan: boolean;
setIsGeneratingPlan: (generating: boolean) => void;
// Branch to use for created features (defaults to 'main' when applying)
currentBranch?: string;
}
type DialogMode = 'input' | 'review' | 'applying';
@@ -78,7 +76,6 @@ export function BacklogPlanDialog({
setPendingPlanResult,
isGeneratingPlan,
setIsGeneratingPlan,
currentBranch,
}: BacklogPlanDialogProps) {
const [mode, setMode] = useState<DialogMode>('input');
const [prompt, setPrompt] = useState('');
@@ -170,11 +167,7 @@ export function BacklogPlanDialog({
}) || [],
};
const result = await api.backlogPlan.apply(
projectPath,
filteredPlanResult,
currentBranch ?? 'main'
);
const result = await api.backlogPlan.apply(projectPath, filteredPlanResult);
if (result.success) {
toast.success(`Applied ${result.appliedChanges?.length || 0} changes`);
setPendingPlanResult(null);
@@ -191,7 +184,6 @@ export function BacklogPlanDialog({
setPendingPlanResult,
onPlanApplied,
onClose,
currentBranch,
]);
const handleDiscard = useCallback(() => {

View File

@@ -10,73 +10,10 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { GitBranch, Loader2, AlertCircle } from 'lucide-react';
import { GitBranch, Loader2 } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
/**
* Parse git/worktree error messages and return user-friendly versions
*/
function parseWorktreeError(error: string): { title: string; description?: string } {
const errorLower = error.toLowerCase();
// Worktree already exists
if (errorLower.includes('already exists') && errorLower.includes('worktree')) {
return {
title: 'A worktree with this name already exists',
description: 'Try a different branch name or delete the existing worktree first.',
};
}
// Branch already checked out in another worktree
if (
errorLower.includes('already checked out') ||
errorLower.includes('is already used by worktree')
) {
return {
title: 'This branch is already in use',
description: 'The branch is checked out in another worktree. Use a different branch name.',
};
}
// Branch name conflicts with existing branch
if (errorLower.includes('already exists') && errorLower.includes('branch')) {
return {
title: 'A branch with this name already exists',
description: 'The worktree will use the existing branch, or try a different name.',
};
}
// Not a git repository
if (errorLower.includes('not a git repository')) {
return {
title: 'Not a git repository',
description: 'Initialize git in this project first with "git init".',
};
}
// Lock file exists (another git operation in progress)
if (errorLower.includes('.lock') || errorLower.includes('lock file')) {
return {
title: 'Another git operation is in progress',
description: 'Wait for it to complete or remove stale lock files.',
};
}
// Permission denied
if (errorLower.includes('permission denied') || errorLower.includes('access denied')) {
return {
title: 'Permission denied',
description: 'Check file permissions for the project directory.',
};
}
// Default: return original error but cleaned up
return {
title: error.replace(/^(fatal|error):\s*/i, '').split('\n')[0],
};
}
interface CreatedWorktreeInfo {
path: string;
branch: string;
@@ -97,21 +34,20 @@ export function CreateWorktreeDialog({
}: CreateWorktreeDialogProps) {
const [branchName, setBranchName] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<{ title: string; description?: string } | null>(null);
const [error, setError] = useState<string | null>(null);
const handleCreate = async () => {
if (!branchName.trim()) {
setError({ title: 'Branch name is required' });
setError('Branch name is required');
return;
}
// Validate branch name (git-compatible)
const validBranchRegex = /^[a-zA-Z0-9._/-]+$/;
if (!validBranchRegex.test(branchName)) {
setError({
title: 'Invalid branch name',
description: 'Use only letters, numbers, dots, underscores, hyphens, and slashes.',
});
setError(
'Invalid branch name. Use only letters, numbers, dots, underscores, hyphens, and slashes.'
);
return;
}
@@ -121,7 +57,7 @@ export function CreateWorktreeDialog({
try {
const api = getElectronAPI();
if (!api?.worktree?.create) {
setError({ title: 'Worktree API not available' });
setError('Worktree API not available');
return;
}
const result = await api.worktree.create(projectPath, branchName);
@@ -134,12 +70,10 @@ export function CreateWorktreeDialog({
onOpenChange(false);
setBranchName('');
} else {
setError(parseWorktreeError(result.error || 'Failed to create worktree'));
setError(result.error || 'Failed to create worktree');
}
} catch (err) {
setError(
parseWorktreeError(err instanceof Error ? err.message : 'Failed to create worktree')
);
setError(err instanceof Error ? err.message : 'Failed to create worktree');
} finally {
setIsLoading(false);
}
@@ -180,17 +114,7 @@ export function CreateWorktreeDialog({
className="font-mono text-sm"
autoFocus
/>
{error && (
<div className="flex items-start gap-2 p-3 rounded-md bg-destructive/10 border border-destructive/20">
<AlertCircle className="w-4 h-4 text-destructive mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p className="text-sm font-medium text-destructive">{error.title}</p>
{error.description && (
<p className="text-xs text-destructive/80">{error.description}</p>
)}
</div>
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<div className="text-xs text-muted-foreground space-y-1">

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState } from 'react';
import {
Dialog,
DialogContent,
@@ -30,8 +30,6 @@ interface DeleteWorktreeDialogProps {
onDeleted: (deletedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
/** Number of features assigned to this worktree's branch */
affectedFeatureCount?: number;
/** Default value for the "delete branch" checkbox */
defaultDeleteBranch?: boolean;
}
export function DeleteWorktreeDialog({
@@ -41,18 +39,10 @@ export function DeleteWorktreeDialog({
worktree,
onDeleted,
affectedFeatureCount = 0,
defaultDeleteBranch = false,
}: DeleteWorktreeDialogProps) {
const [deleteBranch, setDeleteBranch] = useState(defaultDeleteBranch);
const [deleteBranch, setDeleteBranch] = useState(false);
const [isLoading, setIsLoading] = useState(false);
// Reset deleteBranch to default when dialog opens
useEffect(() => {
if (open) {
setDeleteBranch(defaultDeleteBranch);
}
}, [open, defaultDeleteBranch]);
const handleDelete = async () => {
if (!worktree) return;

View File

@@ -21,8 +21,18 @@ import {
FeatureTextFilePath as DescriptionTextFilePath,
ImagePreviewMap,
} from '@/components/ui/description-image-dropzone';
import { GitBranch, Cpu, FolderKanban } from 'lucide-react';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import {
Sparkles,
ChevronDown,
ChevronRight,
GitBranch,
History,
Cpu,
FolderKanban,
} from 'lucide-react';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { cn, modelSupportsThinking } from '@/lib/utils';
import { Feature, ModelAlias, ThinkingLevel, useAppStore, PlanningMode } from '@/store/app-store';
import type { ReasoningEffort, PhaseModelEntry, DescriptionHistoryEntry } from '@automaker/types';
@@ -31,12 +41,17 @@ import {
PrioritySelector,
WorkModeSelector,
PlanningModeSelect,
EnhanceWithAI,
EnhancementHistoryButton,
type EnhancementMode,
} from '../shared';
import type { WorkMode } from '../shared';
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { DependencyTreeDialog } from './dependency-tree-dialog';
import { isClaudeModel, supportsReasoningEffort } from '@automaker/types';
@@ -64,8 +79,7 @@ interface EditFeatureDialogProps {
requirePlanApproval: boolean;
},
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: EnhancementMode,
preEnhancementDescription?: string
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
) => void;
categorySuggestions: string[];
branchSuggestions: string[];
@@ -96,6 +110,11 @@ export function EditFeatureDialog({
const [editFeaturePreviewMap, setEditFeaturePreviewMap] = useState<ImagePreviewMap>(
() => new Map()
);
const [isEnhancing, setIsEnhancing] = useState(false);
const [enhancementMode, setEnhancementMode] = useState<
'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
>('improve');
const [enhanceOpen, setEnhanceOpen] = useState(false);
const [showDependencyTree, setShowDependencyTree] = useState(false);
const [planningMode, setPlanningMode] = useState<PlanningMode>(feature?.planningMode ?? 'skip');
const [requirePlanApproval, setRequirePlanApproval] = useState(
@@ -114,16 +133,15 @@ export function EditFeatureDialog({
// Track the source of description changes for history
const [descriptionChangeSource, setDescriptionChangeSource] = useState<
{ source: 'enhance'; mode: EnhancementMode } | 'edit' | null
{ source: 'enhance'; mode: 'improve' | 'technical' | 'simplify' | 'acceptance' } | 'edit' | null
>(null);
// Track the original description when the dialog opened for comparison
const [originalDescription, setOriginalDescription] = useState(feature?.description ?? '');
// Track the description before enhancement (so it can be restored)
const [preEnhancementDescription, setPreEnhancementDescription] = useState<string | null>(null);
// Local history state for real-time display (combines persisted + session history)
const [localHistory, setLocalHistory] = useState<DescriptionHistoryEntry[]>(
feature?.descriptionHistory ?? []
);
// Track if history dropdown is open
const [showHistory, setShowHistory] = useState(false);
// Enhancement model override
const enhancementOverride = useModelOverride({ phase: 'enhancementModel' });
useEffect(() => {
setEditingFeature(feature);
@@ -135,8 +153,8 @@ export function EditFeatureDialog({
// Reset history tracking state
setOriginalDescription(feature.description ?? '');
setDescriptionChangeSource(null);
setPreEnhancementDescription(null);
setLocalHistory(feature.descriptionHistory ?? []);
setShowHistory(false);
setEnhanceOpen(false);
// Reset model entry
setModelEntry({
model: (feature.model as ModelAlias) || 'opus',
@@ -146,8 +164,7 @@ export function EditFeatureDialog({
} else {
setEditFeaturePreviewMap(new Map());
setDescriptionChangeSource(null);
setPreEnhancementDescription(null);
setLocalHistory([]);
setShowHistory(false);
}
}, [feature]);
@@ -209,13 +226,7 @@ export function EditFeatureDialog({
}
}
onUpdate(
editingFeature.id,
updates,
historySource,
historyEnhancementMode,
preEnhancementDescription ?? undefined
);
onUpdate(editingFeature.id, updates, historySource, historyEnhancementMode);
setEditFeaturePreviewMap(new Map());
onClose();
};
@@ -226,6 +237,36 @@ export function EditFeatureDialog({
}
};
const handleEnhanceDescription = async () => {
if (!editingFeature?.description.trim() || isEnhancing) return;
setIsEnhancing(true);
try {
const api = getElectronAPI();
const result = await api.enhancePrompt?.enhance(
editingFeature.description,
enhancementMode,
enhancementOverride.effectiveModel, // API accepts string, extract from PhaseModelEntry
enhancementOverride.effectiveModelEntry.thinkingLevel // Pass thinking level
);
if (result?.success && result.enhancedText) {
const enhancedText = result.enhancedText;
setEditingFeature((prev) => (prev ? { ...prev, description: enhancedText } : prev));
// Track that this change was from enhancement
setDescriptionChangeSource({ source: 'enhance', mode: enhancementMode });
toast.success('Description enhanced!');
} else {
toast.error(result?.error || 'Failed to enhance description');
}
} catch (error) {
logger.error('Enhancement failed:', error);
toast.error('Failed to enhance description');
} finally {
setIsEnhancing(false);
}
};
if (!editingFeature) {
return null;
}
@@ -263,18 +304,95 @@ export function EditFeatureDialog({
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="edit-description">Description</Label>
{/* Version History Button - uses local history for real-time updates */}
<EnhancementHistoryButton
history={localHistory}
currentValue={editingFeature.description}
onRestore={(description) => {
setEditingFeature((prev) => (prev ? { ...prev, description } : prev));
setDescriptionChangeSource('edit');
}}
valueAccessor={(entry) => entry.description}
title="Version History"
restoreMessage="Description restored from history"
/>
{/* Version History Button */}
{feature?.descriptionHistory && feature.descriptionHistory.length > 0 && (
<Popover open={showHistory} onOpenChange={setShowHistory}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1.5 text-xs text-muted-foreground"
>
<History className="w-3.5 h-3.5" />
History ({feature.descriptionHistory.length})
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
<div className="p-3 border-b">
<h4 className="font-medium text-sm">Version History</h4>
<p className="text-xs text-muted-foreground mt-1">
Click a version to restore it
</p>
</div>
<div className="max-h-64 overflow-y-auto p-2 space-y-1">
{[...(feature.descriptionHistory || [])]
.reverse()
.map((entry: DescriptionHistoryEntry, index: number) => {
const isCurrentVersion =
entry.description === editingFeature.description;
const date = new Date(entry.timestamp);
const formattedDate = date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
const getEnhancementModeLabel = (mode?: string) => {
const labels: Record<string, string> = {
improve: 'Improve Clarity',
technical: 'Add Technical Details',
simplify: 'Simplify',
acceptance: 'Add Acceptance Criteria',
'ux-reviewer': 'User Experience',
};
return labels[mode || 'improve'] || mode || 'improve';
};
const sourceLabel =
entry.source === 'initial'
? 'Original'
: entry.source === 'enhance'
? `Enhanced (${getEnhancementModeLabel(entry.enhancementMode)})`
: 'Edited';
return (
<button
key={`${entry.timestamp}-${index}`}
onClick={() => {
setEditingFeature((prev) =>
prev ? { ...prev, description: entry.description } : prev
);
// Mark as edit since user is restoring from history
setDescriptionChangeSource('edit');
setShowHistory(false);
toast.success('Description restored from history');
}}
className={`w-full text-left p-2 rounded-md hover:bg-muted transition-colors ${
isCurrentVersion ? 'bg-muted/50 border border-primary/20' : ''
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">{sourceLabel}</span>
<span className="text-xs text-muted-foreground">
{formattedDate}
</span>
</div>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{entry.description.slice(0, 100)}
{entry.description.length > 100 ? '...' : ''}
</p>
{isCurrentVersion && (
<span className="text-xs text-primary font-medium mt-1 block">
Current version
</span>
)}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
)}
</div>
<DescriptionImageDropZone
value={editingFeature.description}
@@ -325,40 +443,75 @@ export function EditFeatureDialog({
/>
</div>
{/* Enhancement Section */}
<EnhanceWithAI
value={editingFeature.description}
onChange={(enhanced) =>
setEditingFeature((prev) => (prev ? { ...prev, description: enhanced } : prev))
}
onHistoryAdd={({ mode, originalText, enhancedText }) => {
setDescriptionChangeSource({ source: 'enhance', mode });
setPreEnhancementDescription(originalText);
{/* Collapsible Enhancement Section */}
<Collapsible open={enhanceOpen} onOpenChange={setEnhanceOpen}>
<CollapsibleTrigger asChild>
<button className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full py-1">
{enhanceOpen ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
<Sparkles className="w-4 h-4" />
<span>Enhance with AI</span>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-3">
<div className="flex flex-wrap items-center gap-2 pl-6">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 text-xs">
{enhancementMode === 'improve' && 'Improve Clarity'}
{enhancementMode === 'technical' && 'Add Technical Details'}
{enhancementMode === 'simplify' && 'Simplify'}
{enhancementMode === 'acceptance' && 'Add Acceptance Criteria'}
{enhancementMode === 'ux-reviewer' && 'User Experience'}
<ChevronDown className="w-3 h-3 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}>
Improve Clarity
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}>
Add Technical Details
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}>
Simplify
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
Add Acceptance Criteria
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEnhancementMode('ux-reviewer')}>
User Experience
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
// Update local history for real-time display
const timestamp = new Date().toISOString();
setLocalHistory((prev) => {
const newHistory = [...prev];
// Add original text first (so user can restore to pre-enhancement state)
const lastEntry = prev[prev.length - 1];
if (!lastEntry || lastEntry.description !== originalText) {
newHistory.push({
description: originalText,
timestamp,
source: prev.length === 0 ? 'initial' : 'edit',
});
}
// Add enhanced text
newHistory.push({
description: enhancedText,
timestamp,
source: 'enhance',
enhancementMode: mode,
});
return newHistory;
});
}}
/>
<Button
type="button"
variant="default"
size="sm"
className="h-8 text-xs"
onClick={handleEnhanceDescription}
disabled={!editingFeature.description.trim() || isEnhancing}
loading={isEnhancing}
>
<Sparkles className="w-3 h-3 mr-1" />
Enhance
</Button>
<ModelOverrideTrigger
currentModelEntry={enhancementOverride.effectiveModelEntry}
onModelChange={enhancementOverride.setOverride}
phase="enhancementModel"
isOverridden={enhancementOverride.isOverridden}
size="sm"
variant="icon"
/>
</div>
</CollapsibleContent>
</Collapsible>
</div>
{/* AI & Execution Section */}

View File

@@ -1,5 +1,4 @@
import { useState } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {
Dialog,
DialogContent,
@@ -18,21 +17,6 @@ import {
} from '@/components/ui/description-image-dropzone';
import { MessageSquare } from 'lucide-react';
import { Feature } from '@/store/app-store';
import {
EnhanceWithAI,
EnhancementHistoryButton,
type EnhancementMode,
type BaseHistoryEntry,
} from '../shared';
const logger = createLogger('FollowUpDialog');
/**
* A single entry in the follow-up prompt history
*/
export interface FollowUpHistoryEntry extends BaseHistoryEntry {
prompt: string;
}
interface FollowUpDialogProps {
open: boolean;
@@ -46,10 +30,6 @@ interface FollowUpDialogProps {
onPreviewMapChange: (map: ImagePreviewMap) => void;
onSend: () => void;
isMaximized: boolean;
/** History of prompt versions for restoration */
promptHistory?: FollowUpHistoryEntry[];
/** Callback to add a new entry to prompt history */
onHistoryAdd?: (entry: FollowUpHistoryEntry) => void;
}
export function FollowUpDialog({
@@ -64,11 +44,9 @@ export function FollowUpDialog({
onPreviewMapChange,
onSend,
isMaximized,
promptHistory = [],
onHistoryAdd,
}: FollowUpDialogProps) {
const handleClose = (openState: boolean) => {
if (!openState) {
const handleClose = (open: boolean) => {
if (!open) {
onOpenChange(false);
}
};
@@ -99,18 +77,7 @@ export function FollowUpDialog({
</DialogHeader>
<div className="space-y-4 py-4 overflow-y-auto flex-1 min-h-0">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="follow-up-prompt">Instructions</Label>
{/* Version History Button */}
<EnhancementHistoryButton
history={promptHistory}
currentValue={prompt}
onRestore={onPromptChange}
valueAccessor={(entry) => entry.prompt}
title="Prompt History"
restoreMessage="Prompt restored from history"
/>
</div>
<Label htmlFor="follow-up-prompt">Instructions</Label>
<DescriptionImageDropZone
value={prompt}
onChange={onPromptChange}
@@ -121,33 +88,6 @@ export function FollowUpDialog({
onPreviewMapChange={onPreviewMapChange}
/>
</div>
{/* Enhancement Section */}
<EnhanceWithAI
value={prompt}
onChange={onPromptChange}
onHistoryAdd={({ mode, originalText, enhancedText }) => {
const timestamp = new Date().toISOString();
// Add original text first (so user can restore to pre-enhancement state)
// Only add if it's different from the last history entry
const lastEntry = promptHistory[promptHistory.length - 1];
if (!lastEntry || lastEntry.prompt !== originalText) {
onHistoryAdd?.({
prompt: originalText,
timestamp,
source: promptHistory.length === 0 ? 'initial' : 'edit',
});
}
// Add enhanced text
onHistoryAdd?.({
prompt: enhancedText,
timestamp,
source: 'enhance',
enhancementMode: mode,
});
}}
/>
<p className="text-xs text-muted-foreground">
The agent will continue from where it left off, using the existing context. You can
attach screenshots to help explain the issue.

View File

@@ -5,6 +5,6 @@ export { CompletedFeaturesModal } from './completed-features-modal';
export { ArchiveAllVerifiedDialog } from './archive-all-verified-dialog';
export { DeleteCompletedFeatureDialog } from './delete-completed-feature-dialog';
export { EditFeatureDialog } from './edit-feature-dialog';
export { FollowUpDialog, type FollowUpHistoryEntry } from './follow-up-dialog';
export { FollowUpDialog } from './follow-up-dialog';
export { PlanApprovalDialog } from './plan-approval-dialog';
export { MassEditDialog } from './mass-edit-dialog';

View File

@@ -1,67 +0,0 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { GitBranch, Settings2 } from 'lucide-react';
interface PlanSettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
planUseSelectedWorktreeBranch: boolean;
onPlanUseSelectedWorktreeBranchChange: (value: boolean) => void;
}
export function PlanSettingsDialog({
open,
onOpenChange,
planUseSelectedWorktreeBranch,
onPlanUseSelectedWorktreeBranchChange,
}: PlanSettingsDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" data-testid="plan-settings-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Settings2 className="w-5 h-5" />
Plan Settings
</DialogTitle>
<DialogDescription>
Configure how the Plan feature creates and organizes new features.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Use Selected Worktree Branch Setting */}
<div className="flex items-start space-x-3 p-3 rounded-lg bg-secondary/50">
<div className="flex-1 space-y-1">
<div className="flex items-center justify-between">
<Label
htmlFor="plan-worktree-branch-toggle"
className="text-sm font-medium cursor-pointer flex items-center gap-2"
>
<GitBranch className="w-4 h-4 text-brand-500" />
Use selected worktree branch
</Label>
<Switch
id="plan-worktree-branch-toggle"
checked={planUseSelectedWorktreeBranch}
onCheckedChange={onPlanUseSelectedWorktreeBranchChange}
data-testid="plan-worktree-branch-toggle"
/>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
When enabled, features created via the Plan dialog will be assigned to the currently
selected worktree branch. When disabled, features will be added to the main branch.
</p>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,67 +0,0 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { GitBranch, Settings2 } from 'lucide-react';
interface WorktreeSettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
addFeatureUseSelectedWorktreeBranch: boolean;
onAddFeatureUseSelectedWorktreeBranchChange: (value: boolean) => void;
}
export function WorktreeSettingsDialog({
open,
onOpenChange,
addFeatureUseSelectedWorktreeBranch,
onAddFeatureUseSelectedWorktreeBranchChange,
}: WorktreeSettingsDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" data-testid="worktree-settings-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Settings2 className="w-5 h-5" />
Worktree Settings
</DialogTitle>
<DialogDescription>
Configure how worktrees affect feature creation and organization.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Use Selected Worktree Branch Setting */}
<div className="flex items-start space-x-3 p-3 rounded-lg bg-secondary/50">
<div className="flex-1 space-y-1">
<div className="flex items-center justify-between">
<Label
htmlFor="worktree-branch-toggle"
className="text-sm font-medium cursor-pointer flex items-center gap-2"
>
<GitBranch className="w-4 h-4 text-brand-500" />
Use selected worktree branch
</Label>
<Switch
id="worktree-branch-toggle"
checked={addFeatureUseSelectedWorktreeBranch}
onCheckedChange={onAddFeatureUseSelectedWorktreeBranchChange}
data-testid="worktree-branch-toggle"
/>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
When enabled, the Add Feature dialog will default to custom branch mode with the
currently selected worktree branch pre-filled.
</p>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -8,3 +8,4 @@ export { useBoardBackground } from './use-board-background';
export { useBoardPersistence } from './use-board-persistence';
export { useFollowUpState } from './use-follow-up-state';
export { useSelectionMode } from './use-selection-mode';
export { useBoardOnboarding } from './use-board-onboarding';

View File

@@ -30,8 +30,7 @@ interface UseBoardActionsProps {
featureId: string,
updates: Partial<Feature>,
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
preEnhancementDescription?: string
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
) => Promise<void>;
persistFeatureDelete: (featureId: string) => Promise<void>;
saveCategory: (category: string) => Promise<void>;
@@ -252,8 +251,7 @@ export function useBoardActions({
workMode?: 'current' | 'auto' | 'custom';
},
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
preEnhancementDescription?: string
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
) => {
const workMode = updates.workMode || 'current';
@@ -310,13 +308,7 @@ export function useBoardActions({
};
updateFeature(featureId, finalUpdates);
persistFeatureUpdate(
featureId,
finalUpdates,
descriptionHistorySource,
enhancementMode,
preEnhancementDescription
);
persistFeatureUpdate(featureId, finalUpdates, descriptionHistorySource, enhancementMode);
if (updates.category) {
saveCategory(updates.category);
}

View File

@@ -0,0 +1,275 @@
/**
* Board Onboarding Hook
*
* Board-specific wrapper around the shared onboarding wizard hook.
* Manages board-specific features like sample data (Quick Start).
*
* Usage:
* - Wizard is triggered manually via startWizard() when user clicks help button
* - No auto-show logic - user controls when to see the wizard
*/
import { useState, useCallback, useEffect } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { getItem, setItem } from '@/lib/storage';
import {
useOnboardingWizard,
ONBOARDING_TARGET_ATTRIBUTE,
type OnboardingStep,
} from '@/components/shared/onboarding';
import { PlayCircle, Sparkles, Lightbulb, CheckCircle2, Settings2 } from 'lucide-react';
const logger = createLogger('BoardOnboarding');
// ============================================================================
// CONSTANTS
// ============================================================================
/** Storage key prefix for board-specific onboarding data */
const BOARD_ONBOARDING_STORAGE_KEY = 'automaker:board-onboarding-data';
/** Maximum length for project path hash in storage key */
const PROJECT_PATH_HASH_MAX_LENGTH = 50;
// Board-specific analytics events
export const BOARD_ONBOARDING_ANALYTICS = {
QUICK_START_USED: 'board_onboarding_quick_start_used',
SAMPLE_DATA_CLEARED: 'board_onboarding_sample_data_cleared',
} as const;
// ============================================================================
// WIZARD STEPS
// ============================================================================
/**
* Board wizard step definitions
* Each step targets a kanban column via data-onboarding-target
*/
export const BOARD_WIZARD_STEPS: OnboardingStep[] = [
{
id: 'backlog',
targetId: 'backlog',
title: 'Backlog',
description:
'This is where all your planned tasks live. Add new features, bug fixes, or improvements here. When you\'re ready to work on something, drag it to "In Progress" or click the play button.',
tip: 'Press N or click the + button to quickly add a new feature.',
icon: PlayCircle,
},
{
id: 'in_progress',
targetId: 'in_progress',
title: 'In Progress',
description:
'Tasks being actively worked on appear here. AI agents automatically pick up items from the backlog and move them here when processing begins.',
tip: 'You can run multiple tasks simultaneously using Auto Mode.',
icon: Sparkles,
},
{
id: 'waiting_approval',
targetId: 'waiting_approval',
title: 'Waiting Approval',
description:
'Completed work lands here for your review. Check the changes, run tests, and approve or send back for revisions.',
tip: 'Click "View Output" to see what the AI agent did.',
icon: Lightbulb,
},
{
id: 'verified',
targetId: 'verified',
title: 'Verified',
description:
"Approved and verified tasks are ready for deployment! Archive them when you're done or move them back if changes are needed.",
tip: 'Click "Complete All" to archive all verified items at once.',
icon: CheckCircle2,
},
{
id: 'custom_columns',
targetId: 'pipeline-settings', // Highlight the pipeline settings button icon
title: 'Custom Pipelines',
description:
'You can create custom columns (called pipelines) to build your own workflow! Click this settings icon to add, rename, or configure pipeline steps.',
tip: 'Use pipelines to add code review, QA testing, or any custom stage to your workflow.',
icon: Settings2,
},
];
// Re-export for backward compatibility
export type { OnboardingStep as WizardStep } from '@/components/shared/onboarding';
export { ONBOARDING_TARGET_ATTRIBUTE };
// ============================================================================
// BOARD-SPECIFIC STATE
// ============================================================================
interface BoardOnboardingData {
hasSampleData: boolean;
quickStartUsed: boolean;
}
const DEFAULT_BOARD_DATA: BoardOnboardingData = {
hasSampleData: false,
quickStartUsed: false,
};
/**
* Sanitize project path to create a storage key
*/
function sanitizeProjectPath(projectPath: string): string {
return projectPath.replace(/[^a-zA-Z0-9]/g, '_').slice(0, PROJECT_PATH_HASH_MAX_LENGTH);
}
/**
* Get storage key for board-specific data
*/
function getBoardDataStorageKey(projectPath: string): string {
const hash = sanitizeProjectPath(projectPath);
return `${BOARD_ONBOARDING_STORAGE_KEY}:${hash}`;
}
/**
* Load board-specific onboarding data from localStorage
*/
function loadBoardData(projectPath: string): BoardOnboardingData {
try {
const key = getBoardDataStorageKey(projectPath);
const stored = getItem(key);
if (stored) {
return JSON.parse(stored) as BoardOnboardingData;
}
} catch (error) {
logger.error('Failed to load board onboarding data:', error);
}
return { ...DEFAULT_BOARD_DATA };
}
/**
* Save board-specific onboarding data to localStorage
*/
function saveBoardData(projectPath: string, data: BoardOnboardingData): void {
try {
const key = getBoardDataStorageKey(projectPath);
setItem(key, JSON.stringify(data));
} catch (error) {
logger.error('Failed to save board onboarding data:', error);
}
}
/**
* Track analytics event (placeholder)
*/
function trackAnalytics(event: string, data?: Record<string, unknown>): void {
logger.debug(`[Analytics] ${event}`, data);
}
// ============================================================================
// HOOK
// ============================================================================
export interface UseBoardOnboardingOptions {
projectPath: string | null;
}
export interface UseBoardOnboardingResult {
// From shared wizard hook
isWizardVisible: boolean;
currentStep: number;
currentStepData: OnboardingStep | null;
totalSteps: number;
goToNextStep: () => void;
goToPreviousStep: () => void;
goToStep: (step: number) => void;
startWizard: () => void;
completeWizard: () => void;
skipWizard: () => void;
isCompleted: boolean;
isSkipped: boolean;
// Board-specific
hasSampleData: boolean;
setHasSampleData: (has: boolean) => void;
markQuickStartUsed: () => void;
// Steps data for component
steps: OnboardingStep[];
}
export function useBoardOnboarding({
projectPath,
}: UseBoardOnboardingOptions): UseBoardOnboardingResult {
// Board-specific state for sample data
const [boardData, setBoardData] = useState<BoardOnboardingData>(DEFAULT_BOARD_DATA);
// Create storage key from project path
const storageKey = projectPath ? `board:${sanitizeProjectPath(projectPath)}` : 'board:default';
// Use the shared onboarding wizard hook
const wizard = useOnboardingWizard({
storageKey,
steps: BOARD_WIZARD_STEPS,
});
// Load board-specific data when project changes
useEffect(() => {
if (!projectPath) {
setBoardData(DEFAULT_BOARD_DATA);
return;
}
const data = loadBoardData(projectPath);
setBoardData(data);
}, [projectPath]);
// Update board data helper
const updateBoardData = useCallback(
(updates: Partial<BoardOnboardingData>) => {
if (!projectPath) return;
setBoardData((prev) => {
const newData = { ...prev, ...updates };
saveBoardData(projectPath, newData);
return newData;
});
},
[projectPath]
);
// Sample data handlers
const setHasSampleData = useCallback(
(has: boolean) => {
updateBoardData({ hasSampleData: has });
if (!has) {
trackAnalytics(BOARD_ONBOARDING_ANALYTICS.SAMPLE_DATA_CLEARED, { projectPath });
}
},
[projectPath, updateBoardData]
);
const markQuickStartUsed = useCallback(() => {
updateBoardData({ quickStartUsed: true, hasSampleData: true });
trackAnalytics(BOARD_ONBOARDING_ANALYTICS.QUICK_START_USED, { projectPath });
}, [projectPath, updateBoardData]);
return {
// Spread shared wizard state and actions
isWizardVisible: wizard.isVisible,
currentStep: wizard.currentStep,
currentStepData: wizard.currentStepData,
totalSteps: wizard.totalSteps,
goToNextStep: wizard.goToNextStep,
goToPreviousStep: wizard.goToPreviousStep,
goToStep: wizard.goToStep,
startWizard: wizard.startWizard,
completeWizard: wizard.completeWizard,
skipWizard: wizard.skipWizard,
isCompleted: wizard.isCompleted,
isSkipped: wizard.isSkipped,
// Board-specific
hasSampleData: boardData.hasSampleData,
setHasSampleData,
markQuickStartUsed,
// Steps data
steps: BOARD_WIZARD_STEPS,
};
}

View File

@@ -19,8 +19,7 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
featureId: string,
updates: Partial<Feature>,
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
preEnhancementDescription?: string
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
) => {
if (!currentProject) return;
@@ -36,8 +35,7 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
featureId,
updates,
descriptionHistorySource,
enhancementMode,
preEnhancementDescription
enhancementMode
);
if (result.success && result.feature) {
updateFeature(result.feature.id, result.feature);

View File

@@ -4,18 +4,13 @@ import {
FeatureImagePath as DescriptionImagePath,
ImagePreviewMap,
} from '@/components/ui/description-image-dropzone';
import type { FollowUpHistoryEntry } from '../dialogs/follow-up-dialog';
/**
* Custom hook for managing follow-up dialog state including prompt history
*/
export function useFollowUpState() {
const [showFollowUpDialog, setShowFollowUpDialog] = useState(false);
const [followUpFeature, setFollowUpFeature] = useState<Feature | null>(null);
const [followUpPrompt, setFollowUpPrompt] = useState('');
const [followUpImagePaths, setFollowUpImagePaths] = useState<DescriptionImagePath[]>([]);
const [followUpPreviewMap, setFollowUpPreviewMap] = useState<ImagePreviewMap>(() => new Map());
const [followUpPromptHistory, setFollowUpPromptHistory] = useState<FollowUpHistoryEntry[]>([]);
const resetFollowUpState = useCallback(() => {
setShowFollowUpDialog(false);
@@ -23,7 +18,6 @@ export function useFollowUpState() {
setFollowUpPrompt('');
setFollowUpImagePaths([]);
setFollowUpPreviewMap(new Map());
setFollowUpPromptHistory([]);
}, []);
const handleFollowUpDialogChange = useCallback(
@@ -37,13 +31,6 @@ export function useFollowUpState() {
[resetFollowUpState]
);
/**
* Adds a new entry to the prompt history
*/
const addToPromptHistory = useCallback((entry: FollowUpHistoryEntry) => {
setFollowUpPromptHistory((prev) => [...prev, entry]);
}, []);
return {
// State
showFollowUpDialog,
@@ -51,17 +38,14 @@ export function useFollowUpState() {
followUpPrompt,
followUpImagePaths,
followUpPreviewMap,
followUpPromptHistory,
// Setters
setShowFollowUpDialog,
setFollowUpFeature,
setFollowUpPrompt,
setFollowUpImagePaths,
setFollowUpPreviewMap,
setFollowUpPromptHistory,
// Helpers
resetFollowUpState,
handleFollowUpDialogChange,
addToPromptHistory,
};
}

View File

@@ -1,209 +0,0 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { Terminal, Check, X, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppStore, type InitScriptState } from '@/store/app-store';
import { AnsiOutput } from '@/components/ui/ansi-output';
interface InitScriptIndicatorProps {
projectPath: string;
}
interface SingleIndicatorProps {
stateKey: string;
state: InitScriptState;
onDismiss: (key: string) => void;
isOnlyOne: boolean; // Whether this is the only indicator shown
autoDismiss: boolean; // Whether to auto-dismiss after completion
}
function SingleIndicator({
stateKey,
state,
onDismiss,
isOnlyOne,
autoDismiss,
}: SingleIndicatorProps) {
const [showLogs, setShowLogs] = useState(false);
const logsEndRef = useRef<HTMLDivElement>(null);
const { status, output, branch, error } = state;
// Auto-scroll to bottom when new output arrives
useEffect(() => {
if (showLogs && logsEndRef.current) {
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [output, showLogs]);
// Auto-expand logs when script starts (only if it's the only one or running)
useEffect(() => {
if (status === 'running' && isOnlyOne) {
setShowLogs(true);
}
}, [status, isOnlyOne]);
// Auto-dismiss after completion (5 seconds)
useEffect(() => {
if (autoDismiss && (status === 'success' || status === 'failed')) {
const timer = setTimeout(() => {
onDismiss(stateKey);
}, 5000);
return () => clearTimeout(timer);
}
}, [status, autoDismiss, stateKey, onDismiss]);
if (status === 'idle') return null;
return (
<div
className={cn(
'bg-card border border-border rounded-lg shadow-lg',
'min-w-[350px] max-w-[500px]',
'animate-in slide-in-from-right-5 duration-200'
)}
>
{/* Header */}
<div className="flex items-center justify-between p-3 border-b border-border/50">
<div className="flex items-center gap-2">
{status === 'running' && <Loader2 className="w-4 h-4 animate-spin text-blue-500" />}
{status === 'success' && <Check className="w-4 h-4 text-green-500" />}
{status === 'failed' && <X className="w-4 h-4 text-red-500" />}
<span className="font-medium text-sm">
Init Script{' '}
{status === 'running' ? 'Running' : status === 'success' ? 'Completed' : 'Failed'}
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setShowLogs(!showLogs)}
className="p-1 hover:bg-accent rounded transition-colors"
title={showLogs ? 'Hide logs' : 'Show logs'}
>
{showLogs ? (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronUp className="w-4 h-4 text-muted-foreground" />
)}
</button>
{status !== 'running' && (
<button
onClick={() => onDismiss(stateKey)}
className="p-1 hover:bg-accent rounded transition-colors"
title="Dismiss"
>
<X className="w-4 h-4 text-muted-foreground" />
</button>
)}
</div>
</div>
{/* Branch info */}
<div className="px-3 py-2 text-xs text-muted-foreground flex items-center gap-2">
<Terminal className="w-3.5 h-3.5" />
<span>Branch: {branch}</span>
</div>
{/* Logs (collapsible) */}
{showLogs && (
<div className="border-t border-border/50">
<div className="p-3 max-h-[300px] overflow-y-auto">
{output.length > 0 ? (
<AnsiOutput text={output.join('')} />
) : (
<div className="text-xs text-muted-foreground/60 text-center py-2">
{status === 'running' ? 'Waiting for output...' : 'No output'}
</div>
)}
{error && <div className="mt-2 text-red-500 text-xs font-medium">Error: {error}</div>}
<div ref={logsEndRef} />
</div>
</div>
)}
{/* Status bar for completed states */}
{status !== 'running' && (
<div
className={cn(
'px-3 py-2 text-xs',
status === 'success' ? 'bg-green-500/10 text-green-600' : 'bg-red-500/10 text-red-600'
)}
>
{status === 'success'
? 'Initialization completed successfully'
: 'Initialization failed - worktree is still usable'}
</div>
)}
</div>
);
}
export function InitScriptIndicator({ projectPath }: InitScriptIndicatorProps) {
const getInitScriptStatesForProject = useAppStore((s) => s.getInitScriptStatesForProject);
const clearInitScriptState = useAppStore((s) => s.clearInitScriptState);
const getAutoDismissInitScriptIndicator = useAppStore((s) => s.getAutoDismissInitScriptIndicator);
const [dismissedKeys, setDismissedKeys] = useState<Set<string>>(new Set());
// Get auto-dismiss setting
const autoDismiss = getAutoDismissInitScriptIndicator(projectPath);
// Get all init script states for this project
const allStates = getInitScriptStatesForProject(projectPath);
// Filter out dismissed and idle states
const activeStates = allStates.filter(
({ key, state }) => !dismissedKeys.has(key) && state.status !== 'idle'
);
// Reset dismissed keys when a new script starts for a branch
useEffect(() => {
const runningKeys = allStates
.filter(({ state }) => state.status === 'running')
.map(({ key }) => key);
if (runningKeys.length > 0) {
setDismissedKeys((prev) => {
const newSet = new Set(prev);
runningKeys.forEach((key) => newSet.delete(key));
return newSet;
});
}
}, [allStates]);
const handleDismiss = useCallback(
(key: string) => {
setDismissedKeys((prev) => new Set(prev).add(key));
// Extract branch from key (format: "projectPath::branch")
const branch = key.split('::')[1];
if (branch) {
// Clear state after a delay to allow for future scripts
setTimeout(() => {
clearInitScriptState(projectPath, branch);
}, 100);
}
},
[projectPath, clearInitScriptState]
);
if (activeStates.length === 0) return null;
return (
<div
className={cn(
'fixed bottom-4 right-4 z-50 flex flex-col gap-2',
'max-h-[calc(100vh-120px)] overflow-y-auto',
'scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent'
)}
>
{activeStates.map(({ key, state }) => (
<SingleIndicator
key={key}
stateKey={key}
state={state}
onDismiss={handleDismiss}
isOnlyOne={activeStates.length === 1}
autoDismiss={autoDismiss}
/>
))}
</div>
);
}

View File

@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { DndContext, DragOverlay } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { Button } from '@/components/ui/button';
import { KanbanColumn, KanbanCard, EmptyStateCard } from './components';
import { KanbanColumn, KanbanCard } from './components';
import { Feature, useAppStore, formatShortcut } from '@/store/app-store';
import { Archive, Settings2, CheckSquare, GripVertical, Plus } from 'lucide-react';
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
@@ -51,12 +51,6 @@ interface KanbanBoardProps {
selectedFeatureIds?: Set<string>;
onToggleFeatureSelection?: (featureId: string) => void;
onToggleSelectionMode?: () => void;
// Empty state action props
onAiSuggest?: () => void;
/** Whether currently dragging (hides empty states during drag) */
isDragging?: boolean;
/** Whether the board is in read-only mode */
isReadOnly?: boolean;
}
export function KanbanBoard({
@@ -92,9 +86,6 @@ export function KanbanBoard({
selectedFeatureIds = new Set(),
onToggleFeatureSelection,
onToggleSelectionMode,
onAiSuggest,
isDragging = false,
isReadOnly = false,
}: KanbanBoardProps) {
// Generate columns including pipeline steps
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
@@ -182,6 +173,7 @@ export function KanbanBoard({
onClick={onOpenPipelineSettings}
title="Pipeline Settings"
data-testid="pipeline-settings-button"
data-onboarding-target="pipeline-settings"
>
<Settings2 className="w-3.5 h-3.5" />
</Button>
@@ -220,26 +212,6 @@ export function KanbanBoard({
items={columnFeatures.map((f) => f.id)}
strategy={verticalListSortingStrategy}
>
{/* Empty state card when column has no features */}
{columnFeatures.length === 0 && !isDragging && (
<EmptyStateCard
columnId={column.id}
columnTitle={column.title}
addFeatureShortcut={addFeatureShortcut}
isReadOnly={isReadOnly}
onAiSuggest={column.id === 'backlog' ? onAiSuggest : undefined}
opacity={backgroundSettings.cardOpacity}
glassmorphism={backgroundSettings.cardGlassmorphism}
customConfig={
column.isPipelineStep
? {
title: `${column.title} Empty`,
description: `Features will appear here during the ${column.title.toLowerCase()} phase of the pipeline.`,
}
: undefined
}
/>
)}
{columnFeatures.map((feature, index) => {
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
let shortcutKey: string | undefined;

View File

@@ -1,152 +0,0 @@
import { useState } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Sparkles, ChevronDown, ChevronRight } from 'lucide-react';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
import { EnhancementMode, ENHANCEMENT_MODE_LABELS } from './enhancement-constants';
const logger = createLogger('EnhanceWithAI');
interface EnhanceWithAIProps {
/** Current text value to enhance */
value: string;
/** Callback when text is enhanced */
onChange: (enhancedText: string) => void;
/** Optional callback to track enhancement in history */
onHistoryAdd?: (entry: {
mode: EnhancementMode;
originalText: string;
enhancedText: string;
}) => void;
/** Disable the enhancement feature */
disabled?: boolean;
/** Additional CSS classes */
className?: string;
}
/**
* Reusable "Enhance with AI" component
*
* Provides AI-powered text enhancement with multiple modes:
* - Improve Clarity
* - Add Technical Details
* - Simplify
* - Add Acceptance Criteria
* - User Experience
*
* Used in Add Feature, Edit Feature, and Follow-Up dialogs.
*/
export function EnhanceWithAI({
value,
onChange,
onHistoryAdd,
disabled = false,
className,
}: EnhanceWithAIProps) {
const [isEnhancing, setIsEnhancing] = useState(false);
const [enhancementMode, setEnhancementMode] = useState<EnhancementMode>('improve');
const [enhanceOpen, setEnhanceOpen] = useState(false);
// Enhancement model override
const enhancementOverride = useModelOverride({ phase: 'enhancementModel' });
const handleEnhance = async () => {
if (!value.trim() || isEnhancing || disabled) return;
setIsEnhancing(true);
try {
const api = getElectronAPI();
const result = await api.enhancePrompt?.enhance(
value,
enhancementMode,
enhancementOverride.effectiveModel,
enhancementOverride.effectiveModelEntry.thinkingLevel
);
if (result?.success && result.enhancedText) {
const originalText = value;
const enhancedText = result.enhancedText;
onChange(enhancedText);
// Track in history if callback provided (includes original for restoration)
onHistoryAdd?.({ mode: enhancementMode, originalText, enhancedText });
toast.success('Enhanced successfully!');
} else {
toast.error(result?.error || 'Failed to enhance');
}
} catch (error) {
logger.error('Enhancement failed:', error);
toast.error('Failed to enhance');
} finally {
setIsEnhancing(false);
}
};
return (
<Collapsible open={enhanceOpen} onOpenChange={setEnhanceOpen} className={className}>
<CollapsibleTrigger asChild>
<button
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full py-1"
disabled={disabled}
>
{enhanceOpen ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
<Sparkles className="w-4 h-4" />
<span>Enhance with AI</span>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-3">
<div className="flex flex-wrap items-center gap-2 pl-6">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 text-xs" disabled={disabled}>
{ENHANCEMENT_MODE_LABELS[enhancementMode]}
<ChevronDown className="w-3 h-3 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{(Object.entries(ENHANCEMENT_MODE_LABELS) as [EnhancementMode, string][]).map(
([mode, label]) => (
<DropdownMenuItem key={mode} onClick={() => setEnhancementMode(mode)}>
{label}
</DropdownMenuItem>
)
)}
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant="default"
size="sm"
className="h-8 text-xs"
onClick={handleEnhance}
disabled={!value.trim() || isEnhancing || disabled}
loading={isEnhancing}
>
<Sparkles className="w-3 h-3 mr-1" />
Enhance
</Button>
<ModelOverrideTrigger
currentModelEntry={enhancementOverride.effectiveModelEntry}
onModelChange={enhancementOverride.setOverride}
phase="enhancementModel"
isOverridden={enhancementOverride.isOverridden}
size="sm"
variant="icon"
/>
</div>
</CollapsibleContent>
</Collapsible>
);
}

View File

@@ -1,20 +0,0 @@
/** Enhancement mode options for AI-powered prompt improvement */
export type EnhancementMode = 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer';
/** Labels for enhancement modes displayed in the UI */
export const ENHANCEMENT_MODE_LABELS: Record<EnhancementMode, string> = {
improve: 'Improve Clarity',
technical: 'Add Technical Details',
simplify: 'Simplify',
acceptance: 'Add Acceptance Criteria',
'ux-reviewer': 'User Experience',
};
/** Descriptions for enhancement modes (for tooltips/accessibility) */
export const ENHANCEMENT_MODE_DESCRIPTIONS: Record<EnhancementMode, string> = {
improve: 'Make the prompt clearer and more concise',
technical: 'Add implementation details and specifications',
simplify: 'Reduce complexity while keeping the core intent',
acceptance: 'Add specific acceptance criteria and test cases',
'ux-reviewer': 'Add user experience considerations and flows',
};

View File

@@ -1,136 +0,0 @@
import { useState, useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { History } from 'lucide-react';
import { toast } from 'sonner';
import { EnhancementMode, ENHANCEMENT_MODE_LABELS } from './enhancement-constants';
/**
* Base interface for history entries
*/
export interface BaseHistoryEntry {
timestamp: string;
source: 'initial' | 'enhance' | 'edit';
enhancementMode?: EnhancementMode;
}
interface EnhancementHistoryButtonProps<T extends BaseHistoryEntry> {
/** Array of history entries */
history: T[];
/** Current value to compare against for highlighting */
currentValue: string;
/** Callback when a history entry is restored */
onRestore: (value: string) => void;
/** Function to extract the text value from a history entry */
valueAccessor: (entry: T) => string;
/** Title for the history popover (e.g., "Version History", "Prompt History") */
title?: string;
/** Message shown when restoring an entry */
restoreMessage?: string;
}
/**
* Reusable history button component for enhancement-related history
*
* Displays a popover with a list of historical versions that can be restored.
* Used in edit-feature-dialog and follow-up-dialog for description/prompt history.
*/
export function EnhancementHistoryButton<T extends BaseHistoryEntry>({
history,
currentValue,
onRestore,
valueAccessor,
title = 'Version History',
restoreMessage = 'Restored from history',
}: EnhancementHistoryButtonProps<T>) {
const [showHistory, setShowHistory] = useState(false);
// Memoize reversed history to avoid creating new array on every render
// NOTE: This hook MUST be called before any early returns to follow Rules of Hooks
const reversedHistory = useMemo(() => [...history].reverse(), [history]);
// Early return AFTER all hooks are called
if (history.length === 0) {
return null;
}
const getSourceLabel = (entry: T): string => {
if (entry.source === 'initial') {
return 'Original';
}
if (entry.source === 'enhance') {
const mode = entry.enhancementMode ?? 'improve';
const label = ENHANCEMENT_MODE_LABELS[mode as EnhancementMode] ?? mode;
return `Enhanced (${label})`;
}
return 'Edited';
};
const formatDate = (timestamp: string): string => {
const date = new Date(timestamp);
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
return (
<Popover open={showHistory} onOpenChange={setShowHistory}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1.5 text-xs text-muted-foreground"
>
<History className="w-3.5 h-3.5" />
History ({history.length})
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
<div className="p-3 border-b">
<h4 className="font-medium text-sm">{title}</h4>
<p className="text-xs text-muted-foreground mt-1">Click a version to restore it</p>
</div>
<div className="max-h-64 overflow-y-auto p-2 space-y-1">
{reversedHistory.map((entry, index) => {
const value = valueAccessor(entry);
const isCurrentVersion = value === currentValue;
const sourceLabel = getSourceLabel(entry);
const formattedDate = formatDate(entry.timestamp);
return (
<button
key={`${entry.timestamp}-${index}`}
onClick={() => {
onRestore(value);
setShowHistory(false);
toast.success(restoreMessage);
}}
className={`w-full text-left p-2 rounded-md hover:bg-muted transition-colors ${
isCurrentVersion ? 'bg-muted/50 border border-primary/20' : ''
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">{sourceLabel}</span>
<span className="text-xs text-muted-foreground">{formattedDate}</span>
</div>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{value.slice(0, 100)}
{value.length > 100 ? '...' : ''}
</p>
{isCurrentVersion && (
<span className="text-xs text-primary font-medium mt-1 block">
Current version
</span>
)}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -1,3 +0,0 @@
export * from './enhancement-constants';
export * from './enhance-with-ai';
export * from './enhancement-history-button';

View File

@@ -10,4 +10,3 @@ export * from './planning-mode-selector';
export * from './planning-mode-select';
export * from './ancestor-context-section';
export * from './work-mode-selector';
export * from './enhancement';

View File

@@ -6,15 +6,13 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuLabel,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
} from '@/components/ui/dropdown-menu';
import {
Trash2,
MoreHorizontal,
GitCommit,
GitPullRequest,
ExternalLink,
Download,
Upload,
Play,
@@ -23,19 +21,15 @@ import {
MessageSquare,
GitMerge,
AlertCircle,
RefreshCw,
Copy,
} from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
import { TooltipWrapper } from './tooltip-wrapper';
import { useAvailableEditors, useEffectiveDefaultEditor } from '../hooks/use-available-editors';
import { getEditorIcon } from '@/components/icons/editor-icons';
interface WorktreeActionsDropdownProps {
worktree: WorktreeInfo;
isSelected: boolean;
defaultEditorName: string;
aheadCount: number;
behindCount: number;
isPulling: boolean;
@@ -47,7 +41,7 @@ interface WorktreeActionsDropdownProps {
onOpenChange: (open: boolean) => void;
onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void;
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
onOpenInEditor: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
@@ -56,13 +50,12 @@ interface WorktreeActionsDropdownProps {
onStartDevServer: (worktree: WorktreeInfo) => void;
onStopDevServer: (worktree: WorktreeInfo) => void;
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
onRunInitScript: (worktree: WorktreeInfo) => void;
hasInitScript: boolean;
}
export function WorktreeActionsDropdown({
worktree,
isSelected,
defaultEditorName,
aheadCount,
behindCount,
isPulling,
@@ -83,23 +76,7 @@ export function WorktreeActionsDropdown({
onStartDevServer,
onStopDevServer,
onOpenDevServerUrl,
onRunInitScript,
hasInitScript,
}: WorktreeActionsDropdownProps) {
// Get available editors for the "Open In" submenu
const { editors } = useAvailableEditors();
// Use shared hook for effective default editor
const effectiveDefaultEditor = useEffectiveDefaultEditor(editors);
// Get other editors (excluding the default) for the submenu
const otherEditors = editors.filter((e) => e.command !== effectiveDefaultEditor?.command);
// Get icon component for the effective editor (avoid IIFE in JSX)
const DefaultEditorIcon = effectiveDefaultEditor
? getEditorIcon(effectiveDefaultEditor.command)
: null;
// Check if there's a PR associated with this worktree from stored metadata
const hasPR = !!worktree.pr;
@@ -223,60 +200,10 @@ export function WorktreeActionsDropdown({
</TooltipWrapper>
)}
<DropdownMenuSeparator />
{/* Open in editor - split button: click main area for default, chevron for other options */}
{effectiveDefaultEditor && (
<DropdownMenuSub>
<div className="flex items-center">
{/* Main clickable area - opens in default editor */}
<DropdownMenuItem
onClick={() => onOpenInEditor(worktree, effectiveDefaultEditor.command)}
className="text-xs flex-1 pr-0 rounded-r-none"
>
{DefaultEditorIcon && <DefaultEditorIcon className="w-3.5 h-3.5 mr-2" />}
Open in {effectiveDefaultEditor.name}
</DropdownMenuItem>
{/* Chevron trigger for submenu with other editors and Copy Path */}
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
</div>
<DropdownMenuSubContent>
{/* Other editors */}
{otherEditors.map((editor) => {
const EditorIcon = getEditorIcon(editor.command);
return (
<DropdownMenuItem
key={editor.command}
onClick={() => onOpenInEditor(worktree, editor.command)}
className="text-xs"
>
<EditorIcon className="w-3.5 h-3.5 mr-2" />
{editor.name}
</DropdownMenuItem>
);
})}
{otherEditors.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuItem
onClick={async () => {
try {
await navigator.clipboard.writeText(worktree.path);
toast.success('Path copied to clipboard');
} catch {
toast.error('Failed to copy path to clipboard');
}
}}
className="text-xs"
>
<Copy className="w-3.5 h-3.5 mr-2" />
Copy Path
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{!worktree.isMain && hasInitScript && (
<DropdownMenuItem onClick={() => onRunInitScript(worktree)} className="text-xs">
<RefreshCw className="w-3.5 h-3.5 mr-2" />
Re-run Init Script
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => onOpenInEditor(worktree)} className="text-xs">
<ExternalLink className="w-3.5 h-3.5 mr-2" />
Open in {defaultEditorName}
</DropdownMenuItem>
<DropdownMenuSeparator />
{worktree.hasChanges && (
<TooltipWrapper

View File

@@ -17,6 +17,7 @@ interface WorktreeTabProps {
isActivating: boolean;
isDevServerRunning: boolean;
devServerInfo?: DevServerInfo;
defaultEditorName: string;
branches: BranchInfo[];
filteredBranches: BranchInfo[];
branchFilter: string;
@@ -36,7 +37,7 @@ interface WorktreeTabProps {
onCreateBranch: (worktree: WorktreeInfo) => void;
onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void;
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
onOpenInEditor: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
@@ -45,8 +46,6 @@ interface WorktreeTabProps {
onStartDevServer: (worktree: WorktreeInfo) => void;
onStopDevServer: (worktree: WorktreeInfo) => void;
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
onRunInitScript: (worktree: WorktreeInfo) => void;
hasInitScript: boolean;
}
export function WorktreeTab({
@@ -59,6 +58,7 @@ export function WorktreeTab({
isActivating,
isDevServerRunning,
devServerInfo,
defaultEditorName,
branches,
filteredBranches,
branchFilter,
@@ -87,8 +87,6 @@ export function WorktreeTab({
onStartDevServer,
onStopDevServer,
onOpenDevServerUrl,
onRunInitScript,
hasInitScript,
}: WorktreeTabProps) {
let prBadge: JSX.Element | null = null;
if (worktree.pr) {
@@ -317,6 +315,7 @@ export function WorktreeTab({
<WorktreeActionsDropdown
worktree={worktree}
isSelected={isSelected}
defaultEditorName={defaultEditorName}
aheadCount={aheadCount}
behindCount={behindCount}
isPulling={isPulling}
@@ -337,8 +336,6 @@ export function WorktreeTab({
onStartDevServer={onStartDevServer}
onStopDevServer={onStopDevServer}
onOpenDevServerUrl={onOpenDevServerUrl}
onRunInitScript={onRunInitScript}
hasInitScript={hasInitScript}
/>
</div>
);

View File

@@ -2,5 +2,5 @@ export { useWorktrees } from './use-worktrees';
export { useDevServers } from './use-dev-servers';
export { useBranches } from './use-branches';
export { useWorktreeActions } from './use-worktree-actions';
export { useDefaultEditor } from './use-default-editor';
export { useRunningFeatures } from './use-running-features';
export { useAvailableEditors, useEffectiveDefaultEditor } from './use-available-editors';

View File

@@ -1,101 +0,0 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import type { EditorInfo } from '@automaker/types';
const logger = createLogger('AvailableEditors');
// Re-export EditorInfo for convenience
export type { EditorInfo };
export function useAvailableEditors() {
const [editors, setEditors] = useState<EditorInfo[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const fetchAvailableEditors = useCallback(async () => {
try {
const api = getElectronAPI();
if (!api?.worktree?.getAvailableEditors) {
setIsLoading(false);
return;
}
const result = await api.worktree.getAvailableEditors();
if (result.success && result.result?.editors) {
setEditors(result.result.editors);
}
} catch (error) {
logger.error('Failed to fetch available editors:', error);
} finally {
setIsLoading(false);
}
}, []);
/**
* Refresh editors by clearing the server cache and re-detecting
* Use this when the user has installed/uninstalled editors
*/
const refresh = useCallback(async () => {
setIsRefreshing(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.refreshEditors) {
// Fallback to regular fetch if refresh not available
await fetchAvailableEditors();
return;
}
const result = await api.worktree.refreshEditors();
if (result.success && result.result?.editors) {
setEditors(result.result.editors);
logger.info(`Editor cache refreshed, found ${result.result.editors.length} editors`);
}
} catch (error) {
logger.error('Failed to refresh editors:', error);
} finally {
setIsRefreshing(false);
}
}, [fetchAvailableEditors]);
useEffect(() => {
fetchAvailableEditors();
}, [fetchAvailableEditors]);
return {
editors,
isLoading,
isRefreshing,
refresh,
// Convenience property: has multiple editors (for deciding whether to show submenu)
hasMultipleEditors: editors.length > 1,
// The first editor is the "default" one
defaultEditor: editors[0] ?? null,
};
}
/**
* Hook to get the effective default editor based on user settings
* Falls back to: Cursor > VS Code > first available editor
*/
export function useEffectiveDefaultEditor(editors: EditorInfo[]): EditorInfo | null {
const defaultEditorCommand = useAppStore((s) => s.defaultEditorCommand);
return useMemo(() => {
if (editors.length === 0) return null;
// If user has a saved preference and it exists in available editors, use it
if (defaultEditorCommand) {
const found = editors.find((e) => e.command === defaultEditorCommand);
if (found) return found;
}
// Auto-detect: prefer Cursor, then VS Code, then first available
const cursor = editors.find((e) => e.command === 'cursor');
if (cursor) return cursor;
const vscode = editors.find((e) => e.command === 'code');
if (vscode) return vscode;
return editors[0];
}, [editors, defaultEditorCommand]);
}

View File

@@ -125,14 +125,14 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
[isPushing, fetchBranches, fetchWorktrees]
);
const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo, editorCommand?: string) => {
const handleOpenInEditor = useCallback(async (worktree: WorktreeInfo) => {
try {
const api = getElectronAPI();
if (!api?.worktree?.openInEditor) {
logger.warn('Open in editor API not available');
return;
}
const result = await api.worktree.openInEditor(worktree.path, editorCommand);
const result = await api.worktree.openInEditor(worktree.path);
if (result.success && result.result) {
toast.success(result.result.message);
} else if (result.error) {

View File

@@ -1,15 +1,14 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { useEffect, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { GitBranch, Plus, RefreshCw } from 'lucide-react';
import { cn, pathsEqual } from '@/lib/utils';
import { toast } from 'sonner';
import { getHttpApiClient } from '@/lib/http-api-client';
import type { WorktreePanelProps, WorktreeInfo } from './types';
import {
useWorktrees,
useDevServers,
useBranches,
useWorktreeActions,
useDefaultEditor,
useRunningFeatures,
} from './hooks';
import { WorktreeTab } from './components';
@@ -76,33 +75,13 @@ export function WorktreePanel({
fetchBranches,
});
const { defaultEditorName } = useDefaultEditor();
const { hasRunningFeatures } = useRunningFeatures({
runningFeatureIds,
features,
});
// Track whether init script exists for the project
const [hasInitScript, setHasInitScript] = useState(false);
useEffect(() => {
if (!projectPath) {
setHasInitScript(false);
return;
}
const checkInitScript = async () => {
try {
const api = getHttpApiClient();
const result = await api.worktree.getInitScript(projectPath);
setHasInitScript(result.success && result.exists);
} catch {
setHasInitScript(false);
}
};
checkInitScript();
}, [projectPath]);
// Periodic interval check (5 seconds) to detect branch changes on disk
// Reduced from 1s to 5s to minimize GPU/CPU usage from frequent re-renders
const intervalRef = useRef<NodeJS.Timeout | null>(null);
@@ -137,33 +116,6 @@ export function WorktreePanel({
}
};
const handleRunInitScript = useCallback(
async (worktree: WorktreeInfo) => {
if (!projectPath) return;
try {
const api = getHttpApiClient();
const result = await api.worktree.runInitScript(
projectPath,
worktree.path,
worktree.branch
);
if (!result.success) {
toast.error('Failed to run init script', {
description: result.error,
});
}
// Success feedback will come via WebSocket events (init-started, init-output, init-completed)
} catch (error) {
toast.error('Failed to run init script', {
description: error instanceof Error ? error.message : 'Unknown error',
});
}
},
[projectPath]
);
const mainWorktree = worktrees.find((w) => w.isMain);
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
@@ -185,6 +137,7 @@ export function WorktreePanel({
isActivating={isActivating}
isDevServerRunning={isDevServerRunning(mainWorktree)}
devServerInfo={getDevServerInfo(mainWorktree)}
defaultEditorName={defaultEditorName}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
@@ -213,8 +166,6 @@ export function WorktreePanel({
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onRunInitScript={handleRunInitScript}
hasInitScript={hasInitScript}
/>
)}
</div>
@@ -241,6 +192,7 @@ export function WorktreePanel({
isActivating={isActivating}
isDevServerRunning={isDevServerRunning(worktree)}
devServerInfo={getDevServerInfo(worktree)}
defaultEditorName={defaultEditorName}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
@@ -269,8 +221,6 @@ export function WorktreePanel({
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onRunInitScript={handleRunInitScript}
hasInitScript={hasInitScript}
/>
);
})}

View File

@@ -472,9 +472,9 @@ export function DashboardView() {
aria-hidden="true"
/>
)}
<div className="px-4 sm:px-8 py-4 flex items-center justify-between">
<div className="px-8 py-4 flex items-center justify-between">
<div
className="flex items-center gap-2 sm:gap-3 cursor-pointer group titlebar-no-drag"
className="flex items-center gap-3 cursor-pointer group titlebar-no-drag"
onClick={() => navigate({ to: '/dashboard' })}
>
<svg
@@ -482,7 +482,7 @@ export function DashboardView() {
viewBox="0 0 256 256"
role="img"
aria-label="Automaker Logo"
className="size-8 sm:size-10 group-hover:rotate-12 transition-transform duration-300 ease-out"
className="size-10 group-hover:rotate-12 transition-transform duration-300 ease-out"
>
<defs>
<linearGradient
@@ -521,7 +521,7 @@ export function DashboardView() {
</g>
</svg>
<div className="flex flex-col">
<span className="font-bold text-foreground text-xl sm:text-2xl tracking-tight leading-none">
<span className="font-bold text-foreground text-2xl tracking-tight leading-none">
automaker<span className="text-brand-500">.</span>
</span>
<span className="text-xs text-muted-foreground leading-none font-medium mt-1">
@@ -541,32 +541,30 @@ export function DashboardView() {
</header>
{/* Main content */}
<div className="flex-1 overflow-y-auto p-4 sm:p-8">
<div className="flex-1 overflow-y-auto p-8">
<div className="max-w-6xl mx-auto">
{/* No projects - show getting started */}
{!hasProjects && (
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="text-center mb-8 sm:mb-12">
<h2 className="text-2xl sm:text-3xl font-bold text-foreground mb-3">
Welcome to Automaker
</h2>
<p className="text-base sm:text-lg text-muted-foreground max-w-xl mx-auto px-2">
<div className="text-center mb-12">
<h2 className="text-3xl font-bold text-foreground mb-3">Welcome to Automaker</h2>
<p className="text-lg text-muted-foreground max-w-xl mx-auto">
Your autonomous AI development studio. Get started by creating a new project or
opening an existing one.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6 max-w-3xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-3xl mx-auto">
{/* New Project Card */}
<div
className="group relative rounded-xl border border-border bg-card/80 backdrop-blur-sm hover:bg-card hover:border-brand-500/30 hover:shadow-xl hover:shadow-brand-500/5 transition-all duration-300 hover:-translate-y-1"
data-testid="new-project-card"
>
<div className="absolute inset-0 rounded-xl bg-linear-to-br from-brand-500/5 via-transparent to-purple-600/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<div className="relative p-4 sm:p-6 h-full flex flex-col">
<div className="flex items-start gap-3 sm:gap-4 flex-1">
<div className="w-10 h-10 sm:w-12 sm:h-12 rounded-xl bg-linear-to-br from-brand-500 to-brand-600 shadow-lg shadow-brand-500/25 flex items-center justify-center group-hover:scale-105 group-hover:shadow-brand-500/40 transition-all duration-300 shrink-0">
<Plus className="w-5 h-5 sm:w-6 sm:h-6 text-white" />
<div className="relative p-6 h-full flex flex-col">
<div className="flex items-start gap-4 flex-1">
<div className="w-12 h-12 rounded-xl bg-linear-to-br from-brand-500 to-brand-600 shadow-lg shadow-brand-500/25 flex items-center justify-center group-hover:scale-105 group-hover:shadow-brand-500/40 transition-all duration-300 shrink-0">
<Plus className="w-6 h-6 text-white" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-foreground mb-1.5">
@@ -580,7 +578,7 @@ export function DashboardView() {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="w-full mt-4 sm:mt-5 bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white border-0 shadow-md shadow-brand-500/20 hover:shadow-brand-500/30 transition-all"
className="w-full mt-5 bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white border-0 shadow-md shadow-brand-500/20 hover:shadow-brand-500/30 transition-all"
data-testid="create-new-project"
>
<Plus className="w-4 h-4 mr-2" />
@@ -615,10 +613,10 @@ export function DashboardView() {
data-testid="open-project-card"
>
<div className="absolute inset-0 rounded-xl bg-linear-to-br from-blue-500/5 via-transparent to-cyan-600/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<div className="relative p-4 sm:p-6 h-full flex flex-col">
<div className="flex items-start gap-3 sm:gap-4 flex-1">
<div className="w-10 h-10 sm:w-12 sm:h-12 rounded-xl bg-muted border border-border flex items-center justify-center group-hover:bg-blue-500/10 group-hover:border-blue-500/30 group-hover:scale-105 transition-all duration-300 shrink-0">
<FolderOpen className="w-5 h-5 sm:w-6 sm:h-6 text-muted-foreground group-hover:text-blue-500 transition-colors duration-300" />
<div className="relative p-6 h-full flex flex-col">
<div className="flex items-start gap-4 flex-1">
<div className="w-12 h-12 rounded-xl bg-muted border border-border flex items-center justify-center group-hover:bg-blue-500/10 group-hover:border-blue-500/30 group-hover:scale-105 transition-all duration-300 shrink-0">
<FolderOpen className="w-6 h-6 text-muted-foreground group-hover:text-blue-500 transition-colors duration-300" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-foreground mb-1.5">
@@ -631,7 +629,7 @@ export function DashboardView() {
</div>
<Button
variant="secondary"
className="w-full mt-4 sm:mt-5 bg-secondary/80 hover:bg-secondary text-foreground border border-border hover:border-blue-500/30 transition-all"
className="w-full mt-5 bg-secondary/80 hover:bg-secondary text-foreground border border-border hover:border-blue-500/30 transition-all"
data-testid="open-existing-project"
>
<FolderOpen className="w-4 h-4 mr-2" />
@@ -645,26 +643,21 @@ export function DashboardView() {
{/* Has projects - show project list */}
{hasProjects && (
<div className="space-y-6 sm:space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
{/* Quick actions header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-foreground">Your Projects</h2>
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleOpenProject}
className="flex-1 sm:flex-none"
>
<FolderOpen className="w-4 h-4 sm:mr-2" />
<span className="hidden sm:inline">Open Folder</span>
<Button variant="outline" onClick={handleOpenProject}>
<FolderOpen className="w-4 h-4 mr-2" />
Open Folder
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="flex-1 sm:flex-none bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white">
<Plus className="w-4 h-4 sm:mr-2" />
<span className="hidden sm:inline">New Project</span>
<span className="sm:hidden">New</span>
<ChevronDown className="w-4 h-4 ml-1 sm:ml-2" />
<Button className="bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white">
<Plus className="w-4 h-4 mr-2" />
New Project
<ChevronDown className="w-4 h-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
@@ -684,15 +677,13 @@ export function DashboardView() {
{/* Favorites section */}
{favoriteProjects.length > 0 && (
<div>
<div className="flex items-center gap-2 sm:gap-2.5 mb-3 sm:mb-4">
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-yellow-500/10 flex items-center justify-center">
<Star className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-yellow-500 fill-yellow-500" />
<div className="flex items-center gap-2.5 mb-4">
<div className="w-8 h-8 rounded-lg bg-yellow-500/10 flex items-center justify-center">
<Star className="w-4 h-4 text-yellow-500 fill-yellow-500" />
</div>
<h3 className="text-base sm:text-lg font-semibold text-foreground">
Favorites
</h3>
<h3 className="text-lg font-semibold text-foreground">Favorites</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{favoriteProjects.map((project) => (
<div
key={project.id}
@@ -701,40 +692,40 @@ export function DashboardView() {
data-testid={`project-card-${project.id}`}
>
<div className="absolute inset-0 rounded-xl bg-linear-to-br from-yellow-500/5 to-amber-600/5 opacity-0 group-hover:opacity-100 transition-all duration-300" />
<div className="relative p-3 sm:p-4">
<div className="flex items-start gap-2.5 sm:gap-3">
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-lg bg-yellow-500/10 border border-yellow-500/30 flex items-center justify-center group-hover:bg-yellow-500/20 transition-all duration-300 shrink-0">
<Folder className="w-4 h-4 sm:w-5 sm:h-5 text-yellow-500" />
<div className="relative p-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-yellow-500/10 border border-yellow-500/30 flex items-center justify-center group-hover:bg-yellow-500/20 transition-all duration-300 shrink-0">
<Folder className="w-5 h-5 text-yellow-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm sm:text-base font-medium text-foreground truncate group-hover:text-yellow-500 transition-colors duration-300">
<p className="font-medium text-foreground truncate group-hover:text-yellow-500 transition-colors duration-300">
{project.name}
</p>
<p className="text-xs text-muted-foreground/70 truncate mt-0.5 sm:mt-1">
<p className="text-xs text-muted-foreground/70 truncate mt-1">
{project.path}
</p>
{project.lastOpened && (
<p className="text-xs text-muted-foreground mt-1 sm:mt-1.5">
<p className="text-xs text-muted-foreground mt-1.5">
{new Date(project.lastOpened).toLocaleDateString()}
</p>
)}
</div>
<div className="flex items-center gap-0.5 sm:gap-1">
<div className="flex items-center gap-1">
<button
onClick={(e) => handleToggleFavorite(e, project.id)}
className="p-1 sm:p-1.5 rounded-lg hover:bg-yellow-500/20 transition-colors"
className="p-1.5 rounded-lg hover:bg-yellow-500/20 transition-colors"
title="Remove from favorites"
>
<Star className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-yellow-500 fill-yellow-500" />
<Star className="w-4 h-4 text-yellow-500 fill-yellow-500" />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className="p-1 sm:p-1.5 rounded-lg hover:bg-muted transition-colors opacity-0 group-hover:opacity-100"
className="p-1.5 rounded-lg hover:bg-muted transition-colors opacity-0 group-hover:opacity-100"
title="More options"
>
<MoreVertical className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-muted-foreground" />
<MoreVertical className="w-4 h-4 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@@ -759,15 +750,13 @@ export function DashboardView() {
{/* Recent projects section */}
{recentProjects.length > 0 && (
<div>
<div className="flex items-center gap-2 sm:gap-2.5 mb-3 sm:mb-4">
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-muted/50 flex items-center justify-center">
<Clock className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-muted-foreground" />
<div className="flex items-center gap-2.5 mb-4">
<div className="w-8 h-8 rounded-lg bg-muted/50 flex items-center justify-center">
<Clock className="w-4 h-4 text-muted-foreground" />
</div>
<h3 className="text-base sm:text-lg font-semibold text-foreground">
Recent Projects
</h3>
<h3 className="text-lg font-semibold text-foreground">Recent Projects</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{recentProjects.map((project) => (
<div
key={project.id}
@@ -776,40 +765,40 @@ export function DashboardView() {
data-testid={`project-card-${project.id}`}
>
<div className="absolute inset-0 rounded-xl bg-linear-to-br from-brand-500/0 to-purple-600/0 group-hover:from-brand-500/5 group-hover:to-purple-600/5 transition-all duration-300" />
<div className="relative p-3 sm:p-4">
<div className="flex items-start gap-2.5 sm:gap-3">
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-lg bg-muted/80 border border-border flex items-center justify-center group-hover:bg-brand-500/10 group-hover:border-brand-500/30 transition-all duration-300 shrink-0">
<Folder className="w-4 h-4 sm:w-5 sm:h-5 text-muted-foreground group-hover:text-brand-500 transition-colors duration-300" />
<div className="relative p-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-muted/80 border border-border flex items-center justify-center group-hover:bg-brand-500/10 group-hover:border-brand-500/30 transition-all duration-300 shrink-0">
<Folder className="w-5 h-5 text-muted-foreground group-hover:text-brand-500 transition-colors duration-300" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm sm:text-base font-medium text-foreground truncate group-hover:text-brand-500 transition-colors duration-300">
<p className="font-medium text-foreground truncate group-hover:text-brand-500 transition-colors duration-300">
{project.name}
</p>
<p className="text-xs text-muted-foreground/70 truncate mt-0.5 sm:mt-1">
<p className="text-xs text-muted-foreground/70 truncate mt-1">
{project.path}
</p>
{project.lastOpened && (
<p className="text-xs text-muted-foreground mt-1 sm:mt-1.5">
<p className="text-xs text-muted-foreground mt-1.5">
{new Date(project.lastOpened).toLocaleDateString()}
</p>
)}
</div>
<div className="flex items-center gap-0.5 sm:gap-1">
<div className="flex items-center gap-1">
<button
onClick={(e) => handleToggleFavorite(e, project.id)}
className="p-1 sm:p-1.5 rounded-lg hover:bg-muted transition-colors opacity-0 group-hover:opacity-100"
className="p-1.5 rounded-lg hover:bg-muted transition-colors opacity-0 group-hover:opacity-100"
title="Add to favorites"
>
<Star className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-muted-foreground hover:text-yellow-500" />
<Star className="w-4 h-4 text-muted-foreground hover:text-yellow-500" />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className="p-1 sm:p-1.5 rounded-lg hover:bg-muted transition-colors opacity-0 group-hover:opacity-100"
className="p-1.5 rounded-lg hover:bg-muted transition-colors opacity-0 group-hover:opacity-100"
title="More options"
>
<MoreVertical className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-muted-foreground" />
<MoreVertical className="w-4 h-4 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">

View File

@@ -13,15 +13,7 @@
import { useReducer, useEffect, useRef } from 'react';
import { useNavigate } from '@tanstack/react-router';
import {
login,
getHttpApiClient,
getServerUrlSync,
getApiKey,
getSessionToken,
initApiKey,
waitForApiKeyInit,
} from '@/lib/http-api-client';
import { login, getHttpApiClient, getServerUrlSync } from '@/lib/http-api-client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { KeyRound, AlertCircle, Loader2, RefreshCw, ServerCrash } from 'lucide-react';
@@ -100,7 +92,6 @@ function reducer(state: State, action: Action): State {
const MAX_RETRIES = 5;
const BACKOFF_BASE_MS = 400;
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
// =============================================================================
// Imperative Flow Logic (runs once on mount)
@@ -111,9 +102,7 @@ const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
* Unlike the httpClient methods, this does NOT call handleUnauthorized()
* which would navigate us away to /logged-out.
*
* Supports both:
* - Electron mode: Uses X-API-Key header (API key from IPC)
* - Web mode: Uses HTTP-only session cookie
* Relies on HTTP-only session cookie being sent via credentials: 'include'.
*
* Returns: { authenticated: true } or { authenticated: false }
* Throws: on network errors (for retry logic)
@@ -121,31 +110,9 @@ const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
async function checkAuthStatusSafe(): Promise<{ authenticated: boolean }> {
const serverUrl = getServerUrlSync();
// Wait for API key to be initialized before checking auth
// This ensures we have a valid API key to send in the header
await waitForApiKeyInit();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
// Electron mode: use API key header
const apiKey = getApiKey();
if (apiKey) {
headers['X-API-Key'] = apiKey;
}
// Add session token header if available (web mode)
const sessionToken = getSessionToken();
if (sessionToken) {
headers['X-Session-Token'] = sessionToken;
}
const response = await fetch(`${serverUrl}/api/auth/status`, {
headers,
credentials: 'include',
credentials: 'include', // Send HTTP-only session cookie
signal: AbortSignal.timeout(5000),
cache: NO_STORE_CACHE_MODE,
});
// Any response means server is reachable
@@ -279,14 +246,6 @@ export function LoginView() {
const [state, dispatch] = useReducer(reducer, initialState);
const retryControllerRef = useRef<AbortController | null>(null);
// Initialize API key before checking session
// This ensures getApiKey() returns a valid value in checkAuthStatusSafe()
useEffect(() => {
initApiKey().catch((error) => {
console.warn('Failed to initialize API key:', error);
});
}, []);
// Run initial server/session check on mount.
// IMPORTANT: Do not "run once" via a ref guard here.
// In React StrictMode (dev), effects mount -> cleanup -> mount.

View File

@@ -15,7 +15,6 @@ import { TerminalSection } from './settings-view/terminal/terminal-section';
import { AudioSection } from './settings-view/audio/audio-section';
import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section';
import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature-defaults-section';
import { WorktreesSection } from './settings-view/worktrees';
import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section';
import { AccountSection } from './settings-view/account';
import { SecuritySection } from './settings-view/security';
@@ -150,19 +149,17 @@ export function SettingsView() {
defaultSkipTests={defaultSkipTests}
enableDependencyBlocking={enableDependencyBlocking}
skipVerificationInAutoMode={skipVerificationInAutoMode}
useWorktrees={useWorktrees}
defaultPlanningMode={defaultPlanningMode}
defaultRequirePlanApproval={defaultRequirePlanApproval}
onDefaultSkipTestsChange={setDefaultSkipTests}
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
onSkipVerificationInAutoModeChange={setSkipVerificationInAutoMode}
onUseWorktreesChange={setUseWorktrees}
onDefaultPlanningModeChange={setDefaultPlanningMode}
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
/>
);
case 'worktrees':
return (
<WorktreesSection useWorktrees={useWorktrees} onUseWorktreesChange={setUseWorktrees} />
);
case 'account':
return <AccountSection />;
case 'security':

View File

@@ -1,51 +1,15 @@
import { useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from 'sonner';
import { LogOut, User, Code2, RefreshCw } from 'lucide-react';
import { LogOut, User } from 'lucide-react';
import { cn } from '@/lib/utils';
import { logout } from '@/lib/http-api-client';
import { useAuthStore } from '@/store/auth-store';
import { useAppStore } from '@/store/app-store';
import {
useAvailableEditors,
useEffectiveDefaultEditor,
} from '@/components/views/board-view/worktree-panel/hooks/use-available-editors';
import { getEditorIcon } from '@/components/icons/editor-icons';
export function AccountSection() {
const navigate = useNavigate();
const [isLoggingOut, setIsLoggingOut] = useState(false);
// Editor settings
const { editors, isLoading: isLoadingEditors, isRefreshing, refresh } = useAvailableEditors();
const defaultEditorCommand = useAppStore((s) => s.defaultEditorCommand);
const setDefaultEditorCommand = useAppStore((s) => s.setDefaultEditorCommand);
// Use shared hook for effective default editor
const effectiveEditor = useEffectiveDefaultEditor(editors);
// Normalize Select value: if saved editor isn't found, show 'auto'
const hasSavedEditor =
!!defaultEditorCommand && editors.some((e) => e.command === defaultEditorCommand);
const selectValue = hasSavedEditor ? defaultEditorCommand : 'auto';
// Get icon component for the effective editor
const EffectiveEditorIcon = effectiveEditor ? getEditorIcon(effectiveEditor.command) : null;
const handleRefreshEditors = async () => {
await refresh();
toast.success('Editor list refreshed');
};
const handleLogout = async () => {
setIsLoggingOut(true);
try {
@@ -79,81 +43,6 @@ export function AccountSection() {
<p className="text-sm text-muted-foreground/80 ml-12">Manage your session and account.</p>
</div>
<div className="p-6 space-y-4">
{/* Default IDE */}
<div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-muted/30 border border-border/30">
<div className="flex items-center gap-3.5 min-w-0">
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-muted/50 to-muted/30 border border-border/30 flex items-center justify-center shrink-0">
<Code2 className="w-5 h-5 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="font-medium text-foreground">Default IDE</p>
<p className="text-xs text-muted-foreground/70 mt-0.5">
Default IDE to use when opening branches or worktrees
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Select
value={selectValue}
onValueChange={(value) => setDefaultEditorCommand(value === 'auto' ? null : value)}
disabled={isLoadingEditors || isRefreshing || editors.length === 0}
>
<SelectTrigger className="w-[180px] shrink-0">
<SelectValue placeholder="Select editor">
{effectiveEditor ? (
<span className="flex items-center gap-2">
{EffectiveEditorIcon && <EffectiveEditorIcon className="w-4 h-4" />}
{effectiveEditor.name}
{selectValue === 'auto' && (
<span className="text-muted-foreground text-xs">(Auto)</span>
)}
</span>
) : (
'Select editor'
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">
<span className="flex items-center gap-2">
<Code2 className="w-4 h-4" />
Auto-detect
</span>
</SelectItem>
{editors.map((editor) => {
const Icon = getEditorIcon(editor.command);
return (
<SelectItem key={editor.command} value={editor.command}>
<span className="flex items-center gap-2">
<Icon className="w-4 h-4" />
{editor.name}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleRefreshEditors}
disabled={isRefreshing || isLoadingEditors}
className="shrink-0 h-9 w-9"
>
<RefreshCw className={cn('w-4 h-4', isRefreshing && 'animate-spin')} />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Refresh available editors</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
{/* Logout */}
<div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-muted/30 border border-border/30">
<div className="flex items-center gap-3.5 min-w-0">

View File

@@ -1,40 +1,9 @@
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { CheckCircle2, AlertCircle, RefreshCw, Bot, Cloud } from 'lucide-react';
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CliStatus } from '../shared/types';
import { OpenCodeIcon } from '@/components/ui/provider-icon';
export interface OpenCodeProviderInfo {
id: string;
name: string;
authenticated: boolean;
authMethod?: 'oauth' | 'api_key';
}
/**
* Provider display configuration
*/
const PROVIDER_DISPLAY: Record<string, string> = {
copilot: 'GitHub Copilot',
'github-copilot': 'GitHub Copilot',
anthropic: 'Anthropic',
openai: 'OpenAI',
openrouter: 'OpenRouter',
google: 'Google AI',
'amazon-bedrock': 'AWS Bedrock',
azure: 'Azure OpenAI',
ollama: 'Ollama',
lmstudio: 'LM Studio',
opencode: 'OpenCode',
xai: 'xAI',
deepseek: 'DeepSeek',
};
function getProviderDisplayName(provider: OpenCodeProviderInfo): string {
return PROVIDER_DISPLAY[provider.id] || provider.name || provider.id;
}
export type OpencodeAuthMethod =
| 'api_key_env' // ANTHROPIC_API_KEY or other provider env vars
| 'api_key' // Manually stored API key
@@ -69,7 +38,6 @@ function getAuthMethodLabel(method: OpencodeAuthMethod): string {
interface OpencodeCliStatusProps {
status: CliStatus | null;
authStatus?: OpencodeAuthStatus | null;
providers?: OpenCodeProviderInfo[];
isChecking: boolean;
onRefresh: () => void;
}
@@ -184,11 +152,9 @@ export function OpencodeModelConfigSkeleton() {
export function OpencodeCliStatus({
status,
authStatus,
providers = [],
isChecking,
onRefresh,
}: OpencodeCliStatusProps) {
const authenticatedProviders = providers.filter((p) => p.authenticated);
if (!status) return <OpencodeCliStatusSkeleton />;
return (
@@ -257,8 +223,8 @@ export function OpencodeCliStatus({
</div>
</div>
{/* Authentication Status - consider both direct auth and provider auth */}
{authStatus?.authenticated || authenticatedProviders.length > 0 ? (
{/* Authentication Status */}
{authStatus?.authenticated ? (
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
@@ -266,71 +232,24 @@ export function OpencodeCliStatus({
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-400">Authenticated</p>
<div className="text-xs text-emerald-400/70 mt-1.5">
{authStatus?.authenticated && authStatus.method !== 'none' ? (
<p>
Method:{' '}
<span className="font-mono">{getAuthMethodLabel(authStatus.method)}</span>
</p>
) : authenticatedProviders.length > 0 ? (
<p>
Via {authenticatedProviders.length} connected provider
{authenticatedProviders.length !== 1 ? 's' : ''}
</p>
) : null}
<p>
Method:{' '}
<span className="font-mono">{getAuthMethodLabel(authStatus.method)}</span>
</p>
</div>
</div>
</div>
) : (
<div className="flex items-start gap-3 p-4 rounded-xl bg-blue-500/10 border border-blue-500/20">
<div className="w-10 h-10 rounded-xl bg-blue-500/15 flex items-center justify-center border border-blue-500/20 shrink-0 mt-0.5">
<Bot className="w-5 h-5 text-blue-500" />
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
<div className="w-10 h-10 rounded-xl bg-amber-500/15 flex items-center justify-center border border-amber-500/20 shrink-0 mt-0.5">
<XCircle className="w-5 h-5 text-amber-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-blue-400">OpenCode Free Tier Ready</p>
<p className="text-xs text-blue-400/70 mt-1">
Free OpenCode models work without login. Run{' '}
<code className="font-mono bg-blue-500/10 px-1 rounded">
opencode auth login
</code>{' '}
to connect providers like GitHub Copilot, Google AI, etc.
</p>
</div>
</div>
)}
{/* Dynamic Providers Connected */}
{authenticatedProviders.length > 0 && (
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
<Cloud className="w-5 h-5 text-emerald-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-400">
{authenticatedProviders.length} Dynamic Provider
{authenticatedProviders.length !== 1 ? 's' : ''} Connected
</p>
<div className="flex flex-wrap gap-1.5 mt-2">
{authenticatedProviders.map((provider) => (
<Badge
key={provider.id}
variant="outline"
className="text-xs bg-emerald-500/10 text-emerald-400 border-emerald-500/30"
>
{getProviderDisplayName(provider)}
{provider.authMethod && (
<span className="ml-1 text-emerald-400/60">
({provider.authMethod === 'oauth' ? 'OAuth' : 'Key'})
</span>
)}
</Badge>
))}
</div>
<p className="text-xs text-emerald-400/60 mt-2">
Use{' '}
<code className="font-mono bg-emerald-500/10 px-1 rounded">
opencode auth login
</code>{' '}
to add more providers.
<p className="text-sm font-medium text-amber-400">Not Authenticated</p>
<p className="text-xs text-amber-400/70 mt-1">
Run{' '}
<code className="font-mono bg-amber-500/10 px-1 rounded">opencode auth</code> or
set an API key to authenticate.
</p>
</div>
</div>

View File

@@ -14,8 +14,6 @@ import {
MessageSquareText,
User,
Shield,
Cpu,
GitBranch,
} from 'lucide-react';
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
import type { SettingsViewId } from '../hooks/use-settings-view';
@@ -39,7 +37,6 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [
items: [
{ id: 'model-defaults', label: 'Model Defaults', icon: Workflow },
{ id: 'defaults', label: 'Feature Defaults', icon: FlaskConical },
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
{ id: 'prompts', label: 'Prompt Customization', icon: MessageSquareText },
{ id: 'api-keys', label: 'API Keys', icon: Key },
{

View File

@@ -3,6 +3,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import {
FlaskConical,
TestTube,
GitBranch,
AlertCircle,
Zap,
ClipboardList,
@@ -26,11 +27,13 @@ interface FeatureDefaultsSectionProps {
defaultSkipTests: boolean;
enableDependencyBlocking: boolean;
skipVerificationInAutoMode: boolean;
useWorktrees: boolean;
defaultPlanningMode: PlanningMode;
defaultRequirePlanApproval: boolean;
onDefaultSkipTestsChange: (value: boolean) => void;
onEnableDependencyBlockingChange: (value: boolean) => void;
onSkipVerificationInAutoModeChange: (value: boolean) => void;
onUseWorktreesChange: (value: boolean) => void;
onDefaultPlanningModeChange: (value: PlanningMode) => void;
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
}
@@ -39,11 +42,13 @@ export function FeatureDefaultsSection({
defaultSkipTests,
enableDependencyBlocking,
skipVerificationInAutoMode,
useWorktrees,
defaultPlanningMode,
defaultRequirePlanApproval,
onDefaultSkipTestsChange,
onEnableDependencyBlockingChange,
onSkipVerificationInAutoModeChange,
onUseWorktreesChange,
onDefaultPlanningModeChange,
onDefaultRequirePlanApprovalChange,
}: FeatureDefaultsSectionProps) {
@@ -251,6 +256,33 @@ export function FeatureDefaultsSection({
</p>
</div>
</div>
{/* Separator */}
<div className="border-t border-border/30" />
{/* Worktree Isolation 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
id="use-worktrees"
checked={useWorktrees}
onCheckedChange={(checked) => onUseWorktreesChange(checked === true)}
className="mt-1"
data-testid="use-worktrees-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="use-worktrees"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<GitBranch className="w-4 h-4 text-brand-500" />
Enable Git Worktree Isolation
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Creates isolated git branches for each feature. When disabled, agents work directly in
the main project directory.
</p>
</div>
</div>
</div>
</div>
);

View File

@@ -16,7 +16,6 @@ export type SettingsViewId =
| 'keyboard'
| 'audio'
| 'defaults'
| 'worktrees'
| 'account'
| 'security'
| 'danger';

View File

@@ -1,4 +1,4 @@
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import type {
@@ -25,13 +25,18 @@ import {
THINKING_LEVEL_LABELS,
REASONING_EFFORT_LEVELS,
REASONING_EFFORT_LABELS,
type ModelOption,
} from '@/components/views/board-view/shared/model-constants';
import { Check, ChevronsUpDown, Star, ChevronRight } from 'lucide-react';
import {
AnthropicIcon,
CursorIcon,
OpenAIIcon,
OpenCodeIcon,
DeepSeekIcon,
NovaIcon,
QwenIcon,
MistralIcon,
MetaIcon,
getProviderIconForModel,
} from '@/components/ui/provider-icon';
import { Button } from '@/components/ui/button';
@@ -46,80 +51,6 @@ import {
} from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
const OPENCODE_CLI_GROUP_LABEL = 'OpenCode CLI';
const OPENCODE_PROVIDER_FALLBACK = 'opencode';
const OPENCODE_PROVIDER_WORD_SEPARATOR = '-';
const OPENCODE_MODEL_ID_SEPARATOR = '/';
const OPENCODE_SECTION_GROUP_PADDING = 'pt-2';
const OPENCODE_STATIC_PROVIDER_LABELS: Record<string, string> = {
[OPENCODE_PROVIDER_FALLBACK]: 'OpenCode (Free)',
};
const OPENCODE_DYNAMIC_PROVIDER_LABELS: Record<string, string> = {
'github-copilot': 'GitHub Copilot',
'zai-coding-plan': 'Z.AI Coding Plan',
google: 'Google AI',
openai: 'OpenAI',
openrouter: 'OpenRouter',
anthropic: 'Anthropic',
xai: 'xAI',
deepseek: 'DeepSeek',
ollama: 'Ollama (Local)',
lmstudio: 'LM Studio (Local)',
azure: 'Azure OpenAI',
[OPENCODE_PROVIDER_FALLBACK]: 'OpenCode (Free)',
};
const OPENCODE_DYNAMIC_PROVIDER_ORDER = [
'github-copilot',
'google',
'openai',
'openrouter',
'anthropic',
'xai',
'deepseek',
'ollama',
'lmstudio',
'azure',
'zai-coding-plan',
];
const OPENCODE_SECTION_ORDER = ['free', 'dynamic'] as const;
const OPENCODE_SECTION_LABELS: Record<(typeof OPENCODE_SECTION_ORDER)[number], string> = {
free: 'Free Tier',
dynamic: 'Connected Providers',
};
const OPENCODE_STATIC_PROVIDER_BY_ID = new Map(
OPENCODE_MODELS.map((model) => [model.id, model.provider])
);
function formatProviderLabel(providerKey: string): string {
return providerKey
.split(OPENCODE_PROVIDER_WORD_SEPARATOR)
.map((word) => (word ? word[0].toUpperCase() + word.slice(1) : word))
.join(' ');
}
function getOpencodeSectionKey(providerKey: string): (typeof OPENCODE_SECTION_ORDER)[number] {
if (providerKey === OPENCODE_PROVIDER_FALLBACK) {
return 'free';
}
return 'dynamic';
}
function getOpencodeGroupLabel(
providerKey: string,
sectionKey: (typeof OPENCODE_SECTION_ORDER)[number]
): string {
if (sectionKey === 'free') {
return OPENCODE_STATIC_PROVIDER_LABELS[providerKey] || 'OpenCode Free Tier';
}
return OPENCODE_DYNAMIC_PROVIDER_LABELS[providerKey] || formatProviderLabel(providerKey);
}
interface PhaseModelSelectorProps {
/** Label shown in full mode */
label?: string;
@@ -164,7 +95,6 @@ export function PhaseModelSelector({
codexModels,
codexModelsLoading,
fetchCodexModels,
dynamicOpencodeModels,
} = useAppStore();
// Extract model and thinking/reasoning levels from value
@@ -305,30 +235,12 @@ export function PhaseModelSelector({
const codexModel = transformedCodexModels.find((m) => m.id === selectedModel);
if (codexModel) return { ...codexModel, icon: OpenAIIcon };
// Check OpenCode models (static) - use dynamic icon resolution for provider-specific icons
// Check OpenCode models
const opencodeModel = OPENCODE_MODELS.find((m) => m.id === selectedModel);
if (opencodeModel) return { ...opencodeModel, icon: getProviderIconForModel(opencodeModel.id) };
// Check dynamic OpenCode models - use dynamic icon resolution for provider-specific icons
const dynamicModel = dynamicOpencodeModels.find((m) => m.id === selectedModel);
if (dynamicModel) {
return {
id: dynamicModel.id,
label: dynamicModel.name,
description: dynamicModel.description,
provider: 'opencode' as const,
icon: getProviderIconForModel(dynamicModel.id),
};
}
if (opencodeModel) return { ...opencodeModel, icon: OpenCodeIcon };
return null;
}, [
selectedModel,
selectedThinkingLevel,
availableCursorModels,
transformedCodexModels,
dynamicOpencodeModels,
]);
}, [selectedModel, selectedThinkingLevel, availableCursorModels, transformedCodexModels]);
// Compute grouped vs standalone Cursor models
const { groupedModels, standaloneCursorModels } = useMemo(() => {
@@ -363,35 +275,13 @@ export function PhaseModelSelector({
return { groupedModels: grouped, standaloneCursorModels: standalone };
}, [availableCursorModels, enabledCursorModels]);
// Combine static and dynamic OpenCode models
const allOpencodeModels: ModelOption[] = useMemo(() => {
// Start with static models
const staticModels = [...OPENCODE_MODELS];
// Add dynamic models (convert ModelDefinition to ModelOption)
const dynamicModelOptions: ModelOption[] = dynamicOpencodeModels.map((model) => ({
id: model.id,
label: model.name,
description: model.description,
badge: model.tier === 'premium' ? 'Premium' : model.tier === 'basic' ? 'Free' : undefined,
provider: 'opencode' as const,
}));
// Merge, avoiding duplicates (static models take precedence for same ID)
// In practice, static and dynamic IDs don't overlap
const staticIds = new Set(staticModels.map((m) => m.id));
const uniqueDynamic = dynamicModelOptions.filter((m) => !staticIds.has(m.id));
return [...staticModels, ...uniqueDynamic];
}, [dynamicOpencodeModels]);
// Group models
const { favorites, claude, cursor, codex, opencode } = useMemo(() => {
const favs: typeof CLAUDE_MODELS = [];
const cModels: typeof CLAUDE_MODELS = [];
const curModels: typeof CURSOR_MODELS = [];
const codModels: typeof transformedCodexModels = [];
const ocModels: ModelOption[] = [];
const ocModels: typeof OPENCODE_MODELS = [];
// Process Claude Models
CLAUDE_MODELS.forEach((model) => {
@@ -420,8 +310,8 @@ export function PhaseModelSelector({
}
});
// Process OpenCode Models (including dynamic)
allOpencodeModels.forEach((model) => {
// Process OpenCode Models
OPENCODE_MODELS.forEach((model) => {
if (favoriteModels.includes(model.id)) {
favs.push(model);
} else {
@@ -436,95 +326,7 @@ export function PhaseModelSelector({
codex: codModels,
opencode: ocModels,
};
}, [favoriteModels, availableCursorModels, transformedCodexModels, allOpencodeModels]);
// Group OpenCode models by model type for better organization
const opencodeSections = useMemo(() => {
type OpencodeSectionKey = (typeof OPENCODE_SECTION_ORDER)[number];
type OpencodeGroup = { key: string; label: string; models: ModelOption[] };
type OpencodeSection = {
key: OpencodeSectionKey;
label: string;
showGroupLabels: boolean;
groups: OpencodeGroup[];
};
const sections: Record<OpencodeSectionKey, Record<string, OpencodeGroup>> = {
free: {},
dynamic: {},
};
const dynamicProviderById = new Map(
dynamicOpencodeModels.map((model) => [model.id, model.provider])
);
const resolveProviderKey = (modelId: string): string => {
const staticProvider = OPENCODE_STATIC_PROVIDER_BY_ID.get(modelId);
if (staticProvider) return staticProvider;
const dynamicProvider = dynamicProviderById.get(modelId);
if (dynamicProvider) return dynamicProvider;
return modelId.includes(OPENCODE_MODEL_ID_SEPARATOR)
? modelId.split(OPENCODE_MODEL_ID_SEPARATOR)[0]
: OPENCODE_PROVIDER_FALLBACK;
};
const addModelToGroup = (
sectionKey: OpencodeSectionKey,
providerKey: string,
model: ModelOption
) => {
if (!sections[sectionKey][providerKey]) {
sections[sectionKey][providerKey] = {
key: providerKey,
label: getOpencodeGroupLabel(providerKey, sectionKey),
models: [],
};
}
sections[sectionKey][providerKey].models.push(model);
};
opencode.forEach((model) => {
const providerKey = resolveProviderKey(model.id);
const sectionKey = getOpencodeSectionKey(providerKey);
addModelToGroup(sectionKey, providerKey, model);
});
const buildGroupList = (sectionKey: OpencodeSectionKey): OpencodeGroup[] => {
const groupMap = sections[sectionKey];
const priorityOrder = sectionKey === 'dynamic' ? OPENCODE_DYNAMIC_PROVIDER_ORDER : [];
const priorityMap = new Map(priorityOrder.map((provider, index) => [provider, index]));
return Object.keys(groupMap)
.sort((a, b) => {
const aPriority = priorityMap.get(a);
const bPriority = priorityMap.get(b);
if (aPriority !== undefined && bPriority !== undefined) {
return aPriority - bPriority;
}
if (aPriority !== undefined) return -1;
if (bPriority !== undefined) return 1;
return groupMap[a].label.localeCompare(groupMap[b].label);
})
.map((key) => groupMap[key]);
};
const builtSections = OPENCODE_SECTION_ORDER.map((sectionKey) => {
const groups = buildGroupList(sectionKey);
if (groups.length === 0) return null;
return {
key: sectionKey,
label: OPENCODE_SECTION_LABELS[sectionKey],
showGroupLabels: sectionKey !== 'free',
groups,
};
}).filter(Boolean) as OpencodeSection[];
return builtSections;
}, [opencode, dynamicOpencodeModels]);
}, [favoriteModels, availableCursorModels, transformedCodexModels]);
// Render Codex model item with secondary popover for reasoning effort (only for models that support it)
const renderCodexModelItem = (model: (typeof transformedCodexModels)[0]) => {
@@ -1190,32 +992,9 @@ export function PhaseModelSelector({
</CommandGroup>
)}
{opencodeSections.length > 0 && (
<CommandGroup heading={OPENCODE_CLI_GROUP_LABEL}>
{opencodeSections.map((section, sectionIndex) => (
<Fragment key={section.key}>
<div className="px-2 pt-2 text-xs font-medium text-muted-foreground">
{section.label}
</div>
<div
className={cn(
'space-y-2',
section.key === 'dynamic' && OPENCODE_SECTION_GROUP_PADDING
)}
>
{section.groups.map((group) => (
<div key={group.key} className="space-y-1">
{section.showGroupLabels && (
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
{group.label}
</div>
)}
{group.models.map((model) => renderOpencodeModelItem(model))}
</div>
))}
</div>
</Fragment>
))}
{opencode.length > 0 && (
<CommandGroup heading="OpenCode Models">
{opencode.map((model) => renderOpencodeModelItem(model))}
</CommandGroup>
)}
</CommandList>

View File

@@ -1,7 +1,6 @@
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
@@ -9,32 +8,20 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Terminal, Cloud, Cpu, Brain, Github, Loader2, KeyRound, ShieldCheck } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input';
import type {
OpencodeModelId,
OpencodeProvider,
OpencodeModelConfig,
ModelDefinition,
} from '@automaker/types';
import type { OpencodeModelId, OpencodeProvider, OpencodeModelConfig } from '@automaker/types';
import { OPENCODE_MODELS, OPENCODE_MODEL_CONFIG_MAP } from '@automaker/types';
import type { OpenCodeProviderInfo } from '../cli-status/opencode-cli-status';
import {
OpenCodeIcon,
DeepSeekIcon,
QwenIcon,
NovaIcon,
AnthropicIcon,
OpenRouterIcon,
MistralIcon,
MetaIcon,
GeminiIcon,
OpenAIIcon,
GrokIcon,
getProviderIconForModel,
} from '@/components/ui/provider-icon';
import { useEffect, useMemo, useRef, useState, type ComponentType } from 'react';
import type { ComponentType } from 'react';
interface OpencodeModelConfigurationProps {
enabledOpencodeModels: OpencodeModelId[];
@@ -42,12 +29,6 @@ interface OpencodeModelConfigurationProps {
isSaving: boolean;
onDefaultModelChange: (model: OpencodeModelId) => void;
onModelToggle: (model: OpencodeModelId, enabled: boolean) => void;
providers?: OpenCodeProviderInfo[];
// Dynamic models
dynamicModels: ModelDefinition[];
enabledDynamicModelIds: string[];
onDynamicModelToggle: (modelId: string, enabled: boolean) => void;
isLoadingDynamicModels?: boolean;
}
/**
@@ -64,144 +45,31 @@ function getProviderLabel(provider: OpencodeProvider): string {
switch (provider) {
case 'opencode':
return 'OpenCode (Free)';
case 'amazon-bedrock-anthropic':
return 'Claude (Bedrock)';
case 'amazon-bedrock-deepseek':
return 'DeepSeek (Bedrock)';
case 'amazon-bedrock-amazon':
return 'Amazon Nova (Bedrock)';
case 'amazon-bedrock-meta':
return 'Meta Llama (Bedrock)';
case 'amazon-bedrock-mistral':
return 'Mistral (Bedrock)';
case 'amazon-bedrock-qwen':
return 'Qwen (Bedrock)';
default:
return provider;
}
}
/**
* Configuration for dynamic provider display
*/
const DYNAMIC_PROVIDER_CONFIG: Record<
string,
{ label: string; icon: ComponentType<{ className?: string }> }
> = {
'github-copilot': { label: 'GitHub Copilot', icon: Github },
google: { label: 'Google AI', icon: GeminiIcon },
openai: { label: 'OpenAI', icon: OpenAIIcon },
openrouter: { label: 'OpenRouter', icon: OpenRouterIcon },
anthropic: { label: 'Anthropic', icon: AnthropicIcon },
opencode: { label: 'OpenCode (Free)', icon: Terminal },
ollama: { label: 'Ollama (Local)', icon: Cpu },
lmstudio: { label: 'LM Studio (Local)', icon: Cpu },
azure: { label: 'Azure OpenAI', icon: Cloud },
'amazon-bedrock': { label: 'AWS Bedrock', icon: Cloud },
xai: { label: 'xAI', icon: GrokIcon },
deepseek: { label: 'DeepSeek', icon: Brain },
};
function getDynamicProviderConfig(providerId: string) {
return (
DYNAMIC_PROVIDER_CONFIG[providerId] || {
label: providerId.charAt(0).toUpperCase() + providerId.slice(1).replace(/-/g, ' '),
icon: Cloud,
}
);
}
const OPENCODE_AUTH_METHOD_LABELS: Record<string, string> = {
oauth: 'OAuth',
api_key: 'Key',
api: 'Key',
key: 'Key',
};
const OPENCODE_AUTH_METHOD_ICONS: Record<string, ComponentType<{ className?: string }>> = {
oauth: ShieldCheck,
api_key: KeyRound,
api: KeyRound,
key: KeyRound,
};
const OPENCODE_PROVIDER_FILTER_CLEAR_LABEL = 'Clear';
const OPENCODE_PROVIDER_FILTER_SEARCH_PLACEHOLDER = 'Search models...';
const OPENCODE_PROVIDER_FILTER_EMPTY_LABEL = 'No models match your filters.';
const OPENCODE_PROVIDER_FILTER_EMPTY_HINT = 'Try a different search or provider.';
const OPENCODE_PROVIDER_MODELS_EMPTY_LABEL = 'No models available yet.';
const OPENCODE_PROVIDER_MODELS_EMPTY_HINT = 'Connect or refresh OpenCode CLI to load models.';
const OPENCODE_DYNAMIC_MODELS_SECTION_LABEL = 'Dynamic Models (from OpenCode providers)';
const OPENCODE_SELECT_DYNAMIC_LABEL = 'Select all';
const OPENCODE_SELECT_STATIC_LABEL = 'Select all';
const OPENCODE_SELECT_ALL_CONTAINER_CLASS =
'flex items-center gap-2 rounded-full border border-border/60 bg-card/60 px-2.5 py-1 text-xs text-muted-foreground';
function formatProviderAuthLabel(provider?: OpenCodeProviderInfo): string | null {
if (!provider?.authMethod) return null;
return OPENCODE_AUTH_METHOD_LABELS[provider.authMethod] || provider.authMethod;
}
function getProviderAuthIcon(
provider?: OpenCodeProviderInfo
): ComponentType<{ className?: string }> | null {
if (!provider?.authMethod) return null;
return OPENCODE_AUTH_METHOD_ICONS[provider.authMethod] || null;
}
function getDynamicProviderBaseLabel(
providerId: string,
providerInfo: OpenCodeProviderInfo | undefined
): string {
const providerConfig = getDynamicProviderConfig(providerId);
return providerInfo?.name || providerConfig.label;
}
function getDynamicProviderLabel(
providerId: string,
providerInfo: OpenCodeProviderInfo | undefined
): string {
const providerConfig = getDynamicProviderConfig(providerId);
const baseLabel = providerInfo?.name || providerConfig.label;
const authLabel = formatProviderAuthLabel(providerInfo);
return authLabel ? `${baseLabel} (${authLabel})` : baseLabel;
}
function getSelectionState(
candidateIds: string[],
selectedIds: string[]
): boolean | 'indeterminate' {
if (candidateIds.length === 0) return false;
const allSelected = candidateIds.every((modelId) => selectedIds.includes(modelId));
if (allSelected) return true;
const anySelected = candidateIds.some((modelId) => selectedIds.includes(modelId));
return anySelected ? 'indeterminate' : false;
}
/**
* Group dynamic models by their provider
*/
function groupDynamicModelsByProvider(
models: ModelDefinition[]
): Record<string, ModelDefinition[]> {
return models.reduce(
(acc, model) => {
const provider = model.provider || 'unknown';
if (!acc[provider]) {
acc[provider] = [];
}
acc[provider].push(model);
return acc;
},
{} as Record<string, ModelDefinition[]>
);
}
function matchesDynamicModelQuery(model: ModelDefinition, query: string): boolean {
if (!query) return true;
const haystack = `${model.name} ${model.description} ${model.id}`.toLowerCase();
return haystack.includes(query);
}
export function OpencodeModelConfiguration({
enabledOpencodeModels,
opencodeDefaultModel,
isSaving,
onDefaultModelChange,
onModelToggle,
providers,
dynamicModels,
enabledDynamicModelIds,
onDynamicModelToggle,
isLoadingDynamicModels = false,
}: OpencodeModelConfigurationProps) {
// Group static models by provider for organized display
// Group models by provider for organized display
const modelsByProvider = OPENCODE_MODELS.reduce(
(acc, model) => {
if (!acc[model.provider]) {
@@ -213,141 +81,17 @@ export function OpencodeModelConfiguration({
{} as Record<OpencodeProvider, OpencodeModelConfig[]>
);
// Group dynamic models by provider
const dynamicModelsByProvider = groupDynamicModelsByProvider(dynamicModels);
const authenticatedProviders = (providers || []).filter((provider) => provider.authenticated);
const [dynamicProviderFilter, setDynamicProviderFilter] = useState<string | null>(null);
const hasInitializedDynamicProviderFilter = useRef(false);
const [dynamicProviderSearch, setDynamicProviderSearch] = useState('');
const normalizedDynamicSearch = dynamicProviderSearch.trim().toLowerCase();
const hasDynamicSearch = normalizedDynamicSearch.length > 0;
const allStaticModelIds = OPENCODE_MODELS.map((model) => model.id);
const selectableStaticModelIds = allStaticModelIds.filter(
(modelId) => modelId !== opencodeDefaultModel
);
const allDynamicModelIds = dynamicModels.map((model) => model.id);
const hasDynamicModels = allDynamicModelIds.length > 0;
const staticSelectState = getSelectionState(selectableStaticModelIds, enabledOpencodeModels);
// Order: Free tier first, then Claude, then others
const providerOrder: OpencodeProvider[] = ['opencode'];
// Dynamic provider order (prioritize commonly used ones)
const dynamicProviderOrder = [
'github-copilot',
'google',
'openai',
'openrouter',
'anthropic',
'xai',
'deepseek',
'ollama',
'lmstudio',
'azure',
'amazon-bedrock',
'opencode', // Skip opencode in dynamic since it's in static
const providerOrder: OpencodeProvider[] = [
'opencode',
'amazon-bedrock-anthropic',
'amazon-bedrock-deepseek',
'amazon-bedrock-amazon',
'amazon-bedrock-meta',
'amazon-bedrock-mistral',
'amazon-bedrock-qwen',
];
const sortedDynamicProviders = useMemo(() => {
const providerIndex = (providerId: string) => dynamicProviderOrder.indexOf(providerId);
const providerIds = new Set([
...Object.keys(dynamicModelsByProvider),
...(providers || []).map((provider) => provider.id),
]);
providerIds.delete('opencode'); // Don't show opencode twice
return Array.from(providerIds).sort((a, b) => {
const aIndex = providerIndex(a);
const bIndex = providerIndex(b);
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
if (aIndex !== -1) return -1;
if (bIndex !== -1) return 1;
return a.localeCompare(b);
});
}, [dynamicModelsByProvider, providers]);
useEffect(() => {
if (
dynamicProviderFilter &&
sortedDynamicProviders.length > 0 &&
!sortedDynamicProviders.includes(dynamicProviderFilter)
) {
setDynamicProviderFilter(sortedDynamicProviders[0]);
return;
}
if (
!hasInitializedDynamicProviderFilter.current &&
!dynamicProviderFilter &&
sortedDynamicProviders.length > 0
) {
hasInitializedDynamicProviderFilter.current = true;
setDynamicProviderFilter(sortedDynamicProviders[0]);
}
}, [dynamicProviderFilter, sortedDynamicProviders]);
const filteredDynamicProviders = useMemo(() => {
const baseProviders = dynamicProviderFilter ? [dynamicProviderFilter] : sortedDynamicProviders;
if (!hasDynamicSearch) {
return baseProviders;
}
return baseProviders.filter((providerId) => {
const models = dynamicModelsByProvider[providerId] || [];
return models.some((model) => matchesDynamicModelQuery(model, normalizedDynamicSearch));
});
}, [
dynamicModelsByProvider,
dynamicProviderFilter,
hasDynamicSearch,
normalizedDynamicSearch,
sortedDynamicProviders,
]);
const hasDynamicProviders = sortedDynamicProviders.length > 0;
const showDynamicProviderFilters = sortedDynamicProviders.length > 1;
const hasFilteredDynamicProviders = filteredDynamicProviders.length > 0;
const toggleDynamicProviderFilter = (providerId: string) => {
setDynamicProviderFilter((current) => (current === providerId ? current : providerId));
};
const toggleAllStaticModels = (checked: boolean) => {
if (checked) {
selectableStaticModelIds.forEach((modelId) => {
if (!enabledOpencodeModels.includes(modelId)) {
onModelToggle(modelId, true);
}
});
return;
}
selectableStaticModelIds.forEach((modelId) => {
if (enabledOpencodeModels.includes(modelId)) {
onModelToggle(modelId, false);
}
});
};
const toggleProviderDynamicModels = (modelIds: string[], checked: boolean) => {
if (checked) {
modelIds.forEach((modelId) => {
if (!enabledDynamicModelIds.includes(modelId)) {
onDynamicModelToggle(modelId, true);
}
});
return;
}
modelIds.forEach((modelId) => {
if (enabledDynamicModelIds.includes(modelId)) {
onDynamicModelToggle(modelId, false);
}
});
};
return (
<div
className={cn(
@@ -402,21 +146,7 @@ export function OpencodeModelConfiguration({
{/* Available Models grouped by provider */}
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<Label>Available Models</Label>
{selectableStaticModelIds.length > 0 && (
<div className={OPENCODE_SELECT_ALL_CONTAINER_CLASS}>
<Checkbox
checked={staticSelectState}
onCheckedChange={toggleAllStaticModels}
disabled={isSaving}
/>
<span>{OPENCODE_SELECT_STATIC_LABEL}</span>
</div>
)}
</div>
{/* Static models grouped by provider (Built-in) */}
<Label>Available Models</Label>
{providerOrder.map((provider) => {
const models = modelsByProvider[provider];
if (!models || models.length === 0) return null;
@@ -487,196 +217,6 @@ export function OpencodeModelConfiguration({
</div>
);
})}
{/* Dynamic models from OpenCode providers */}
{(hasDynamicProviders || isLoadingDynamicModels) && (
<>
{/* Separator between static and dynamic models */}
<div className="border-t border-border/50 my-4" />
<div className="flex flex-wrap items-center justify-between gap-2 -mt-2 mb-2">
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs text-muted-foreground">
{OPENCODE_DYNAMIC_MODELS_SECTION_LABEL}
</p>
{isLoadingDynamicModels && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="w-3 h-3 animate-spin" />
<span>Discovering...</span>
</div>
)}
</div>
</div>
{showDynamicProviderFilters && (
<div className="space-y-2">
<div className="flex flex-wrap gap-2 rounded-xl border border-border/60 bg-card/40 p-2">
{sortedDynamicProviders.map((providerId) => {
const providerInfo = authenticatedProviders.find(
(provider) => provider.id === providerId
);
const providerLabel = getDynamicProviderBaseLabel(providerId, providerInfo);
const providerConfig = getDynamicProviderConfig(providerId);
const ProviderIcon = providerConfig.icon;
const AuthIcon = getProviderAuthIcon(providerInfo);
const authLabel = formatProviderAuthLabel(providerInfo);
const isActive = dynamicProviderFilter === providerId;
const authBadgeClass = cn(
'inline-flex h-5 w-5 items-center justify-center rounded-full border border-transparent bg-transparent text-muted-foreground/80 transition-colors',
isActive && 'text-accent-foreground'
);
return (
<Button
key={providerId}
type="button"
size="sm"
variant="outline"
onClick={() => toggleDynamicProviderFilter(providerId)}
className={cn('text-xs', isActive && 'bg-accent text-accent-foreground')}
>
<span className="flex items-center gap-1.5">
<ProviderIcon className="w-3.5 h-3.5" />
<span>{providerLabel}</span>
{AuthIcon && authLabel && (
<span className={authBadgeClass}>
<AuthIcon className="w-2.5 h-2.5" />
<span className="sr-only">{authLabel}</span>
</span>
)}
</span>
</Button>
);
})}
</div>
</div>
)}
{hasDynamicProviders && (
<div className="flex flex-wrap items-center gap-2">
<Input
value={dynamicProviderSearch}
onChange={(event) => setDynamicProviderSearch(event.target.value)}
placeholder={OPENCODE_PROVIDER_FILTER_SEARCH_PLACEHOLDER}
className="h-8 text-xs"
/>
{dynamicProviderSearch && (
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setDynamicProviderSearch('')}
className="text-xs"
>
{OPENCODE_PROVIDER_FILTER_CLEAR_LABEL}
</Button>
)}
</div>
)}
{hasDynamicSearch && !hasFilteredDynamicProviders && (
<div className="rounded-xl border border-dashed border-border/60 bg-card/40 px-3 py-2 text-xs text-muted-foreground">
<p className="font-medium">{OPENCODE_PROVIDER_FILTER_EMPTY_LABEL}</p>
<p className="mt-1">{OPENCODE_PROVIDER_FILTER_EMPTY_HINT}</p>
</div>
)}
{filteredDynamicProviders.map((providerId) => {
const models = dynamicModelsByProvider[providerId] || [];
const providerConfig = getDynamicProviderConfig(providerId);
const providerInfo = authenticatedProviders.find(
(provider) => provider.id === providerId
);
const providerLabel = getDynamicProviderLabel(providerId, providerInfo);
const DynamicProviderIcon = providerConfig.icon;
const filteredModels = hasDynamicSearch
? models.filter((model) =>
matchesDynamicModelQuery(model, normalizedDynamicSearch)
)
: models;
if (hasDynamicSearch && filteredModels.length === 0) {
return null;
}
return (
<div key={`dynamic-${providerId}`} className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<DynamicProviderIcon className="w-4 h-4" />
<span className="font-medium">{providerLabel}</span>
<Badge
variant="outline"
className="text-xs bg-blue-500/10 text-blue-500 border-blue-500/30"
>
Dynamic
</Badge>
</div>
{models.length > 0 && (
<div className={OPENCODE_SELECT_ALL_CONTAINER_CLASS}>
<Checkbox
checked={getSelectionState(
models.map((model) => model.id),
enabledDynamicModelIds
)}
onCheckedChange={(checked) =>
toggleProviderDynamicModels(
models.map((model) => model.id),
checked
)
}
disabled={isSaving}
/>
<span>{OPENCODE_SELECT_DYNAMIC_LABEL}</span>
</div>
)}
</div>
<div className="grid gap-2">
{filteredModels.length === 0 ? (
<div className="rounded-xl border border-dashed border-border/60 bg-card/40 px-3 py-2 text-xs text-muted-foreground">
<p className="font-medium">{OPENCODE_PROVIDER_MODELS_EMPTY_LABEL}</p>
<p className="mt-1">{OPENCODE_PROVIDER_MODELS_EMPTY_HINT}</p>
</div>
) : (
filteredModels.map((model) => {
const isEnabled = enabledDynamicModelIds.includes(model.id);
return (
<div
key={model.id}
className="flex items-center justify-between p-3 rounded-xl border border-border/50 bg-card/50 hover:bg-accent/30 transition-colors"
>
<div className="flex items-center gap-3">
<Checkbox
checked={isEnabled}
onCheckedChange={(checked) =>
onDynamicModelToggle(model.id, !!checked)
}
disabled={isSaving}
/>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{model.name}</span>
{model.supportsVision && (
<Badge variant="outline" className="text-xs">
Vision
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
{model.description}
</p>
</div>
</div>
</div>
);
})
)}
</div>
</div>
);
})}
</>
)}
</div>
</div>
</div>

View File

@@ -1,18 +1,19 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { useState, useCallback, useEffect } from 'react';
import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store';
import { OpencodeCliStatus, OpencodeCliStatusSkeleton } from '../cli-status/opencode-cli-status';
import {
OpencodeCliStatus,
OpencodeCliStatusSkeleton,
OpencodeModelConfigSkeleton,
} from '../cli-status/opencode-cli-status';
import { OpencodeModelConfiguration } from './opencode-model-configuration';
import { getElectronAPI } from '@/lib/electron';
import { createLogger } from '@automaker/utils/logger';
import type { CliStatus as SharedCliStatus } from '../shared/types';
import type { OpencodeModelId } from '@automaker/types';
import type { OpencodeAuthStatus, OpenCodeProviderInfo } from '../cli-status/opencode-cli-status';
import type { OpencodeAuthStatus } from '../cli-status/opencode-cli-status';
const logger = createLogger('OpencodeSettings');
const OPENCODE_PROVIDER_ID = 'opencode';
const OPENCODE_PROVIDER_SIGNATURE_SEPARATOR = '|';
const OPENCODE_STATIC_MODEL_PROVIDERS = new Set([OPENCODE_PROVIDER_ID]);
export function OpencodeSettingsTab() {
const {
@@ -20,22 +21,15 @@ export function OpencodeSettingsTab() {
opencodeDefaultModel,
setOpencodeDefaultModel,
toggleOpencodeModel,
setDynamicOpencodeModels,
dynamicOpencodeModels,
enabledDynamicModelIds,
toggleDynamicModel,
cachedOpencodeProviders,
setCachedOpencodeProviders,
} = useAppStore();
const [isCheckingOpencodeCli, setIsCheckingOpencodeCli] = useState(false);
const [isLoadingDynamicModels, setIsLoadingDynamicModels] = useState(false);
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [cliStatus, setCliStatus] = useState<SharedCliStatus | null>(null);
const [authStatus, setAuthStatus] = useState<OpencodeAuthStatus | null>(null);
const [isSaving, setIsSaving] = useState(false);
const providerRefreshSignatureRef = useRef<string>('');
// Phase 1: Load CLI status quickly on mount
// Load OpenCode CLI status on mount
useEffect(() => {
const checkOpencodeStatus = async () => {
setIsCheckingOpencodeCli(true);
@@ -52,6 +46,7 @@ export function OpencodeSettingsTab() {
recommendation: result.recommendation,
installCommands: result.installCommands,
});
// Set auth status if available
if (result.auth) {
setAuthStatus({
authenticated: result.auth.authenticated,
@@ -62,6 +57,7 @@ export function OpencodeSettingsTab() {
});
}
} else {
// Fallback for web mode or when API is not available
setCliStatus({
success: false,
status: 'not_installed',
@@ -77,111 +73,14 @@ export function OpencodeSettingsTab() {
});
} finally {
setIsCheckingOpencodeCli(false);
setIsInitialLoading(false);
}
};
checkOpencodeStatus();
}, []);
// Phase 2: Load dynamic models and providers in background (only if not cached)
useEffect(() => {
const loadDynamicContent = async () => {
const api = getElectronAPI();
const isInstalled = cliStatus?.success && cliStatus?.status === 'installed';
if (!isInstalled || !api?.setup) return;
// Skip if already have cached data
const needsProviders = cachedOpencodeProviders.length === 0;
const needsModels = dynamicOpencodeModels.length === 0;
if (!needsProviders && !needsModels) return;
setIsLoadingDynamicModels(true);
try {
// Load providers if needed
if (needsProviders && api.setup.getOpencodeProviders) {
const providersResult = await api.setup.getOpencodeProviders();
if (providersResult.success && providersResult.providers) {
setCachedOpencodeProviders(providersResult.providers);
}
}
// Load models if needed
if (needsModels && api.setup.getOpencodeModels) {
const modelsResult = await api.setup.getOpencodeModels();
if (modelsResult.success && modelsResult.models) {
setDynamicOpencodeModels(modelsResult.models);
}
}
} catch (error) {
logger.error('Failed to load dynamic content:', error);
} finally {
setIsLoadingDynamicModels(false);
}
};
loadDynamicContent();
}, [cliStatus?.success, cliStatus?.status]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const refreshModelsForNewProviders = async () => {
const api = getElectronAPI();
const isInstalled = cliStatus?.success && cliStatus?.status === 'installed';
if (!isInstalled || !api?.setup?.refreshOpencodeModels) return;
if (isLoadingDynamicModels) return;
const authenticatedProviders = cachedOpencodeProviders
.filter((provider) => provider.authenticated)
.map((provider) => provider.id)
.filter((providerId) => !OPENCODE_STATIC_MODEL_PROVIDERS.has(providerId));
if (authenticatedProviders.length === 0) {
providerRefreshSignatureRef.current = '';
return;
}
const dynamicProviderIds = new Set(
dynamicOpencodeModels.map((model) => model.provider).filter(Boolean)
);
const missingProviders = authenticatedProviders.filter(
(providerId) => !dynamicProviderIds.has(providerId)
);
if (missingProviders.length === 0) {
providerRefreshSignatureRef.current = '';
return;
}
const signature = [...missingProviders].sort().join(OPENCODE_PROVIDER_SIGNATURE_SEPARATOR);
if (providerRefreshSignatureRef.current === signature) return;
providerRefreshSignatureRef.current = signature;
setIsLoadingDynamicModels(true);
try {
const modelsResult = await api.setup.refreshOpencodeModels();
if (modelsResult.success && modelsResult.models) {
setDynamicOpencodeModels(modelsResult.models);
}
} catch (error) {
logger.error('Failed to refresh OpenCode models for new providers:', error);
} finally {
setIsLoadingDynamicModels(false);
}
};
refreshModelsForNewProviders();
}, [
cachedOpencodeProviders,
dynamicOpencodeModels,
cliStatus?.success,
cliStatus?.status,
isLoadingDynamicModels,
setDynamicOpencodeModels,
]);
const handleRefreshOpencodeCli = useCallback(async () => {
setIsCheckingOpencodeCli(true);
setIsLoadingDynamicModels(true);
try {
const api = getElectronAPI();
if (api?.setup?.getOpencodeStatus) {
@@ -195,6 +94,7 @@ export function OpencodeSettingsTab() {
recommendation: result.recommendation,
installCommands: result.installCommands,
});
// Update auth status if available
if (result.auth) {
setAuthStatus({
authenticated: result.auth.authenticated,
@@ -204,35 +104,14 @@ export function OpencodeSettingsTab() {
hasOAuthToken: result.auth.hasOAuthToken,
});
}
if (result.installed) {
// Refresh providers
if (api?.setup?.getOpencodeProviders) {
const providersResult = await api.setup.getOpencodeProviders();
if (providersResult.success && providersResult.providers) {
setCachedOpencodeProviders(providersResult.providers);
}
}
// Refresh dynamic models
if (api?.setup?.refreshOpencodeModels) {
const modelsResult = await api.setup.refreshOpencodeModels();
if (modelsResult.success && modelsResult.models) {
setDynamicOpencodeModels(modelsResult.models);
}
}
toast.success('OpenCode CLI refreshed');
}
}
} catch (error) {
logger.error('Failed to refresh OpenCode CLI status:', error);
toast.error('Failed to refresh OpenCode CLI status');
} finally {
setIsCheckingOpencodeCli(false);
setIsLoadingDynamicModels(false);
}
}, [setDynamicOpencodeModels, setCachedOpencodeProviders]);
}, []);
const handleDefaultModelChange = useCallback(
(model: OpencodeModelId) => {
@@ -263,25 +142,12 @@ export function OpencodeSettingsTab() {
[toggleOpencodeModel]
);
const handleDynamicModelToggle = useCallback(
(modelId: string, enabled: boolean) => {
setIsSaving(true);
try {
toggleDynamicModel(modelId, enabled);
} catch (error) {
toast.error('Failed to update dynamic model');
} finally {
setIsSaving(false);
}
},
[toggleDynamicModel]
);
// Show skeleton only while checking CLI status initially
if (!cliStatus && isCheckingOpencodeCli) {
// Show loading skeleton during initial load
if (isInitialLoading) {
return (
<div className="space-y-6">
<OpencodeCliStatusSkeleton />
<OpencodeModelConfigSkeleton />
</div>
);
}
@@ -293,7 +159,6 @@ export function OpencodeSettingsTab() {
<OpencodeCliStatus
status={cliStatus}
authStatus={authStatus}
providers={cachedOpencodeProviders as OpenCodeProviderInfo[]}
isChecking={isCheckingOpencodeCli}
onRefresh={handleRefreshOpencodeCli}
/>
@@ -306,11 +171,6 @@ export function OpencodeSettingsTab() {
isSaving={isSaving}
onDefaultModelChange={handleDefaultModelChange}
onModelToggle={handleModelToggle}
providers={cachedOpencodeProviders as OpenCodeProviderInfo[]}
dynamicModels={dynamicOpencodeModels}
enabledDynamicModelIds={enabledDynamicModelIds}
onDynamicModelToggle={handleDynamicModelToggle}
isLoadingDynamicModels={isLoadingDynamicModels}
/>
)}
</div>

View File

@@ -1 +0,0 @@
export { WorktreesSection } from './worktrees-section';

View File

@@ -1,430 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { ShellSyntaxEditor } from '@/components/ui/shell-syntax-editor';
import {
GitBranch,
Terminal,
FileCode,
Save,
RotateCcw,
Trash2,
Loader2,
PanelBottomClose,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { apiGet, apiPut, apiDelete } from '@/lib/api-fetch';
import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store';
import { getHttpApiClient } from '@/lib/http-api-client';
interface WorktreesSectionProps {
useWorktrees: boolean;
onUseWorktreesChange: (value: boolean) => void;
}
interface InitScriptResponse {
success: boolean;
exists: boolean;
content: string;
path: string;
error?: string;
}
export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: WorktreesSectionProps) {
const currentProject = useAppStore((s) => s.currentProject);
const getShowInitScriptIndicator = useAppStore((s) => s.getShowInitScriptIndicator);
const setShowInitScriptIndicator = useAppStore((s) => s.setShowInitScriptIndicator);
const getDefaultDeleteBranch = useAppStore((s) => s.getDefaultDeleteBranch);
const setDefaultDeleteBranch = useAppStore((s) => s.setDefaultDeleteBranch);
const getAutoDismissInitScriptIndicator = useAppStore((s) => s.getAutoDismissInitScriptIndicator);
const setAutoDismissInitScriptIndicator = useAppStore((s) => s.setAutoDismissInitScriptIndicator);
const [scriptContent, setScriptContent] = useState('');
const [originalContent, setOriginalContent] = useState('');
const [scriptExists, setScriptExists] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// Get the current show indicator setting
const showIndicator = currentProject?.path
? getShowInitScriptIndicator(currentProject.path)
: true;
// Get the default delete branch setting
const defaultDeleteBranch = currentProject?.path
? getDefaultDeleteBranch(currentProject.path)
: false;
// Get the auto-dismiss setting
const autoDismiss = currentProject?.path
? getAutoDismissInitScriptIndicator(currentProject.path)
: true;
// Check if there are unsaved changes
const hasChanges = scriptContent !== originalContent;
// Load init script content when project changes
useEffect(() => {
if (!currentProject?.path) {
setScriptContent('');
setOriginalContent('');
setScriptExists(false);
setIsLoading(false);
return;
}
const loadInitScript = async () => {
setIsLoading(true);
try {
const response = await apiGet<InitScriptResponse>(
`/api/worktree/init-script?projectPath=${encodeURIComponent(currentProject.path)}`
);
if (response.success) {
const content = response.content || '';
setScriptContent(content);
setOriginalContent(content);
setScriptExists(response.exists);
}
} catch (error) {
console.error('Failed to load init script:', error);
} finally {
setIsLoading(false);
}
};
loadInitScript();
}, [currentProject?.path]);
// Save script
const handleSave = useCallback(async () => {
if (!currentProject?.path) return;
setIsSaving(true);
try {
const response = await apiPut<{ success: boolean; error?: string }>(
'/api/worktree/init-script',
{
projectPath: currentProject.path,
content: scriptContent,
}
);
if (response.success) {
setOriginalContent(scriptContent);
setScriptExists(true);
toast.success('Init script saved');
} else {
toast.error('Failed to save init script', {
description: response.error,
});
}
} catch (error) {
console.error('Failed to save init script:', error);
toast.error('Failed to save init script');
} finally {
setIsSaving(false);
}
}, [currentProject?.path, scriptContent]);
// Reset to original content
const handleReset = useCallback(() => {
setScriptContent(originalContent);
}, [originalContent]);
// Delete script
const handleDelete = useCallback(async () => {
if (!currentProject?.path) return;
setIsDeleting(true);
try {
const response = await apiDelete<{ success: boolean; error?: string }>(
'/api/worktree/init-script',
{
body: { projectPath: currentProject.path },
}
);
if (response.success) {
setScriptContent('');
setOriginalContent('');
setScriptExists(false);
toast.success('Init script deleted');
} else {
toast.error('Failed to delete init script', {
description: response.error,
});
}
} catch (error) {
console.error('Failed to delete init script:', error);
toast.error('Failed to delete init script');
} finally {
setIsDeleting(false);
}
}, [currentProject?.path]);
// Handle content change (no auto-save)
const handleContentChange = useCallback((value: string) => {
setScriptContent(value);
}, []);
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<GitBranch className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Worktrees</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Configure git worktree isolation and initialization scripts.
</p>
</div>
<div className="p-6 space-y-5">
{/* Enable Worktrees Toggle */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="use-worktrees"
checked={useWorktrees}
onCheckedChange={(checked) => onUseWorktreesChange(checked === true)}
className="mt-1"
data-testid="use-worktrees-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="use-worktrees"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<GitBranch className="w-4 h-4 text-brand-500" />
Enable Git Worktree Isolation
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Creates isolated git branches for each feature. When disabled, agents work directly in
the main project directory.
</p>
</div>
</div>
{/* Show Init Script Indicator Toggle */}
{currentProject && (
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3 mt-4">
<Checkbox
id="show-init-script-indicator"
checked={showIndicator}
onCheckedChange={async (checked) => {
if (currentProject?.path) {
const value = checked === true;
setShowInitScriptIndicator(currentProject.path, value);
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(currentProject.path, {
showInitScriptIndicator: value,
});
} catch (error) {
console.error('Failed to persist showInitScriptIndicator:', error);
}
}
}}
className="mt-1"
/>
<div className="space-y-1.5">
<Label
htmlFor="show-init-script-indicator"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<PanelBottomClose className="w-4 h-4 text-brand-500" />
Show Init Script Indicator
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Display a floating panel in the bottom-right corner showing init script execution
status and output when a worktree is created.
</p>
</div>
</div>
)}
{/* Auto-dismiss Init Script Indicator Toggle */}
{currentProject && showIndicator && (
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3 ml-6">
<Checkbox
id="auto-dismiss-indicator"
checked={autoDismiss}
onCheckedChange={async (checked) => {
if (currentProject?.path) {
const value = checked === true;
setAutoDismissInitScriptIndicator(currentProject.path, value);
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(currentProject.path, {
autoDismissInitScriptIndicator: value,
});
} catch (error) {
console.error('Failed to persist autoDismissInitScriptIndicator:', error);
}
}
}}
className="mt-1"
/>
<div className="space-y-1.5">
<Label
htmlFor="auto-dismiss-indicator"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
Auto-dismiss After Completion
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Automatically hide the indicator 5 seconds after the script completes.
</p>
</div>
</div>
)}
{/* Default Delete Branch Toggle */}
{currentProject && (
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="default-delete-branch"
checked={defaultDeleteBranch}
onCheckedChange={async (checked) => {
if (currentProject?.path) {
const value = checked === true;
setDefaultDeleteBranch(currentProject.path, value);
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(currentProject.path, {
defaultDeleteBranch: value,
});
} catch (error) {
console.error('Failed to persist defaultDeleteBranch:', error);
}
}
}}
className="mt-1"
/>
<div className="space-y-1.5">
<Label
htmlFor="default-delete-branch"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<Trash2 className="w-4 h-4 text-brand-500" />
Delete Branch by Default
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
When deleting a worktree, automatically check the "Also delete the branch" option.
</p>
</div>
</div>
)}
{/* Separator */}
<div className="border-t border-border/30" />
{/* Init Script Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Terminal className="w-4 h-4 text-brand-500" />
<Label className="text-foreground font-medium">Initialization Script</Label>
</div>
</div>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Shell commands to run after a worktree is created. Runs once per worktree. Uses Git Bash
on Windows for cross-platform compatibility.
</p>
{currentProject ? (
<>
{/* File path indicator */}
<div className="flex items-center gap-2 text-xs text-muted-foreground/60">
<FileCode className="w-3.5 h-3.5" />
<code className="font-mono">.automaker/worktree-init.sh</code>
{hasChanges && (
<span className="text-amber-500 font-medium">(unsaved changes)</span>
)}
</div>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : (
<>
<ShellSyntaxEditor
value={scriptContent}
onChange={handleContentChange}
placeholder={`# Example initialization commands
npm install
# Or use pnpm
# pnpm install
# Copy environment file
# cp .env.example .env`}
minHeight="200px"
maxHeight="500px"
data-testid="init-script-editor"
/>
{/* Action buttons */}
<div className="flex items-center justify-end gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={!hasChanges || isSaving || isDeleting}
className="gap-1.5"
>
<RotateCcw className="w-3.5 h-3.5" />
Reset
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDelete}
disabled={!scriptExists || isSaving || isDeleting}
className="gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/10"
>
{isDeleting ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Trash2 className="w-3.5 h-3.5" />
)}
Delete
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={!hasChanges || isSaving || isDeleting}
className="gap-1.5"
>
{isSaving ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Save className="w-3.5 h-3.5" />
)}
Save
</Button>
</div>
</>
)}
</>
) : (
<div className="text-sm text-muted-foreground/60 py-4 text-center">
Select a project to configure the init script.
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -182,7 +182,7 @@ export function OpencodeSetupStep({ onNext, onBack, onSkip }: OpencodeSetupStepP
<div>
<p className="font-medium text-foreground">This step is optional</p>
<p className="text-sm text-muted-foreground mt-1">
Configure OpenCode CLI for access to free tier models and connected providers. You
Configure OpenCode CLI for access to free tier models and AWS Bedrock models. You
can skip this and use other providers, or configure it later in Settings.
</p>
</div>
@@ -241,7 +241,7 @@ export function OpencodeSetupStep({ onNext, onBack, onSkip }: OpencodeSetupStepP
<div className="flex-1">
<p className="font-medium text-foreground">OpenCode CLI not found</p>
<p className="text-sm text-muted-foreground mt-1">
Install the OpenCode CLI to use free tier models and connected providers.
Install the OpenCode CLI to use free tier and AWS Bedrock models.
</p>
</div>
</div>

View File

@@ -1119,7 +1119,7 @@ function OpencodeContent() {
<div className="flex-1">
<p className="font-medium text-foreground">OpenCode CLI not found</p>
<p className="text-sm text-muted-foreground mt-1">
Install the OpenCode CLI for free tier models and connected providers.
Install the OpenCode CLI for free tier and AWS Bedrock models.
</p>
</div>
</div>

View File

@@ -44,7 +44,6 @@ import { getElectronAPI } from '@/lib/electron';
import { getApiKey, getSessionToken, getServerUrlSync } from '@/lib/http-api-client';
const logger = createLogger('Terminal');
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
// Font size constraints
const MIN_FONT_SIZE = 8;
@@ -505,7 +504,6 @@ export function TerminalPanel({
const response = await fetch(`${serverUrl}/api/auth/token`, {
headers,
credentials: 'include',
cache: NO_STORE_CACHE_MODE,
});
if (!response.ok) {

View File

@@ -1,79 +0,0 @@
import { useEffect } from 'react';
import { useAppStore } from '@/store/app-store';
import { getHttpApiClient } from '@/lib/http-api-client';
import { pathsEqual } from '@/lib/utils';
interface InitScriptStartedPayload {
projectPath: string;
worktreePath: string;
branch: string;
}
interface InitScriptOutputPayload {
projectPath: string;
branch: string;
type: 'stdout' | 'stderr';
content: string;
}
interface InitScriptCompletedPayload {
projectPath: string;
worktreePath: string;
branch: string;
success: boolean;
exitCode?: number;
error?: string;
}
/**
* Hook to subscribe to init script WebSocket events and update the store.
* Should be used in a component that's always mounted (e.g., board-view).
*/
export function useInitScriptEvents(projectPath: string | null) {
const setInitScriptState = useAppStore((s) => s.setInitScriptState);
const appendInitScriptOutput = useAppStore((s) => s.appendInitScriptOutput);
useEffect(() => {
if (!projectPath) return;
const api = getHttpApiClient();
const unsubscribe = api.worktree.onInitScriptEvent((event) => {
const payload = event.payload as
| InitScriptStartedPayload
| InitScriptOutputPayload
| InitScriptCompletedPayload;
// Only handle events for the current project (use pathsEqual for cross-platform path comparison)
if (!pathsEqual(payload.projectPath, projectPath)) return;
switch (event.type) {
case 'worktree:init-started': {
const startPayload = payload as InitScriptStartedPayload;
setInitScriptState(projectPath, startPayload.branch, {
status: 'running',
branch: startPayload.branch,
output: [],
error: undefined,
});
break;
}
case 'worktree:init-output': {
const outputPayload = payload as InitScriptOutputPayload;
appendInitScriptOutput(projectPath, outputPayload.branch, outputPayload.content);
break;
}
case 'worktree:init-completed': {
const completePayload = payload as InitScriptCompletedPayload;
setInitScriptState(projectPath, completePayload.branch, {
status: completePayload.success ? 'success' : 'failed',
error: completePayload.error,
});
break;
}
}
});
return unsubscribe;
}, [projectPath, setInitScriptState, appendInitScriptOutput]);
}

View File

@@ -18,11 +18,6 @@ export function useProjectSettingsLoader() {
const setCardBorderOpacity = useAppStore((state) => state.setCardBorderOpacity);
const setHideScrollbar = useAppStore((state) => state.setHideScrollbar);
const setWorktreePanelVisible = useAppStore((state) => state.setWorktreePanelVisible);
const setShowInitScriptIndicator = useAppStore((state) => state.setShowInitScriptIndicator);
const setDefaultDeleteBranch = useAppStore((state) => state.setDefaultDeleteBranch);
const setAutoDismissInitScriptIndicator = useAppStore(
(state) => state.setAutoDismissInitScriptIndicator
);
const loadingRef = useRef<string | null>(null);
const currentProjectRef = useRef<string | null>(null);
@@ -83,27 +78,6 @@ export function useProjectSettingsLoader() {
if (result.settings.worktreePanelVisible !== undefined) {
setWorktreePanelVisible(requestedProjectPath, result.settings.worktreePanelVisible);
}
// Apply showInitScriptIndicator if present
if (result.settings.showInitScriptIndicator !== undefined) {
setShowInitScriptIndicator(
requestedProjectPath,
result.settings.showInitScriptIndicator
);
}
// Apply defaultDeleteBranch if present
if (result.settings.defaultDeleteBranch !== undefined) {
setDefaultDeleteBranch(requestedProjectPath, result.settings.defaultDeleteBranch);
}
// Apply autoDismissInitScriptIndicator if present
if (result.settings.autoDismissInitScriptIndicator !== undefined) {
setAutoDismissInitScriptIndicator(
requestedProjectPath,
result.settings.autoDismissInitScriptIndicator
);
}
}
} catch (error) {
console.error('Failed to load project settings:', error);

View File

@@ -28,11 +28,7 @@ import { getHttpApiClient, waitForApiKeyInit } from '@/lib/http-api-client';
import { getItem, setItem } from '@/lib/storage';
import { useAppStore, THEME_STORAGE_KEY } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import {
DEFAULT_OPENCODE_MODEL,
getAllOpencodeModelIds,
type GlobalSettings,
} from '@automaker/types';
import type { GlobalSettings } from '@automaker/types';
const logger = createLogger('SettingsMigration');
@@ -156,10 +152,6 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
phaseModels: state.phaseModels as GlobalSettings['phaseModels'],
enabledCursorModels: state.enabledCursorModels as GlobalSettings['enabledCursorModels'],
cursorDefaultModel: state.cursorDefaultModel as GlobalSettings['cursorDefaultModel'],
enabledOpencodeModels: state.enabledOpencodeModels as GlobalSettings['enabledOpencodeModels'],
opencodeDefaultModel: state.opencodeDefaultModel as GlobalSettings['opencodeDefaultModel'],
enabledDynamicModelIds:
state.enabledDynamicModelIds as GlobalSettings['enabledDynamicModelIds'],
autoLoadClaudeMd: state.autoLoadClaudeMd as boolean,
keyboardShortcuts: state.keyboardShortcuts as GlobalSettings['keyboardShortcuts'],
mcpServers: state.mcpServers as GlobalSettings['mcpServers'],
@@ -503,27 +495,6 @@ export function useSettingsMigration(): MigrationState {
*/
export function hydrateStoreFromSettings(settings: GlobalSettings): void {
const current = useAppStore.getState();
const validOpencodeModelIds = new Set(getAllOpencodeModelIds());
const incomingEnabledOpencodeModels =
settings.enabledOpencodeModels ?? current.enabledOpencodeModels;
const sanitizedOpencodeDefaultModel = validOpencodeModelIds.has(
settings.opencodeDefaultModel ?? current.opencodeDefaultModel
)
? (settings.opencodeDefaultModel ?? current.opencodeDefaultModel)
: DEFAULT_OPENCODE_MODEL;
const sanitizedEnabledOpencodeModels = Array.from(
new Set(incomingEnabledOpencodeModels.filter((modelId) => validOpencodeModelIds.has(modelId)))
);
if (!sanitizedEnabledOpencodeModels.includes(sanitizedOpencodeDefaultModel)) {
sanitizedEnabledOpencodeModels.push(sanitizedOpencodeDefaultModel);
}
const persistedDynamicModelIds =
settings.enabledDynamicModelIds ?? current.enabledDynamicModelIds;
const sanitizedDynamicModelIds = persistedDynamicModelIds.filter(
(modelId) => !modelId.startsWith('amazon-bedrock/')
);
// Convert ProjectRef[] to Project[] (minimal data, features will be loaded separately)
const projects = (settings.projects ?? []).map((ref) => ({
@@ -546,9 +517,8 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
}
// Save theme to localStorage for fallback when server settings aren't available
const storedTheme = (currentProject?.theme as string | undefined) || settings.theme;
if (storedTheme) {
setItem(THEME_STORAGE_KEY, storedTheme);
if (settings.theme) {
setItem(THEME_STORAGE_KEY, settings.theme);
}
useAppStore.setState({
@@ -568,9 +538,6 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
phaseModels: settings.phaseModels ?? current.phaseModels,
enabledCursorModels: settings.enabledCursorModels ?? current.enabledCursorModels,
cursorDefaultModel: settings.cursorDefaultModel ?? 'auto',
enabledOpencodeModels: sanitizedEnabledOpencodeModels,
opencodeDefaultModel: sanitizedOpencodeDefaultModel,
enabledDynamicModelIds: sanitizedDynamicModelIds,
autoLoadClaudeMd: settings.autoLoadClaudeMd ?? false,
skipSandboxWarning: settings.skipSandboxWarning ?? false,
keyboardShortcuts: {
@@ -624,7 +591,6 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
enhancementModel: state.enhancementModel,
validationModel: state.validationModel,
phaseModels: state.phaseModels,
enabledDynamicModelIds: state.enabledDynamicModelIds,
autoLoadClaudeMd: state.autoLoadClaudeMd,
skipSandboxWarning: state.skipSandboxWarning,
keyboardShortcuts: state.keyboardShortcuts,

View File

@@ -19,11 +19,7 @@ import { useAppStore, type ThemeMode, THEME_STORAGE_KEY } from '@/store/app-stor
import { useSetupStore } from '@/store/setup-store';
import { useAuthStore } from '@/store/auth-store';
import { waitForMigrationComplete, resetMigrationState } from './use-settings-migration';
import {
DEFAULT_OPENCODE_MODEL,
getAllOpencodeModelIds,
type GlobalSettings,
} from '@automaker/types';
import type { GlobalSettings } from '@automaker/types';
const logger = createLogger('SettingsSync');
@@ -48,13 +44,9 @@ const SETTINGS_FIELDS_TO_SYNC = [
'phaseModels',
'enabledCursorModels',
'cursorDefaultModel',
'enabledOpencodeModels',
'opencodeDefaultModel',
'enabledDynamicModelIds',
'autoLoadClaudeMd',
'keyboardShortcuts',
'mcpServers',
'defaultEditorCommand',
'promptCustomization',
'projects',
'trashedProjects',
@@ -97,7 +89,6 @@ export function useSettingsSync(): SettingsSyncState {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const authChecked = useAuthStore((s) => s.authChecked);
const settingsLoaded = useAuthStore((s) => s.settingsLoaded);
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastSyncedRef = useRef<string>('');
@@ -126,17 +117,9 @@ export function useSettingsSync(): SettingsSyncState {
// Debounced sync function
const syncToServer = useCallback(async () => {
try {
// Never sync when not authenticated or settings not loaded
// The settingsLoaded flag ensures we don't sync default empty state before hydration
// Never sync when not authenticated (prevents overwriting server settings during logout/login transitions)
const auth = useAuthStore.getState();
logger.debug('syncToServer check:', {
authChecked: auth.authChecked,
isAuthenticated: auth.isAuthenticated,
settingsLoaded: auth.settingsLoaded,
projectsCount: useAppStore.getState().projects?.length ?? 0,
});
if (!auth.authChecked || !auth.isAuthenticated || !auth.settingsLoaded) {
logger.debug('Sync skipped: not authenticated or settings not loaded');
if (!auth.authChecked || !auth.isAuthenticated) {
return;
}
@@ -144,8 +127,6 @@ export function useSettingsSync(): SettingsSyncState {
const api = getHttpApiClient();
const appState = useAppStore.getState();
logger.debug('Syncing to server:', { projectsCount: appState.projects?.length ?? 0 });
// Build updates object from current state
const updates: Record<string, unknown> = {};
for (const field of SETTINGS_FIELDS_TO_SYNC) {
@@ -166,13 +147,10 @@ export function useSettingsSync(): SettingsSyncState {
// Create a hash of the updates to avoid redundant syncs
const updateHash = JSON.stringify(updates);
if (updateHash === lastSyncedRef.current) {
logger.debug('Sync skipped: no changes');
setState((s) => ({ ...s, syncing: false }));
return;
}
logger.info('Sending settings update:', { projects: updates.projects });
const result = await api.settings.updateGlobal(updates);
if (result.success) {
lastSyncedRef.current = updateHash;
@@ -206,20 +184,11 @@ export function useSettingsSync(): SettingsSyncState {
void syncToServer();
}, [syncToServer]);
// Initialize sync - WAIT for settings to be loaded and migration to complete
// Initialize sync - WAIT for migration to complete first
useEffect(() => {
// Don't initialize syncing until:
// 1. Auth has been checked
// 2. User is authenticated
// 3. Settings have been loaded from server (settingsLoaded flag)
// This prevents syncing empty/default state before hydration completes.
logger.debug('useSettingsSync initialization check:', {
authChecked,
isAuthenticated,
settingsLoaded,
stateLoaded: state.loaded,
});
if (!authChecked || !isAuthenticated || !settingsLoaded) return;
// Don't initialize syncing until we know auth status and are authenticated.
// Prevents accidental overwrites when the app boots before settings are hydrated.
if (!authChecked || !isAuthenticated) return;
if (isInitializedRef.current) return;
isInitializedRef.current = true;
@@ -229,26 +198,14 @@ export function useSettingsSync(): SettingsSyncState {
await waitForApiKeyInit();
// CRITICAL: Wait for migration/hydration to complete before we start syncing
// This is a backup to the settingsLoaded flag for extra safety
// This prevents overwriting server data with empty/default state
logger.info('Waiting for migration to complete before starting sync...');
await waitForMigrationComplete();
// Wait for React to finish rendering after store hydration.
// Zustand's subscribe() fires during setState(), which happens BEFORE React's
// render completes. Use a small delay to ensure all pending state updates
// have propagated through the React tree before we read state.
await new Promise((resolve) => setTimeout(resolve, 50));
logger.info('Migration complete, initializing sync');
// Read state - at this point React has processed the store update
const appState = useAppStore.getState();
const setupState = useSetupStore.getState();
logger.info('Initial state read:', { projectsCount: appState.projects?.length ?? 0 });
// Store the initial state hash to avoid immediate re-sync
// (migration has already hydrated the store from server/localStorage)
const appState = useAppStore.getState();
const updates: Record<string, unknown> = {};
for (const field of SETTINGS_FIELDS_TO_SYNC) {
if (field === 'currentProjectId') {
@@ -257,6 +214,7 @@ export function useSettingsSync(): SettingsSyncState {
updates[field] = appState[field as keyof typeof appState];
}
}
const setupState = useSetupStore.getState();
for (const field of SETUP_FIELDS_TO_SYNC) {
updates[field] = setupState[field as keyof typeof setupState];
}
@@ -275,33 +233,16 @@ export function useSettingsSync(): SettingsSyncState {
}
initializeSync();
}, [authChecked, isAuthenticated, settingsLoaded]);
}, [authChecked, isAuthenticated]);
// Subscribe to store changes and sync to server
useEffect(() => {
if (!state.loaded || !authChecked || !isAuthenticated || !settingsLoaded) return;
if (!state.loaded || !authChecked || !isAuthenticated) return;
// Subscribe to app store changes
const unsubscribeApp = useAppStore.subscribe((newState, prevState) => {
const auth = useAuthStore.getState();
logger.debug('Store subscription fired:', {
prevProjects: prevState.projects?.length ?? 0,
newProjects: newState.projects?.length ?? 0,
authChecked: auth.authChecked,
isAuthenticated: auth.isAuthenticated,
settingsLoaded: auth.settingsLoaded,
loaded: state.loaded,
});
// Don't sync if settings not loaded yet
if (!auth.settingsLoaded) {
logger.debug('Store changed but settings not loaded, skipping sync');
return;
}
// If the current project changed, sync immediately so we can restore on next launch
if (newState.currentProject?.id !== prevState.currentProject?.id) {
logger.debug('Current project changed, syncing immediately');
syncNow();
return;
}
@@ -325,7 +266,6 @@ export function useSettingsSync(): SettingsSyncState {
}
if (changed) {
logger.debug('Store changed, scheduling sync');
scheduleSyncToServer();
}
});
@@ -354,11 +294,11 @@ export function useSettingsSync(): SettingsSyncState {
clearTimeout(syncTimeoutRef.current);
}
};
}, [state.loaded, authChecked, isAuthenticated, settingsLoaded, scheduleSyncToServer, syncNow]);
}, [state.loaded, authChecked, isAuthenticated, scheduleSyncToServer, syncNow]);
// Best-effort flush on tab close / backgrounding
useEffect(() => {
if (!state.loaded || !authChecked || !isAuthenticated || !settingsLoaded) return;
if (!state.loaded || !authChecked || !isAuthenticated) return;
const handleBeforeUnload = () => {
// Fire-and-forget; may not complete in all browsers, but helps in Electron/webview
@@ -378,7 +318,7 @@ export function useSettingsSync(): SettingsSyncState {
window.removeEventListener('beforeunload', handleBeforeUnload);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [state.loaded, authChecked, isAuthenticated, settingsLoaded, syncNow]);
}, [state.loaded, authChecked, isAuthenticated, syncNow]);
return state;
}
@@ -428,27 +368,6 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
const serverSettings = result.settings as unknown as GlobalSettings;
const currentAppState = useAppStore.getState();
const validOpencodeModelIds = new Set(getAllOpencodeModelIds());
const incomingEnabledOpencodeModels =
serverSettings.enabledOpencodeModels ?? currentAppState.enabledOpencodeModels;
const sanitizedOpencodeDefaultModel = validOpencodeModelIds.has(
serverSettings.opencodeDefaultModel ?? currentAppState.opencodeDefaultModel
)
? (serverSettings.opencodeDefaultModel ?? currentAppState.opencodeDefaultModel)
: DEFAULT_OPENCODE_MODEL;
const sanitizedEnabledOpencodeModels = Array.from(
new Set(incomingEnabledOpencodeModels.filter((modelId) => validOpencodeModelIds.has(modelId)))
);
if (!sanitizedEnabledOpencodeModels.includes(sanitizedOpencodeDefaultModel)) {
sanitizedEnabledOpencodeModels.push(sanitizedOpencodeDefaultModel);
}
const persistedDynamicModelIds =
serverSettings.enabledDynamicModelIds ?? currentAppState.enabledDynamicModelIds;
const sanitizedDynamicModelIds = persistedDynamicModelIds.filter(
(modelId) => !modelId.startsWith('amazon-bedrock/')
);
// Save theme to localStorage for fallback when server settings aren't available
if (serverSettings.theme) {
@@ -472,9 +391,6 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
phaseModels: serverSettings.phaseModels,
enabledCursorModels: serverSettings.enabledCursorModels,
cursorDefaultModel: serverSettings.cursorDefaultModel,
enabledOpencodeModels: sanitizedEnabledOpencodeModels,
opencodeDefaultModel: sanitizedOpencodeDefaultModel,
enabledDynamicModelIds: sanitizedDynamicModelIds,
autoLoadClaudeMd: serverSettings.autoLoadClaudeMd ?? false,
keyboardShortcuts: {
...currentAppState.keyboardShortcuts,
@@ -483,7 +399,6 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
>),
},
mcpServers: serverSettings.mcpServers,
defaultEditorCommand: serverSettings.defaultEditorCommand ?? null,
promptCustomization: serverSettings.promptCustomization ?? {},
projects: serverSettings.projects,
trashedProjects: serverSettings.trashedProjects,

View File

@@ -13,7 +13,6 @@ import { getApiKey, getSessionToken, getServerUrlSync } from './http-api-client'
// Server URL - uses shared cached URL from http-api-client
const getServerUrl = (): string => getServerUrlSync();
const DEFAULT_CACHE_MODE: RequestCache = 'no-store';
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
@@ -81,7 +80,7 @@ export async function apiFetch(
method: HttpMethod = 'GET',
options: ApiFetchOptions = {}
): Promise<Response> {
const { headers: additionalHeaders, body, skipAuth, cache, ...restOptions } = options;
const { headers: additionalHeaders, body, skipAuth, ...restOptions } = options;
const headers = skipAuth
? { 'Content-Type': 'application/json', ...additionalHeaders }
@@ -91,7 +90,6 @@ export async function apiFetch(
method,
headers,
credentials: 'include',
cache: cache ?? DEFAULT_CACHE_MODE,
...restOptions,
};

View File

@@ -462,8 +462,7 @@ export interface FeaturesAPI {
featureId: string,
updates: Partial<Feature>,
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
preEnhancementDescription?: string
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
) => Promise<{ success: boolean; feature?: Feature; error?: string }>;
delete: (projectPath: string, featureId: string) => Promise<{ success: boolean; error?: string }>;
getAgentOutput: (
@@ -613,8 +612,7 @@ export interface ElectronAPI {
enhance: (
originalText: string,
enhancementMode: string,
model?: string,
thinkingLevel?: string
model?: string
) => Promise<{
success: boolean;
enhancedText?: string;
@@ -651,8 +649,7 @@ export interface ElectronAPI {
removedDependencies: string[];
addedDependencies: string[];
}>;
},
branchName?: string
}
) => Promise<{ success: boolean; appliedChanges?: string[]; error?: string }>;
onEvent: (callback: (data: unknown) => void) => () => void;
};
@@ -1646,34 +1643,13 @@ function createMockWorktreeAPI(): WorktreeAPI {
};
},
openInEditor: async (worktreePath: string, editorCommand?: string) => {
const ANTIGRAVITY_EDITOR_COMMAND = 'antigravity';
const ANTIGRAVITY_LEGACY_COMMAND = 'agy';
// Map editor commands to display names
const editorNameMap: Record<string, string> = {
cursor: 'Cursor',
code: 'VS Code',
zed: 'Zed',
subl: 'Sublime Text',
windsurf: 'Windsurf',
trae: 'Trae',
rider: 'Rider',
webstorm: 'WebStorm',
xed: 'Xcode',
studio: 'Android Studio',
[ANTIGRAVITY_EDITOR_COMMAND]: 'Antigravity',
[ANTIGRAVITY_LEGACY_COMMAND]: 'Antigravity',
open: 'Finder',
explorer: 'Explorer',
'xdg-open': 'File Manager',
};
const editorName = editorCommand ? (editorNameMap[editorCommand] ?? 'Editor') : 'VS Code';
console.log('[Mock] Opening in editor:', worktreePath, 'using:', editorName);
openInEditor: async (worktreePath: string) => {
console.log('[Mock] Opening in editor:', worktreePath);
return {
success: true,
result: {
message: `Opened ${worktreePath} in ${editorName}`,
editorName,
message: `Opened ${worktreePath} in VS Code`,
editorName: 'VS Code',
},
};
},
@@ -1689,32 +1665,6 @@ function createMockWorktreeAPI(): WorktreeAPI {
};
},
getAvailableEditors: async () => {
console.log('[Mock] Getting available editors');
return {
success: true,
result: {
editors: [
{ name: 'VS Code', command: 'code' },
{ name: 'Finder', command: 'open' },
],
},
};
},
refreshEditors: async () => {
console.log('[Mock] Refreshing available editors');
return {
success: true,
result: {
editors: [
{ name: 'VS Code', command: 'code' },
{ name: 'Finder', command: 'open' },
],
message: 'Found 2 available editors',
},
};
},
initGit: async (projectPath: string) => {
console.log('[Mock] Initializing git:', projectPath);
return {
@@ -1770,47 +1720,6 @@ function createMockWorktreeAPI(): WorktreeAPI {
},
};
},
getInitScript: async (projectPath: string) => {
console.log('[Mock] Getting init script:', { projectPath });
return {
success: true,
exists: false,
content: '',
path: `${projectPath}/.automaker/worktree-init.sh`,
};
},
setInitScript: async (projectPath: string, content: string) => {
console.log('[Mock] Setting init script:', { projectPath, content });
return {
success: true,
path: `${projectPath}/.automaker/worktree-init.sh`,
};
},
deleteInitScript: async (projectPath: string) => {
console.log('[Mock] Deleting init script:', { projectPath });
return {
success: true,
};
},
runInitScript: async (projectPath: string, worktreePath: string, branch: string) => {
console.log('[Mock] Running init script:', { projectPath, worktreePath, branch });
return {
success: true,
message: 'Init script started (mock)',
};
},
onInitScriptEvent: (callback) => {
console.log('[Mock] Subscribing to init script events');
// Return unsubscribe function
return () => {
console.log('[Mock] Unsubscribing from init script events');
};
},
};
}

View File

@@ -39,7 +39,6 @@ import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/typ
import { getGlobalFileBrowser } from '@/contexts/file-browser-context';
const logger = createLogger('HttpClient');
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
// Cached server URL (set during initialization in Electron mode)
let cachedServerUrl: string | null = null;
@@ -70,7 +69,6 @@ const handleUnauthorized = (): void => {
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: '{}',
cache: NO_STORE_CACHE_MODE,
}).catch(() => {});
notifyLoggedOut();
};
@@ -298,7 +296,6 @@ export const checkAuthStatus = async (): Promise<{
const response = await fetch(`${getServerUrl()}/api/auth/status`, {
credentials: 'include',
headers: getApiKey() ? { 'X-API-Key': getApiKey()! } : undefined,
cache: NO_STORE_CACHE_MODE,
});
const data = await response.json();
return {
@@ -325,7 +322,6 @@ export const login = async (
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ apiKey }),
cache: NO_STORE_CACHE_MODE,
});
const data = await response.json();
@@ -365,7 +361,6 @@ export const fetchSessionToken = async (): Promise<boolean> => {
try {
const response = await fetch(`${getServerUrl()}/api/auth/status`, {
credentials: 'include', // Send the session cookie
cache: NO_STORE_CACHE_MODE,
});
if (!response.ok) {
@@ -396,7 +391,6 @@ export const logout = async (): Promise<{ success: boolean }> => {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
cache: NO_STORE_CACHE_MODE,
});
// Clear the cached session token
@@ -445,7 +439,6 @@ export const verifySession = async (): Promise<boolean> => {
const response = await fetch(`${getServerUrl()}/api/settings/status`, {
headers,
credentials: 'include',
cache: NO_STORE_CACHE_MODE,
// Avoid hanging indefinitely during backend reloads or network issues
signal: AbortSignal.timeout(2500),
});
@@ -482,7 +475,6 @@ export const checkSandboxEnvironment = async (): Promise<{
try {
const response = await fetch(`${getServerUrl()}/api/health/environment`, {
method: 'GET',
cache: NO_STORE_CACHE_MODE,
signal: AbortSignal.timeout(5000),
});
@@ -507,10 +499,7 @@ type EventType =
| 'issue-validation:event'
| 'backlog-plan:event'
| 'ideation:stream'
| 'ideation:analysis'
| 'worktree:init-started'
| 'worktree:init-output'
| 'worktree:init-completed';
| 'ideation:analysis';
type EventCallback = (payload: unknown) => void;
@@ -567,7 +556,6 @@ export class HttpApiClient implements ElectronAPI {
const response = await fetch(`${this.serverUrl}/api/auth/token`, {
headers,
credentials: 'include',
cache: NO_STORE_CACHE_MODE,
});
if (response.status === 401 || response.status === 403) {
@@ -599,17 +587,6 @@ export class HttpApiClient implements ElectronAPI {
this.isConnecting = true;
// Wait for API key initialization to complete before attempting connection
// This prevents race conditions during app startup
waitForApiKeyInit()
.then(() => this.doConnectWebSocketInternal())
.catch((error) => {
logger.error('Failed to initialize for WebSocket connection:', error);
this.isConnecting = false;
});
}
private doConnectWebSocketInternal(): void {
// Electron mode typically authenticates with the injected API key.
// However, in external-server/cookie-auth flows, the API key may be unavailable.
// In that case, fall back to the same wsToken/cookie authentication used in web mode
@@ -794,7 +771,6 @@ export class HttpApiClient implements ElectronAPI {
const response = await fetch(`${this.serverUrl}${endpoint}`, {
headers: this.getHeaders(),
credentials: 'include', // Include cookies for session auth
cache: NO_STORE_CACHE_MODE,
});
if (response.status === 401 || response.status === 403) {
@@ -849,14 +825,13 @@ export class HttpApiClient implements ElectronAPI {
return response.json();
}
private async httpDelete<T>(endpoint: string, body?: unknown): Promise<T> {
private async httpDelete<T>(endpoint: string): Promise<T> {
// Ensure API key is initialized before making request
await waitForApiKeyInit();
const response = await fetch(`${this.serverUrl}${endpoint}`, {
method: 'DELETE',
headers: this.getHeaders(),
credentials: 'include', // Include cookies for session auth
body: body ? JSON.stringify(body) : undefined,
});
if (response.status === 401 || response.status === 403) {
@@ -1440,67 +1415,6 @@ export class HttpApiClient implements ElectronAPI {
error?: string;
}> => this.get('/api/setup/opencode-status'),
// OpenCode Dynamic Model Discovery
getOpencodeModels: (
refresh?: boolean
): Promise<{
success: boolean;
models?: Array<{
id: string;
name: string;
modelString: string;
provider: string;
description: string;
supportsTools: boolean;
supportsVision: boolean;
tier: string;
default?: boolean;
}>;
count?: number;
cached?: boolean;
error?: string;
}> => this.get(`/api/setup/opencode/models${refresh ? '?refresh=true' : ''}`),
refreshOpencodeModels: (): Promise<{
success: boolean;
models?: Array<{
id: string;
name: string;
modelString: string;
provider: string;
description: string;
supportsTools: boolean;
supportsVision: boolean;
tier: string;
default?: boolean;
}>;
count?: number;
error?: string;
}> => this.post('/api/setup/opencode/models/refresh'),
getOpencodeProviders: (): Promise<{
success: boolean;
providers?: Array<{
id: string;
name: string;
authenticated: boolean;
authMethod?: 'oauth' | 'api_key';
}>;
authenticated?: Array<{
id: string;
name: string;
authenticated: boolean;
authMethod?: 'oauth' | 'api_key';
}>;
error?: string;
}> => this.get('/api/setup/opencode/providers'),
clearOpencodeCache: (): Promise<{
success: boolean;
message?: string;
error?: string;
}> => this.post('/api/setup/opencode/cache/clear'),
onInstallProgress: (callback: (progress: unknown) => void) => {
return this.subscribeToEvent('agent:stream', callback);
},
@@ -1545,8 +1459,7 @@ export class HttpApiClient implements ElectronAPI {
featureId: string,
updates: Partial<Feature>,
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
preEnhancementDescription?: string
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'
) =>
this.post('/api/features/update', {
projectPath,
@@ -1554,7 +1467,6 @@ export class HttpApiClient implements ElectronAPI {
updates,
descriptionHistorySource,
enhancementMode,
preEnhancementDescription,
}),
delete: (projectPath: string, featureId: string) =>
this.post('/api/features/delete', { projectPath, featureId }),
@@ -1700,11 +1612,9 @@ export class HttpApiClient implements ElectronAPI {
this.post('/api/worktree/list-branches', { worktreePath }),
switchBranch: (worktreePath: string, branchName: string) =>
this.post('/api/worktree/switch-branch', { worktreePath, branchName }),
openInEditor: (worktreePath: string, editorCommand?: string) =>
this.post('/api/worktree/open-in-editor', { worktreePath, editorCommand }),
openInEditor: (worktreePath: string) =>
this.post('/api/worktree/open-in-editor', { worktreePath }),
getDefaultEditor: () => this.get('/api/worktree/default-editor'),
getAvailableEditors: () => this.get('/api/worktree/available-editors'),
refreshEditors: () => this.post('/api/worktree/refresh-editors', {}),
initGit: (projectPath: string) => this.post('/api/worktree/init-git', { projectPath }),
startDevServer: (projectPath: string, worktreePath: string) =>
this.post('/api/worktree/start-dev', { projectPath, worktreePath }),
@@ -1712,37 +1622,6 @@ export class HttpApiClient implements ElectronAPI {
listDevServers: () => this.post('/api/worktree/list-dev-servers', {}),
getPRInfo: (worktreePath: string, branchName: string) =>
this.post('/api/worktree/pr-info', { worktreePath, branchName }),
// Init script methods
getInitScript: (projectPath: string) =>
this.get(`/api/worktree/init-script?projectPath=${encodeURIComponent(projectPath)}`),
setInitScript: (projectPath: string, content: string) =>
this.put('/api/worktree/init-script', { projectPath, content }),
deleteInitScript: (projectPath: string) =>
this.httpDelete('/api/worktree/init-script', { projectPath }),
runInitScript: (projectPath: string, worktreePath: string, branch: string) =>
this.post('/api/worktree/run-init-script', { projectPath, worktreePath, branch }),
onInitScriptEvent: (
callback: (event: {
type: 'worktree:init-started' | 'worktree:init-output' | 'worktree:init-completed';
payload: unknown;
}) => void
) => {
// Note: subscribeToEvent callback receives (payload) not (_, payload)
const unsub1 = this.subscribeToEvent('worktree:init-started', (payload) =>
callback({ type: 'worktree:init-started', payload })
);
const unsub2 = this.subscribeToEvent('worktree:init-output', (payload) =>
callback({ type: 'worktree:init-output', payload })
);
const unsub3 = this.subscribeToEvent('worktree:init-completed', (payload) =>
callback({ type: 'worktree:init-completed', payload })
);
return () => {
unsub1();
unsub2();
unsub3();
};
},
};
// Git API
@@ -2263,10 +2142,9 @@ export class HttpApiClient implements ElectronAPI {
removedDependencies: string[];
addedDependencies: string[];
}>;
},
branchName?: string
}
): Promise<{ success: boolean; appliedChanges?: string[]; error?: string }> =>
this.post('/api/backlog-plan/apply', { projectPath, plan, branchName }),
this.post('/api/backlog-plan/apply', { projectPath, plan }),
onEvent: (callback: (data: unknown) => void): (() => void) => {
return this.subscribeToEvent('backlog-plan:event', callback as EventCallback);

View File

@@ -12,14 +12,12 @@ import { useSetupStore } from '@/store/setup-store';
import { useAuthStore } from '@/store/auth-store';
import { getElectronAPI, isElectron } from '@/lib/electron';
import { isMac } from '@/lib/utils';
import { initializeProject } from '@/lib/project-init';
import {
initApiKey,
verifySession,
checkSandboxEnvironment,
getServerUrlSync,
getHttpApiClient,
handleServerOffline,
} from '@/lib/http-api-client';
import {
hydrateStoreFromSettings,
@@ -27,29 +25,13 @@ import {
performSettingsMigration,
} from '@/hooks/use-settings-migration';
import { Toaster } from 'sonner';
import { Menu } from 'lucide-react';
import { ThemeOption, themeOptions } from '@/config/theme-options';
import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog';
import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen';
import { LoadingState } from '@/components/ui/loading-state';
import { useProjectSettingsLoader } from '@/hooks/use-project-settings-loader';
import type { Project } from '@/lib/electron';
const logger = createLogger('RootLayout');
const SERVER_READY_MAX_ATTEMPTS = 8;
const SERVER_READY_BACKOFF_BASE_MS = 250;
const SERVER_READY_MAX_DELAY_MS = 1500;
const SERVER_READY_TIMEOUT_MS = 2000;
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
const AUTO_OPEN_HISTORY_INDEX = 0;
const SINGLE_PROJECT_COUNT = 1;
const DEFAULT_LAST_OPENED_TIME_MS = 0;
const AUTO_OPEN_STATUS = {
idle: 'idle',
opening: 'opening',
done: 'done',
} as const;
type AutoOpenStatus = (typeof AUTO_OPEN_STATUS)[keyof typeof AUTO_OPEN_STATUS];
// Apply stored theme immediately on page load (before React hydration)
// This prevents flash of default theme on login/setup pages
@@ -78,90 +60,15 @@ function applyStoredTheme(): void {
// Apply stored theme immediately (runs synchronously before render)
applyStoredTheme();
async function waitForServerReady(): Promise<boolean> {
const serverUrl = getServerUrlSync();
for (let attempt = 1; attempt <= SERVER_READY_MAX_ATTEMPTS; attempt++) {
try {
const response = await fetch(`${serverUrl}/api/health`, {
method: 'GET',
signal: AbortSignal.timeout(SERVER_READY_TIMEOUT_MS),
cache: NO_STORE_CACHE_MODE,
});
if (response.ok) {
return true;
}
} catch (error) {
logger.warn(`Server readiness check failed (attempt ${attempt})`, error);
}
const delayMs = Math.min(SERVER_READY_MAX_DELAY_MS, SERVER_READY_BACKOFF_BASE_MS * attempt);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
return false;
}
function getProjectLastOpenedMs(project: Project): number {
if (!project.lastOpened) return DEFAULT_LAST_OPENED_TIME_MS;
const parsed = Date.parse(project.lastOpened);
return Number.isNaN(parsed) ? DEFAULT_LAST_OPENED_TIME_MS : parsed;
}
function selectAutoOpenProject(
currentProject: Project | null,
projects: Project[],
projectHistory: string[]
): Project | null {
if (currentProject) return currentProject;
if (projectHistory.length > 0) {
const historyProjectId = projectHistory[AUTO_OPEN_HISTORY_INDEX];
const historyProject = projects.find((project) => project.id === historyProjectId);
if (historyProject) {
return historyProject;
}
}
if (projects.length === SINGLE_PROJECT_COUNT) {
return projects[AUTO_OPEN_HISTORY_INDEX] ?? null;
}
if (projects.length > SINGLE_PROJECT_COUNT) {
let latestProject: Project | null = projects[AUTO_OPEN_HISTORY_INDEX] ?? null;
let latestTimestamp = latestProject
? getProjectLastOpenedMs(latestProject)
: DEFAULT_LAST_OPENED_TIME_MS;
for (const project of projects) {
const openedAt = getProjectLastOpenedMs(project);
if (openedAt > latestTimestamp) {
latestTimestamp = openedAt;
latestProject = project;
}
}
return latestProject;
}
return null;
}
function RootLayoutContent() {
const location = useLocation();
const {
setIpcConnected,
projects,
currentProject,
projectHistory,
upsertAndSetCurrentProject,
getEffectiveTheme,
skipSandboxWarning,
setSkipSandboxWarning,
fetchCodexModels,
sidebarOpen,
toggleSidebar,
} = useAppStore();
const { setupComplete, codexCliStatus } = useSetupStore();
const navigate = useNavigate();
@@ -169,7 +76,6 @@ function RootLayoutContent() {
const [streamerPanelOpen, setStreamerPanelOpen] = useState(false);
const authChecked = useAuthStore((s) => s.authChecked);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const settingsLoaded = useAuthStore((s) => s.settingsLoaded);
const { openFileBrowser } = useFileBrowser();
// Load project settings when switching projects
@@ -179,22 +85,6 @@ function RootLayoutContent() {
const isLoginRoute = location.pathname === '/login';
const isLoggedOutRoute = location.pathname === '/logged-out';
const isDashboardRoute = location.pathname === '/dashboard';
const isBoardRoute = location.pathname === '/board';
const isRootRoute = location.pathname === '/';
const [autoOpenStatus, setAutoOpenStatus] = useState<AutoOpenStatus>(AUTO_OPEN_STATUS.idle);
const autoOpenCandidate = selectAutoOpenProject(currentProject, projects, projectHistory);
const canAutoOpen =
authChecked &&
isAuthenticated &&
settingsLoaded &&
setupComplete &&
!isLoginRoute &&
!isLoggedOutRoute &&
!isSetupRoute &&
!!autoOpenCandidate;
const shouldAutoOpen = canAutoOpen && autoOpenStatus !== AUTO_OPEN_STATUS.done;
const shouldBlockForSettings =
authChecked && isAuthenticated && !settingsLoaded && !isLoginRoute && !isLoggedOutRoute;
// Sandbox environment check state
type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed';
@@ -328,11 +218,9 @@ function RootLayoutContent() {
// Works for ALL modes (unified flow)
useEffect(() => {
const handleLoggedOut = () => {
logger.warn('automaker:logged-out event received!');
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
if (location.pathname !== '/logged-out') {
logger.warn('Navigating to /logged-out due to logged-out event');
navigate({ to: '/logged-out' });
}
};
@@ -348,7 +236,6 @@ function RootLayoutContent() {
// Redirects to login page which will detect server is offline and show error UI.
useEffect(() => {
const handleServerOffline = () => {
logger.warn('automaker:server-offline event received!');
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
// Navigate to login - the login page will detect server is offline and show appropriate UI
@@ -379,12 +266,6 @@ function RootLayoutContent() {
// Initialize API key for Electron mode
await initApiKey();
const serverReady = await waitForServerReady();
if (!serverReady) {
handleServerOffline();
return;
}
// 1. Verify session (Single Request, ALL modes)
let isValid = false;
try {
@@ -421,28 +302,13 @@ function RootLayoutContent() {
// Hydrate store with the final settings (merged if migration occurred)
hydrateStoreFromSettings(finalSettings);
// CRITICAL: Wait for React to render the hydrated state before
// signaling completion. Zustand updates are synchronous, but React
// hasn't necessarily re-rendered yet. This prevents race conditions
// where useSettingsSync reads state before the UI has updated.
await new Promise((resolve) => setTimeout(resolve, 0));
// Signal that settings hydration is complete FIRST.
// This ensures useSettingsSync's waitForMigrationComplete() will resolve
// immediately when it starts after auth state change, preventing it from
// syncing default empty state to the server.
// Signal that settings hydration is complete so useSettingsSync can start
signalMigrationComplete();
// Now mark auth as checked AND settings as loaded.
// The settingsLoaded flag ensures useSettingsSync won't start syncing
// until settings have been properly hydrated, even if authChecked was
// set earlier by login-view.
useAuthStore.getState().setAuthState({
isAuthenticated: true,
authChecked: true,
settingsLoaded: true,
});
// Mark auth as checked only after settings hydration succeeded.
useAuthStore
.getState()
.setAuthState({ isAuthenticated: true, authChecked: true });
return;
}
@@ -507,38 +373,17 @@ function RootLayoutContent() {
// - If authenticated but setup incomplete: force /setup
// - If authenticated and setup complete: allow access to app
useEffect(() => {
logger.debug('Routing effect triggered:', {
authChecked,
isAuthenticated,
settingsLoaded,
setupComplete,
pathname: location.pathname,
});
// Wait for auth check to complete before enforcing any redirects
if (!authChecked) {
logger.debug('Auth not checked yet, skipping routing');
return;
}
if (!authChecked) return;
// Unauthenticated -> force /logged-out (but allow /login so user can authenticate)
if (!isAuthenticated) {
logger.warn('Not authenticated, redirecting to /logged-out. Auth state:', {
authChecked,
isAuthenticated,
settingsLoaded,
currentPath: location.pathname,
});
if (location.pathname !== '/logged-out' && location.pathname !== '/login') {
navigate({ to: '/logged-out' });
}
return;
}
// Wait for settings to be loaded before making setupComplete-based routing decisions
// This prevents redirecting to /setup before we know the actual setupComplete value
if (!settingsLoaded) return;
// Authenticated -> determine whether setup is required
if (!setupComplete && location.pathname !== '/setup') {
navigate({ to: '/setup' });
@@ -549,46 +394,7 @@ function RootLayoutContent() {
if (setupComplete && location.pathname === '/setup') {
navigate({ to: '/dashboard' });
}
}, [authChecked, isAuthenticated, settingsLoaded, setupComplete, location.pathname, navigate]);
// Fallback: If auth is checked and authenticated but settings not loaded,
// it means login-view or another component set auth state before __root.tsx's
// auth flow completed. Load settings now to prevent sync with empty state.
useEffect(() => {
// Only trigger if auth is valid but settings aren't loaded yet
// This handles the case where login-view sets authChecked=true before we finish our auth flow
if (!authChecked || !isAuthenticated || settingsLoaded) {
logger.debug('Fallback skipped:', { authChecked, isAuthenticated, settingsLoaded });
return;
}
logger.info('Auth valid but settings not loaded - triggering fallback load');
const loadSettings = async () => {
const api = getHttpApiClient();
try {
logger.debug('Fetching settings in fallback...');
const settingsResult = await api.settings.getGlobal();
logger.debug('Settings fetched:', settingsResult.success ? 'success' : 'failed');
if (settingsResult.success && settingsResult.settings) {
const { settings: finalSettings } = await performSettingsMigration(
settingsResult.settings as unknown as Parameters<typeof performSettingsMigration>[0]
);
logger.debug('Settings migrated, hydrating stores...');
hydrateStoreFromSettings(finalSettings);
await new Promise((resolve) => setTimeout(resolve, 0));
signalMigrationComplete();
logger.debug('Setting settingsLoaded=true');
useAuthStore.getState().setAuthState({ settingsLoaded: true });
logger.info('Fallback settings load completed successfully');
}
} catch (error) {
logger.error('Failed to load settings in fallback:', error);
}
};
loadSettings();
}, [authChecked, isAuthenticated, settingsLoaded]);
}, [authChecked, isAuthenticated, setupComplete, location.pathname, navigate]);
useEffect(() => {
setGlobalFileBrowser(openFileBrowser);
@@ -622,10 +428,7 @@ function RootLayoutContent() {
// Redirect from welcome page based on project state
useEffect(() => {
if (isMounted && isRootRoute) {
if (!settingsLoaded || shouldAutoOpen) {
return;
}
if (isMounted && location.pathname === '/') {
if (currentProject) {
// Project is selected, go to board
navigate({ to: '/board' });
@@ -634,66 +437,14 @@ function RootLayoutContent() {
navigate({ to: '/dashboard' });
}
}
}, [isMounted, currentProject, isRootRoute, navigate, shouldAutoOpen, settingsLoaded]);
// Auto-open the most recent project on startup
useEffect(() => {
if (!canAutoOpen) return;
if (autoOpenStatus !== AUTO_OPEN_STATUS.idle) return;
if (!autoOpenCandidate) return;
setAutoOpenStatus(AUTO_OPEN_STATUS.opening);
const openProject = async () => {
try {
const initResult = await initializeProject(autoOpenCandidate.path);
if (!initResult.success) {
logger.warn('Auto-open project failed:', initResult.error);
if (isRootRoute) {
navigate({ to: '/dashboard' });
}
return;
}
if (!currentProject || currentProject.id !== autoOpenCandidate.id) {
upsertAndSetCurrentProject(
autoOpenCandidate.path,
autoOpenCandidate.name,
autoOpenCandidate.theme
);
}
if (isRootRoute) {
navigate({ to: '/board' });
}
} catch (error) {
logger.error('Auto-open project crashed:', error);
if (isRootRoute) {
navigate({ to: '/dashboard' });
}
} finally {
setAutoOpenStatus(AUTO_OPEN_STATUS.done);
}
};
void openProject();
}, [
canAutoOpen,
autoOpenStatus,
autoOpenCandidate,
currentProject,
navigate,
upsertAndSetCurrentProject,
isRootRoute,
]);
}, [isMounted, currentProject, location.pathname, navigate]);
// Bootstrap Codex models on app startup (after auth completes)
useEffect(() => {
// Only fetch if authenticated and Codex CLI is available
if (!authChecked || !isAuthenticated) return;
const isCodexAvailable = codexCliStatus?.installed && codexCliStatus?.hasApiKey;
const isCodexAvailable = codexCliStatus?.installed && codexCliStatus?.auth?.authenticated;
if (!isCodexAvailable) return;
// Fetch models in the background
@@ -761,22 +512,6 @@ function RootLayoutContent() {
);
}
if (shouldBlockForSettings) {
return (
<main className="flex h-screen items-center justify-center" data-testid="app-container">
<LoadingState message="Loading settings..." />
</main>
);
}
if (shouldAutoOpen) {
return (
<main className="flex h-screen items-center justify-center" data-testid="app-container">
<LoadingState message="Opening project..." />
</main>
);
}
// Show setup page (full screen, no sidebar) - authenticated only
if (isSetupRoute) {
return (
@@ -814,16 +549,6 @@ function RootLayoutContent() {
/>
)}
<Sidebar />
{/* Mobile menu toggle button - only shows when sidebar is closed on mobile */}
{!sidebarOpen && (
<button
onClick={toggleSidebar}
className="fixed top-3 left-3 z-50 p-2 rounded-lg bg-card/90 backdrop-blur-sm border border-border shadow-lg lg:hidden"
aria-label="Open menu"
>
<Menu className="w-5 h-5 text-foreground" />
</button>
)}
<div
className="flex-1 flex flex-col overflow-hidden transition-all duration-300"
style={{ marginRight: streamerPanelOpen ? '250px' : '0' }}

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