mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 14:22:02 +00:00
Compare commits
2 Commits
v0.10.0
...
feat/inter
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4ce1f331b | ||
|
|
66b68ea4eb |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -95,4 +95,3 @@ check-sync.sh
|
||||
# API key files
|
||||
data/.api-key
|
||||
data/credentials.json
|
||||
data/
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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}`);
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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';
|
||||
|
||||
55
apps/ui/src/components/shared/onboarding/constants.ts
Normal file
55
apps/ui/src/components/shared/onboarding/constants.ts
Normal 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;
|
||||
21
apps/ui/src/components/shared/onboarding/index.ts
Normal file
21
apps/ui/src/components/shared/onboarding/index.ts
Normal 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';
|
||||
545
apps/ui/src/components/shared/onboarding/onboarding-wizard.tsx
Normal file
545
apps/ui/src/components/shared/onboarding/onboarding-wizard.tsx
Normal 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);
|
||||
}
|
||||
109
apps/ui/src/components/shared/onboarding/types.ts
Normal file
109
apps/ui/src/components/shared/onboarding/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './enhancement-constants';
|
||||
export * from './enhance-with-ai';
|
||||
export * from './enhancement-history-button';
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,6 @@ export type SettingsViewId =
|
||||
| 'keyboard'
|
||||
| 'audio'
|
||||
| 'defaults'
|
||||
| 'worktrees'
|
||||
| 'account'
|
||||
| 'security'
|
||||
| 'danger';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { WorktreesSection } from './worktrees-section';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user